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}