Skip to main content

openkind_core/
question.rs

1//! Question types — Noul, Choice, Score.
2//!
3//! Spec: <https://docs.typesafe.ai/api#question-types>
4//! > A `Question` is one of three types, set by its `type` field. All three
5//! > share `type` and `instructions`; each adds its own `criteria`.
6
7use std::collections::HashMap;
8
9use schemars::JsonSchema;
10use serde::de::{Error as _, IgnoredAny, MapAccess, Visitor};
11use serde::{Deserialize, Deserializer, Serialize};
12
13/// Tagged union of the three question kinds. The `type` field is the discriminant.
14#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
15#[serde(tag = "type", rename_all = "lowercase")]
16pub enum Question {
17    /// Boolean probability evaluation (yes/no), returning a single probability value.
18    Noul(NoulQuestion),
19    /// Categorical choice evaluation across discrete options.
20    Choice(ChoiceQuestion),
21    /// Ordinal rating evaluation rated along an ordered rubric of at least 2 levels.
22    Score(ScoreQuestion),
23}
24
25#[derive(Deserialize)]
26#[serde(field_identifier, rename_all = "lowercase")]
27enum QuestionField {
28    Type,
29    Instructions,
30    Criteria,
31    #[serde(other)]
32    Other,
33}
34
35#[derive(Clone, Copy, Deserialize)]
36#[serde(rename_all = "lowercase")]
37enum QuestionTag {
38    Noul,
39    Choice,
40    Score,
41}
42
43// Serde's internally-tagged derive buffers the entire object into `Content`
44// before it can pick a variant, allocating twice per question on every
45// request parse. The visitor below streams the same wire format directly:
46// it reads `type` (emitted first by this engine and the reference SDKs) and
47// deserializes the remaining fields straight into the selected variant.
48// Fields that legitimately arrive before `type` are buffered as JSON values
49// and converted once the tag is known, and every rejected shape (missing or
50// unknown tag, missing or duplicate fields, wrong field types) still fails
51// exactly like the derived implementation.
52impl<'de> Deserialize<'de> for Question {
53    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
54    where
55        D: Deserializer<'de>,
56    {
57        struct QuestionVisitor;
58
59        impl<'de> Visitor<'de> for QuestionVisitor {
60            type Value = Question;
61
62            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63                formatter.write_str("a tagged question object")
64            }
65
66            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
67            where
68                A: MapAccess<'de>,
69            {
70                let mut tag: Option<QuestionTag> = None;
71                // `instructions` has the same shape in every variant, so it
72                // streams even before the tag is known. `criteria` differs
73                // per variant and is buffered as a JSON value if it arrives
74                // before the tag.
75                let mut instructions: Option<Instructions> = None;
76                let mut early_criteria: Option<serde_json::Value> = None;
77                let mut noul_criteria: Option<Option<NoulCriteria>> = None;
78                let mut choice_criteria: Option<HashMap<String, Option<String>>> = None;
79                let mut score_criteria: Option<Vec<String>> = None;
80
81                while let Some(key) = map.next_key::<QuestionField>()? {
82                    match key {
83                        QuestionField::Type => {
84                            if tag.is_some() {
85                                return Err(A::Error::duplicate_field("type"));
86                            }
87                            tag = Some(map.next_value()?);
88                        }
89                        QuestionField::Instructions => {
90                            if instructions.is_some() {
91                                return Err(A::Error::duplicate_field("instructions"));
92                            }
93                            instructions = Some(map.next_value()?);
94                        }
95                        QuestionField::Criteria => {
96                            let already_buffered = early_criteria.is_some()
97                                || noul_criteria.is_some()
98                                || choice_criteria.is_some()
99                                || score_criteria.is_some();
100                            if already_buffered {
101                                return Err(A::Error::duplicate_field("criteria"));
102                            }
103                            match tag {
104                                None => early_criteria = Some(map.next_value()?),
105                                Some(QuestionTag::Noul) => {
106                                    noul_criteria = Some(map.next_value::<Option<NoulCriteria>>()?)
107                                }
108                                Some(QuestionTag::Choice) => {
109                                    choice_criteria = Some(map.next_value()?)
110                                }
111                                Some(QuestionTag::Score) => {
112                                    score_criteria = Some(map.next_value()?)
113                                }
114                            }
115                        }
116                        QuestionField::Other => {
117                            let _ = map.next_value::<IgnoredAny>()?;
118                        }
119                    }
120                }
121
122                let tag = tag.ok_or_else(|| A::Error::missing_field("type"))?;
123                let instructions =
124                    instructions.ok_or_else(|| A::Error::missing_field("instructions"))?;
125                match tag {
126                    QuestionTag::Noul => Ok(Question::Noul(NoulQuestion {
127                        instructions,
128                        criteria: match noul_criteria {
129                            Some(criteria) => criteria,
130                            // `null` criteria deserializes to `None`, so the
131                            // buffered value converts through the Option.
132                            None => convert_criteria::<Option<NoulCriteria>, _>(early_criteria)?
133                                .flatten(),
134                        },
135                    })),
136                    QuestionTag::Choice => Ok(Question::Choice(ChoiceQuestion {
137                        instructions,
138                        criteria: match choice_criteria {
139                            Some(criteria) => criteria,
140                            None => convert_criteria(early_criteria)?
141                                .ok_or_else(|| A::Error::missing_field("criteria"))?,
142                        },
143                    })),
144                    QuestionTag::Score => Ok(Question::Score(ScoreQuestion {
145                        instructions,
146                        criteria: match score_criteria {
147                            Some(criteria) => criteria,
148                            None => convert_criteria(early_criteria)?
149                                .ok_or_else(|| A::Error::missing_field("criteria"))?,
150                        },
151                    })),
152                }
153            }
154        }
155
156        deserializer.deserialize_map(QuestionVisitor)
157    }
158}
159
160/// Convert a `criteria` value that arrived before the `type` tag into the
161/// selected variant's criteria type.
162fn convert_criteria<T, E>(early: Option<serde_json::Value>) -> Result<Option<T>, E>
163where
164    T: serde::de::DeserializeOwned,
165    E: serde::de::Error,
166{
167    early
168        .map(serde_json::from_value)
169        .transpose()
170        .map_err(E::custom)
171}
172
173/// `instructions` is `string | object | array` per the spec.
174/// We keep it as a generic JSON value so the model can be prompted with the
175/// exact shape the caller chose.
176pub type Instructions = serde_json::Value;
177
178// ---------- Noul ----------
179
180/// Yes/no question. Returns the probability the answer is yes.
181///
182/// Spec: <https://docs.typesafe.ai/api#noul>
183#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
184pub struct NoulQuestion {
185    /// Instructions describing what judgment is requested.
186    /// Can be a string, object, or array.
187    pub instructions: Instructions,
188    /// Optional descriptions of what a yes and a no mean.
189    /// Reserved keys: `true`, `false`.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub criteria: Option<NoulCriteria>,
192}
193
194/// Reserved `true`/`false` keys. `r#true` / `r#false` because those are
195/// keywords in Rust (and in the JSON spec).
196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
197pub struct NoulCriteria {
198    /// Meaning of a true/yes decision.
199    #[serde(rename = "true")]
200    pub r#true: String,
201    /// Meaning of a false/no decision.
202    #[serde(rename = "false")]
203    pub r#false: String,
204}
205
206// ---------- Choice ----------
207
208/// Pick one option from a set. Spec: <https://docs.typesafe.ai/api#choice>
209#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
210pub struct ChoiceQuestion {
211    /// Instructions describing what categorical decision is requested.
212    pub instructions: Instructions,
213    /// Map of option key → rubric description. **Values may be `null`**
214    /// ("use null when an option needs no extra detail").
215    pub criteria: HashMap<String, Option<String>>,
216}
217
218// ---------- Score ----------
219
220/// Rate along an ordered rubric. Spec: <https://docs.typesafe.ai/api#score>
221#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
222pub struct ScoreQuestion {
223    /// Instructions describing what ordinal score is requested.
224    pub instructions: Instructions,
225    /// Ordered array of level descriptions. Must contain at least 2.
226    pub criteria: Vec<String>,
227}