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    #[must_use]
48    pub fn when_true(mut self, description: impl Into<Value>) -> Self {
49        self.criteria.get_or_insert_with(Default::default).yes = Some(description.into());
50        self
51    }
52
53    /// Describe what a no means.
54    #[must_use]
55    pub fn when_false(mut self, description: impl Into<Value>) -> Self {
56        self.criteria.get_or_insert_with(Default::default).no = Some(description.into());
57        self
58    }
59}
60
61/// Pick one option from a set you define.
62#[derive(Debug, Clone, Default, PartialEq, Serialize)]
63#[non_exhaustive]
64#[serde(tag = "type", rename = "choice")]
65pub struct Choice {
66    /// What the model should decide.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub instructions: Option<Value>,
69    /// Option label → description (`None` is sent as `null`: an undescribed option).
70    pub criteria: IndexMap<String, Option<Value>>,
71}
72
73impl Choice {
74    /// A choice with instructions and no options yet; add them with [`Choice::option`].
75    pub fn new(instructions: impl Into<Value>) -> Self {
76        Self {
77            instructions: Some(instructions.into()),
78            criteria: IndexMap::new(),
79        }
80    }
81
82    /// A choice between undescribed labels.
83    pub fn from_labels<I, S>(instructions: impl Into<Value>, labels: I) -> Self
84    where
85        I: IntoIterator<Item = S>,
86        S: Into<String>,
87    {
88        let mut c = Self::new(instructions);
89        c.criteria
90            .extend(labels.into_iter().map(|l| (l.into(), None)));
91        c
92    }
93
94    /// Add an option with a description.
95    #[must_use]
96    pub fn option(mut self, label: impl Into<String>, description: impl Into<Value>) -> Self {
97        self.criteria.insert(label.into(), Some(description.into()));
98        self
99    }
100
101    /// Add an option without a description.
102    #[must_use]
103    pub fn label(mut self, label: impl Into<String>) -> Self {
104        self.criteria.insert(label.into(), None);
105        self
106    }
107}
108
109/// Rate the state along ordered levels; the answer is a probability-weighted level index.
110#[derive(Debug, Clone, Default, PartialEq, Serialize)]
111#[non_exhaustive]
112#[serde(tag = "type", rename = "score")]
113pub struct Score {
114    /// What the model should rate.
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub instructions: Option<Value>,
117    /// Ordered level descriptions; level `i` is index `i`.
118    pub criteria: Vec<Value>,
119}
120
121impl Score {
122    /// A score with instructions and ordered level descriptions.
123    pub fn new<I, V>(instructions: impl Into<Value>, levels: I) -> Self
124    where
125        I: IntoIterator<Item = V>,
126        V: Into<Value>,
127    {
128        Self {
129            instructions: Some(instructions.into()),
130            criteria: levels.into_iter().map(Into::into).collect(),
131        }
132    }
133
134    /// Append a level.
135    #[must_use]
136    pub fn level(mut self, description: impl Into<Value>) -> Self {
137        self.criteria.push(description.into());
138        self
139    }
140}
141
142/// Any question. `Raw` passes a hand-built JSON object through (after light validation), which is
143/// useful for fields this SDK version does not model yet.
144#[derive(Debug, Clone, PartialEq)]
145#[non_exhaustive]
146pub enum Question {
147    /// See [`Noul`].
148    Noul(Noul),
149    /// See [`Choice`].
150    Choice(Choice),
151    /// See [`Score`].
152    Score(Score),
153    /// A JSON object with a non-empty string `type`.
154    Raw(Value),
155}
156
157impl Serialize for Question {
158    fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
159        match self {
160            Question::Noul(q) => q.serialize(s),
161            Question::Choice(q) => q.serialize(s),
162            Question::Score(q) => q.serialize(s),
163            Question::Raw(v) => v.serialize(s),
164        }
165    }
166}
167
168impl From<Noul> for Question {
169    fn from(q: Noul) -> Self {
170        Question::Noul(q)
171    }
172}
173impl From<Choice> for Question {
174    fn from(q: Choice) -> Self {
175        Question::Choice(q)
176    }
177}
178impl From<Score> for Question {
179    fn from(q: Score) -> Self {
180        Question::Score(q)
181    }
182}
183impl From<Value> for Question {
184    fn from(v: Value) -> Self {
185        Question::Raw(v)
186    }
187}
188
189/// Ordered map of question name → question. Answers come back under the same names.
190#[derive(Debug, Clone, Default, PartialEq, Serialize)]
191#[serde(transparent)]
192pub struct Questions(IndexMap<String, Question>);
193
194impl Questions {
195    /// An empty set.
196    pub fn new() -> Self {
197        Self::default()
198    }
199
200    /// Add (or replace) a question, builder-style.
201    #[must_use]
202    pub fn with(mut self, name: impl Into<String>, question: impl Into<Question>) -> Self {
203        self.insert(name, question);
204        self
205    }
206
207    /// Add a question, or replace the one of the same name (keeping its position). Returns the
208    /// question it replaced, as `HashMap::insert` does.
209    pub fn insert(
210        &mut self,
211        name: impl Into<String>,
212        question: impl Into<Question>,
213    ) -> Option<Question> {
214        self.0.insert(name.into(), question.into())
215    }
216
217    /// The question named `name`.
218    pub fn get(&self, name: &str) -> Option<&Question> {
219        self.0.get(name)
220    }
221
222    /// Number of questions.
223    pub fn len(&self) -> usize {
224        self.0.len()
225    }
226
227    /// Whether there are no questions.
228    pub fn is_empty(&self) -> bool {
229        self.0.is_empty()
230    }
231
232    /// Iterate in insertion order.
233    pub fn iter(&self) -> impl Iterator<Item = (&str, &Question)> {
234        self.0.iter().map(|(k, v)| (k.as_str(), v))
235    }
236
237    /// Reject what the Python SDK rejects locally; everything else is left to server validation.
238    pub(crate) fn validate(&self) -> Result<()> {
239        if self.0.is_empty() {
240            return Err(Error::invalid_request("at least one question is required"));
241        }
242        for (name, q) in &self.0 {
243            match q {
244                Question::Score(s) if s.criteria.is_empty() => return Err(empty_score(name)),
245                Question::Choice(c) if c.criteria.is_empty() => return Err(empty_choice(name)),
246                Question::Raw(v) => validate_raw(name, v)?,
247                _ => {}
248            }
249        }
250        Ok(())
251    }
252}
253
254fn empty_score(name: &str) -> Error {
255    Error::invalid_request(format!(
256        "score question \"{name}\" has no criteria; at least one score is required"
257    ))
258}
259
260fn empty_choice(name: &str) -> Error {
261    Error::invalid_request(format!(
262        "choice question \"{name}\" has no criteria; at least one option is required"
263    ))
264}
265
266fn validate_raw(name: &str, v: &Value) -> Result<()> {
267    let ty = v
268        .as_object()
269        .and_then(|o| o.get("type"))
270        .and_then(Value::as_str)
271        .filter(|t| !t.is_empty())
272        .ok_or_else(|| {
273            Error::invalid_request(format!(
274                "question \"{name}\" must be a question object or a JSON object with a nonempty string \"type\""
275            ))
276        })?;
277    if matches!(ty, "choice" | "score") {
278        let criteria = v.get("criteria").ok_or_else(|| {
279            Error::invalid_request(format!("question \"{name}\" requires \"criteria\""))
280        })?;
281        let empty = match criteria {
282            Value::Null => true,
283            Value::Bool(b) => !b,
284            Value::String(s) => s.is_empty(),
285            Value::Array(a) => a.is_empty(),
286            Value::Object(o) => o.is_empty(),
287            Value::Number(n) => n.as_f64() == Some(0.0),
288        };
289        if empty {
290            return Err(if ty == "score" {
291                empty_score(name)
292            } else {
293                empty_choice(name)
294            });
295        }
296    }
297    Ok(())
298}
299
300impl<K: Into<String>, Q: Into<Question>> FromIterator<(K, Q)> for Questions {
301    fn from_iter<T: IntoIterator<Item = (K, Q)>>(iter: T) -> Self {
302        Self(
303            iter.into_iter()
304                .map(|(k, q)| (k.into(), q.into()))
305                .collect(),
306        )
307    }
308}
309
310impl<K: Into<String>, Q: Into<Question>> Extend<(K, Q)> for Questions {
311    /// Add (or replace) each question, as [`Questions::insert`] does.
312    fn extend<T: IntoIterator<Item = (K, Q)>>(&mut self, iter: T) {
313        self.0
314            .extend(iter.into_iter().map(|(k, q)| (k.into(), q.into())));
315    }
316}
317
318/// Name and question, in insertion order.
319impl IntoIterator for Questions {
320    type Item = (String, Question);
321    type IntoIter = indexmap::map::IntoIter<String, Question>;
322
323    fn into_iter(self) -> Self::IntoIter {
324        self.0.into_iter()
325    }
326}
327
328/// The same items as [`Questions::iter`].
329impl<'a> IntoIterator for &'a Questions {
330    type Item = (&'a str, &'a Question);
331    type IntoIter = std::iter::Map<
332        indexmap::map::Iter<'a, String, Question>,
333        fn((&'a String, &'a Question)) -> (&'a str, &'a Question),
334    >;
335
336    fn into_iter(self) -> Self::IntoIter {
337        self.0.iter().map(|(k, v)| (k.as_str(), v))
338    }
339}
340
341impl<K: Into<String>, Q: Into<Question>, const N: usize> From<[(K, Q); N]> for Questions {
342    fn from(arr: [(K, Q); N]) -> Self {
343        arr.into_iter().collect()
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use serde_json::json;
351
352    #[test]
353    fn serializes_like_the_api_reference() {
354        let q = Questions::new()
355            .with(
356                "department",
357                Choice::new("Which team should handle this")
358                    .option("billing", "Payment or subscription issues")
359                    .label("other"),
360            )
361            .with(
362                "frustration",
363                Score::new("How frustrated", ["Calm", "Angry"]),
364            )
365            .with(
366                "is_urgent",
367                Noul::new("Urgent?").when_true("Explicitly time-sensitive"),
368            )
369            .with("bare", Noul::default());
370        assert_eq!(
371            serde_json::to_value(&q).unwrap(),
372            json!({
373                "department": {"type": "choice", "instructions": "Which team should handle this",
374                    "criteria": {"billing": "Payment or subscription issues", "other": null}},
375                "frustration": {"type": "score", "instructions": "How frustrated", "criteria": ["Calm", "Angry"]},
376                "is_urgent": {"type": "noul", "instructions": "Urgent?", "criteria": {"true": "Explicitly time-sensitive"}},
377                "bare": {"type": "noul"}
378            })
379        );
380        // insertion order is preserved on the wire
381        let keys: Vec<_> = q.iter().map(|(k, _)| k).collect();
382        assert_eq!(keys, ["department", "frustration", "is_urgent", "bare"]);
383    }
384
385    #[test]
386    fn lookup_iteration_and_extend() {
387        let mut q: Questions = [("a", Noul::new("a"))].into_iter().collect();
388        q.extend([("b", Question::from(Noul::new("b")))]);
389        q.extend(vec![(String::from("a"), Score::new("a", ["lo", "hi"]))]);
390        assert_eq!(q.len(), 2);
391        assert!(matches!(q.get("a"), Some(Question::Score(_))));
392        assert_eq!(q.get("b"), Some(&Question::Noul(Noul::new("b"))));
393        assert_eq!(q.get("c"), None);
394
395        let borrowed: Vec<&str> = (&q).into_iter().map(|(k, _)| k).collect();
396        assert_eq!(borrowed, ["a", "b"]);
397        let mut names = Vec::new();
398        for (name, _) in &q {
399            names.push(name);
400        }
401        assert_eq!(names, borrowed);
402        let owned: Vec<String> = q.clone().into_iter().map(|(k, _)| k).collect();
403        assert_eq!(owned, ["a", "b"]);
404        // Round-trips through the owned iterator without losing order or content.
405        assert_eq!(q.clone().into_iter().collect::<Questions>(), q);
406
407        // `insert` hands back what it replaced.
408        let mut r = Questions::new();
409        assert_eq!(r.insert("c", Noul::new("c")), None);
410        assert_eq!(r.insert("c", Noul::new("d")), Some(Noul::new("c").into()));
411        assert_eq!(r.get("c"), Some(&Question::Noul(Noul::new("d"))));
412    }
413
414    #[test]
415    fn structured_instructions() {
416        let q = Score::new(
417            json!({"task": "rate", "focus": ["tone"]}),
418            [json!({"level": "low"}), json!("high")],
419        );
420        assert_eq!(
421            serde_json::to_value(Question::from(q)).unwrap(),
422            json!({"type": "score", "instructions": {"task": "rate", "focus": ["tone"]},
423                   "criteria": [{"level": "low"}, "high"]})
424        );
425    }
426
427    #[test]
428    fn validation() {
429        assert!(Questions::new().validate().is_err());
430        assert!(
431            Questions::from([("s", Score::new("x", Vec::<Value>::new()))])
432                .validate()
433                .is_err()
434        );
435        assert!(
436            Questions::from([("r", json!({"instructions": "x"}))])
437                .validate()
438                .is_err()
439        );
440        assert!(
441            Questions::from([("r", json!({"type": ""}))])
442                .validate()
443                .is_err()
444        );
445        assert!(
446            Questions::from([("c", Choice::new("x"))])
447                .validate()
448                .is_err()
449        );
450        assert!(
451            Questions::from([("r", json!({"type": "choice"}))])
452                .validate()
453                .is_err()
454        );
455        assert!(
456            Questions::from([("r", json!({"type": "choice", "criteria": {}}))])
457                .validate()
458                .is_err()
459        );
460        assert!(
461            Questions::from([("r", json!({"type": "score", "criteria": []}))])
462                .validate()
463                .is_err()
464        );
465        assert!(
466            Questions::from([("r", json!({"type": "noul", "future_field": 1}))])
467                .validate()
468                .is_ok()
469        );
470        assert!(
471            Questions::from([("r", json!({"type": "choice", "criteria": {"a": null}}))])
472                .validate()
473                .is_ok()
474        );
475    }
476}