Skip to main content

typesafe/
question.rs

1//! Typed questions: [`Noul`], [`Choice`] and [`Score`].
2//!
3//! Instructions, option descriptions and score levels accept any JSON value (`&str`, `String`,
4//! or `serde_json::json!({...})` for structured rubrics), per the API's "advanced structure" support.
5
6use indexmap::IndexMap;
7use serde::{Serialize, Serializer};
8use serde_json::Value;
9
10use crate::error::{Error, Result};
11
12/// A yes/no question; the answer is the probability of "yes".
13#[derive(Debug, Clone, Default, PartialEq, Serialize)]
14#[non_exhaustive]
15#[serde(tag = "type", rename = "noul")]
16pub struct Noul {
17    /// The question to evaluate.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub instructions: Option<Value>,
20    /// Optional descriptions of what yes and no mean.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub criteria: Option<NoulCriteria>,
23}
24
25/// Descriptions of the yes (`true`) and no (`false`) outcomes of a [`Noul`].
26#[derive(Debug, Clone, Default, PartialEq, Serialize)]
27#[non_exhaustive]
28pub struct NoulCriteria {
29    /// What a yes (value near 1) means.
30    #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
31    pub yes: Option<Value>,
32    /// What a no (value near 0) means.
33    #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
34    pub no: Option<Value>,
35}
36
37impl Noul {
38    /// A yes/no question with the given instructions.
39    pub fn new(instructions: impl Into<Value>) -> Self {
40        Self {
41            instructions: Some(instructions.into()),
42            criteria: None,
43        }
44    }
45
46    /// Describe what a yes means.
47    pub fn when_true(mut self, description: impl Into<Value>) -> Self {
48        self.criteria.get_or_insert_with(Default::default).yes = Some(description.into());
49        self
50    }
51
52    /// Describe what a no means.
53    pub fn when_false(mut self, description: impl Into<Value>) -> Self {
54        self.criteria.get_or_insert_with(Default::default).no = Some(description.into());
55        self
56    }
57}
58
59/// Pick one option from a set you define.
60#[derive(Debug, Clone, Default, PartialEq, Serialize)]
61#[non_exhaustive]
62#[serde(tag = "type", rename = "choice")]
63pub struct Choice {
64    /// What the model should decide.
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub instructions: Option<Value>,
67    /// Option label → description (`None` is sent as `null`: an undescribed option).
68    pub criteria: IndexMap<String, Option<Value>>,
69}
70
71impl Choice {
72    /// A choice with instructions and no options yet; add them with [`Choice::option`].
73    pub fn new(instructions: impl Into<Value>) -> Self {
74        Self {
75            instructions: Some(instructions.into()),
76            criteria: IndexMap::new(),
77        }
78    }
79
80    /// A choice between undescribed labels.
81    pub fn from_labels<I, S>(instructions: impl Into<Value>, labels: I) -> Self
82    where
83        I: IntoIterator<Item = S>,
84        S: Into<String>,
85    {
86        let mut c = Self::new(instructions);
87        c.criteria
88            .extend(labels.into_iter().map(|l| (l.into(), None)));
89        c
90    }
91
92    /// Add an option with a description.
93    pub fn option(mut self, label: impl Into<String>, description: impl Into<Value>) -> Self {
94        self.criteria.insert(label.into(), Some(description.into()));
95        self
96    }
97
98    /// Add an option without a description.
99    pub fn label(mut self, label: impl Into<String>) -> Self {
100        self.criteria.insert(label.into(), None);
101        self
102    }
103}
104
105/// Rate the state along ordered levels; the answer is a probability-weighted level index.
106#[derive(Debug, Clone, Default, PartialEq, Serialize)]
107#[non_exhaustive]
108#[serde(tag = "type", rename = "score")]
109pub struct Score {
110    /// What the model should rate.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub instructions: Option<Value>,
113    /// Ordered level descriptions; level `i` is index `i`.
114    pub criteria: Vec<Value>,
115}
116
117impl Score {
118    /// A score with instructions and ordered level descriptions.
119    pub fn new<I, V>(instructions: impl Into<Value>, levels: I) -> Self
120    where
121        I: IntoIterator<Item = V>,
122        V: Into<Value>,
123    {
124        Self {
125            instructions: Some(instructions.into()),
126            criteria: levels.into_iter().map(Into::into).collect(),
127        }
128    }
129
130    /// Append a level.
131    pub fn level(mut self, description: impl Into<Value>) -> Self {
132        self.criteria.push(description.into());
133        self
134    }
135}
136
137/// Any question. `Raw` passes a hand-built JSON object through (after light validation), which is
138/// useful for fields this SDK version does not model yet.
139#[derive(Debug, Clone, PartialEq)]
140#[non_exhaustive]
141pub enum Question {
142    /// See [`Noul`].
143    Noul(Noul),
144    /// See [`Choice`].
145    Choice(Choice),
146    /// See [`Score`].
147    Score(Score),
148    /// A JSON object with a non-empty string `type`.
149    Raw(Value),
150}
151
152impl Serialize for Question {
153    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
154        match self {
155            Question::Noul(q) => q.serialize(s),
156            Question::Choice(q) => q.serialize(s),
157            Question::Score(q) => q.serialize(s),
158            Question::Raw(v) => v.serialize(s),
159        }
160    }
161}
162
163impl From<Noul> for Question {
164    fn from(q: Noul) -> Self {
165        Question::Noul(q)
166    }
167}
168impl From<Choice> for Question {
169    fn from(q: Choice) -> Self {
170        Question::Choice(q)
171    }
172}
173impl From<Score> for Question {
174    fn from(q: Score) -> Self {
175        Question::Score(q)
176    }
177}
178impl From<Value> for Question {
179    fn from(v: Value) -> Self {
180        Question::Raw(v)
181    }
182}
183
184/// Ordered map of question name → question. Answers come back under the same names.
185#[derive(Debug, Clone, Default, PartialEq, Serialize)]
186#[serde(transparent)]
187pub struct Questions(IndexMap<String, Question>);
188
189impl Questions {
190    /// An empty set.
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// Add (or replace) a question, builder-style.
196    pub fn with(mut self, name: impl Into<String>, question: impl Into<Question>) -> Self {
197        self.insert(name, question);
198        self
199    }
200
201    /// Add (or replace) a question.
202    pub fn insert(&mut self, name: impl Into<String>, question: impl Into<Question>) -> &mut Self {
203        self.0.insert(name.into(), question.into());
204        self
205    }
206
207    /// Number of questions.
208    pub fn len(&self) -> usize {
209        self.0.len()
210    }
211
212    /// Whether there are no questions.
213    pub fn is_empty(&self) -> bool {
214        self.0.is_empty()
215    }
216
217    /// Iterate in insertion order.
218    pub fn iter(&self) -> impl Iterator<Item = (&str, &Question)> {
219        self.0.iter().map(|(k, v)| (k.as_str(), v))
220    }
221
222    /// Reject what the Python SDK rejects locally; everything else is left to server validation.
223    pub(crate) fn validate(&self) -> Result<()> {
224        if self.0.is_empty() {
225            return Err(Error::InvalidRequest(
226                "At least one question is required.".into(),
227            ));
228        }
229        for (name, q) in &self.0 {
230            match q {
231                Question::Score(s) if s.criteria.is_empty() => return Err(empty_score(name)),
232                Question::Choice(c) if c.criteria.is_empty() => return Err(empty_choice(name)),
233                Question::Raw(v) => validate_raw(name, v)?,
234                _ => {}
235            }
236        }
237        Ok(())
238    }
239}
240
241fn empty_score(name: &str) -> Error {
242    Error::InvalidRequest(format!(
243        "Score question \"{name}\" has no criteria; at least one score is required."
244    ))
245}
246
247fn empty_choice(name: &str) -> Error {
248    Error::InvalidRequest(format!(
249        "Choice question \"{name}\" has no criteria; at least one option is required."
250    ))
251}
252
253fn validate_raw(name: &str, v: &Value) -> Result<()> {
254    let ty = v
255        .as_object()
256        .and_then(|o| o.get("type"))
257        .and_then(Value::as_str)
258        .filter(|t| !t.is_empty())
259        .ok_or_else(|| {
260            Error::InvalidRequest(format!(
261                "Question \"{name}\" must be a question object or a JSON object with a nonempty string \"type\"."
262            ))
263        })?;
264    if matches!(ty, "choice" | "score") {
265        let criteria = v.get("criteria").ok_or_else(|| {
266            Error::InvalidRequest(format!("Question \"{name}\" requires \"criteria\"."))
267        })?;
268        let empty = match criteria {
269            Value::Null => true,
270            Value::Bool(b) => !b,
271            Value::String(s) => s.is_empty(),
272            Value::Array(a) => a.is_empty(),
273            Value::Object(o) => o.is_empty(),
274            Value::Number(n) => n.as_f64() == Some(0.0),
275        };
276        if empty {
277            return Err(if ty == "score" {
278                empty_score(name)
279            } else {
280                empty_choice(name)
281            });
282        }
283    }
284    Ok(())
285}
286
287impl<K: Into<String>, Q: Into<Question>> FromIterator<(K, Q)> for Questions {
288    fn from_iter<T: IntoIterator<Item = (K, Q)>>(iter: T) -> Self {
289        Self(
290            iter.into_iter()
291                .map(|(k, q)| (k.into(), q.into()))
292                .collect(),
293        )
294    }
295}
296
297impl<K: Into<String>, Q: Into<Question>, const N: usize> From<[(K, Q); N]> for Questions {
298    fn from(arr: [(K, Q); N]) -> Self {
299        arr.into_iter().collect()
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use serde_json::json;
307
308    #[test]
309    fn serializes_like_the_api_reference() {
310        let q = Questions::new()
311            .with(
312                "department",
313                Choice::new("Which team should handle this")
314                    .option("billing", "Payment or subscription issues")
315                    .label("other"),
316            )
317            .with(
318                "frustration",
319                Score::new("How frustrated", ["Calm", "Angry"]),
320            )
321            .with(
322                "is_urgent",
323                Noul::new("Urgent?").when_true("Explicitly time-sensitive"),
324            )
325            .with("bare", Noul::default());
326        assert_eq!(
327            serde_json::to_value(&q).unwrap(),
328            json!({
329                "department": {"type": "choice", "instructions": "Which team should handle this",
330                    "criteria": {"billing": "Payment or subscription issues", "other": null}},
331                "frustration": {"type": "score", "instructions": "How frustrated", "criteria": ["Calm", "Angry"]},
332                "is_urgent": {"type": "noul", "instructions": "Urgent?", "criteria": {"true": "Explicitly time-sensitive"}},
333                "bare": {"type": "noul"}
334            })
335        );
336        // insertion order is preserved on the wire
337        let keys: Vec<_> = q.iter().map(|(k, _)| k).collect();
338        assert_eq!(keys, ["department", "frustration", "is_urgent", "bare"]);
339    }
340
341    #[test]
342    fn structured_instructions() {
343        let q = Score::new(
344            json!({"task": "rate", "focus": ["tone"]}),
345            [json!({"level": "low"}), json!("high")],
346        );
347        assert_eq!(
348            serde_json::to_value(Question::from(q)).unwrap(),
349            json!({"type": "score", "instructions": {"task": "rate", "focus": ["tone"]},
350                   "criteria": [{"level": "low"}, "high"]})
351        );
352    }
353
354    #[test]
355    fn validation() {
356        assert!(Questions::new().validate().is_err());
357        assert!(
358            Questions::from([("s", Score::new("x", Vec::<Value>::new()))])
359                .validate()
360                .is_err()
361        );
362        assert!(
363            Questions::from([("r", json!({"instructions": "x"}))])
364                .validate()
365                .is_err()
366        );
367        assert!(
368            Questions::from([("r", json!({"type": ""}))])
369                .validate()
370                .is_err()
371        );
372        assert!(
373            Questions::from([("c", Choice::new("x"))])
374                .validate()
375                .is_err()
376        );
377        assert!(
378            Questions::from([("r", json!({"type": "choice"}))])
379                .validate()
380                .is_err()
381        );
382        assert!(
383            Questions::from([("r", json!({"type": "choice", "criteria": {}}))])
384                .validate()
385                .is_err()
386        );
387        assert!(
388            Questions::from([("r", json!({"type": "score", "criteria": []}))])
389                .validate()
390                .is_err()
391        );
392        assert!(
393            Questions::from([("r", json!({"type": "noul", "future_field": 1}))])
394                .validate()
395                .is_ok()
396        );
397        assert!(
398            Questions::from([("r", json!({"type": "choice", "criteria": {"a": null}}))])
399                .validate()
400                .is_ok()
401        );
402    }
403}