Skip to main content

typesafe_systemone/
question.rs

1use std::collections::BTreeMap;
2
3use serde::Serialize;
4use serde_json::Value;
5
6/// Conventional name of the abstain option in a Choice. The model always picks *some*
7/// option, so give it one that means "nothing here fits".
8pub const NONE_OF_THE_ABOVE: &str = "none_of_the_above";
9
10/// Options one Choice may carry, per the API. Counting the abstain option.
11pub const MAX_CHOICE_OPTIONS: usize = 255;
12
13/// Optional descriptions of what a "yes" and a "no" mean for a [`Question::Noul`].
14#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
15pub struct NoulCriteria {
16    /// What a yes (value near 1) means.
17    #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
18    pub yes: Option<String>,
19    /// What a no (value near 0) means.
20    #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
21    pub no: Option<String>,
22}
23
24/// A typed question evaluated against the request `state`.
25///
26/// `instructions` may be a string, object or array. Reference nested state with
27/// backticked paths such as `` `rows[3].country` ``. Construct through the associated
28/// functions ([`Question::noul`], [`Question::choice`], [`Question::score`]) or the
29/// [`SystemOneRequest`](crate::SystemOneRequest) builder; the variants are
30/// `#[non_exhaustive]` so fields can follow the API without breaking callers.
31#[derive(Clone, Debug, PartialEq, Serialize)]
32#[serde(tag = "type", rename_all = "lowercase")]
33#[non_exhaustive]
34pub enum Question {
35    /// Yes/no. Answered with the probability of "yes".
36    #[non_exhaustive]
37    Noul {
38        /// The question, referring to the state.
39        instructions: Value,
40        /// What yes and no mean, when spelled out.
41        #[serde(skip_serializing_if = "Option::is_none")]
42        criteria: Option<NoulCriteria>,
43    },
44    /// One option out of a defined set (at most [`MAX_CHOICE_OPTIONS`]). Answered with
45    /// the chosen option and the probability of every option.
46    #[non_exhaustive]
47    Choice {
48        /// The question, referring to the state.
49        instructions: Value,
50        /// Option → optional rubric description.
51        criteria: BTreeMap<String, Option<String>>,
52    },
53    /// A position along an ordered rubric of at least two levels.
54    #[non_exhaustive]
55    Score {
56        /// The question, referring to the state.
57        instructions: Value,
58        /// Level descriptions, lowest first.
59        criteria: Vec<String>,
60    },
61}
62
63impl Question {
64    /// A yes/no question without criteria descriptions.
65    pub fn noul(instructions: impl Into<Value>) -> Self {
66        Self::Noul {
67            instructions: instructions.into(),
68            criteria: None,
69        }
70    }
71
72    /// A yes/no question with descriptions of what yes and no mean.
73    pub fn noul_with_criteria(instructions: impl Into<Value>, yes: impl Into<String>, no: impl Into<String>) -> Self {
74        Self::Noul {
75            instructions: instructions.into(),
76            criteria: Some(NoulCriteria {
77                yes: Some(yes.into()),
78                no: Some(no.into()),
79            }),
80        }
81    }
82
83    /// Pick one of `options`. Each option is `(name, optional description)`.
84    pub fn choice<K, V>(instructions: impl Into<Value>, options: impl IntoIterator<Item = (K, Option<V>)>) -> Self
85    where
86        K: Into<String>,
87        V: Into<String>,
88    {
89        Self::Choice {
90            instructions: instructions.into(),
91            criteria: options
92                .into_iter()
93                .map(|(k, v)| (k.into(), v.map(Into::into)))
94                .collect(),
95        }
96    }
97
98    /// Pick one of `options`, none of which carries a description.
99    pub fn choice_plain<K: Into<String>>(instructions: impl Into<Value>, options: impl IntoIterator<Item = K>) -> Self {
100        Self::choice(instructions, options.into_iter().map(|k| (k, None::<String>)))
101    }
102
103    /// Rate along `levels`, ordered from lowest to highest.
104    pub fn score<L: Into<String>>(instructions: impl Into<Value>, levels: impl IntoIterator<Item = L>) -> Self {
105        Self::Score {
106            instructions: instructions.into(),
107            criteria: levels.into_iter().map(Into::into).collect(),
108        }
109    }
110
111    /// Why the API would reject this question, if it would. `None` = well-formed.
112    pub(crate) fn structural_problem(&self) -> Option<String> {
113        match self {
114            Self::Choice { criteria, .. } if criteria.is_empty() => Some("has no options".to_string()),
115            Self::Choice { criteria, .. } if criteria.len() > MAX_CHOICE_OPTIONS => Some(format!(
116                "has {} options; the API allows at most {MAX_CHOICE_OPTIONS}",
117                criteria.len()
118            )),
119            Self::Score { criteria, .. } if criteria.len() < 2 => Some("needs at least two levels".to_string()),
120            Self::Noul { .. } | Self::Choice { .. } | Self::Score { .. } => None,
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use serde_json::json;
129
130    #[test]
131    fn noul_serializes_without_criteria_when_absent() {
132        let q = Question::noul("Does this convey urgency?");
133        assert_eq!(
134            serde_json::to_value(&q).unwrap(),
135            json!({"type": "noul", "instructions": "Does this convey urgency?"})
136        );
137    }
138
139    #[test]
140    fn noul_criteria_use_true_false_keys() {
141        let q = Question::noul_with_criteria("Urgent?", "Explicitly time-sensitive", "No urgency expressed");
142        assert_eq!(
143            serde_json::to_value(&q).unwrap(),
144            json!({
145                "type": "noul",
146                "instructions": "Urgent?",
147                "criteria": {"true": "Explicitly time-sensitive", "false": "No urgency expressed"}
148            })
149        );
150    }
151
152    #[test]
153    fn choice_serializes_null_for_undescribed_options() {
154        let q = Question::choice("Which team?", [("billing", Some("Payments")), ("sales", None)]);
155        assert_eq!(
156            serde_json::to_value(&q).unwrap(),
157            json!({"type": "choice", "instructions": "Which team?", "criteria": {"billing": "Payments", "sales": null}})
158        );
159    }
160
161    #[test]
162    fn choice_over_the_option_limit_is_a_structural_problem() {
163        let q = Question::choice_plain("?", (0..=MAX_CHOICE_OPTIONS).map(|i| format!("o{i}")));
164        assert!(q.structural_problem().unwrap().contains("256 options"));
165    }
166
167    #[test]
168    fn choice_at_the_option_limit_is_well_formed() {
169        let q = Question::choice_plain("?", (0..MAX_CHOICE_OPTIONS).map(|i| format!("o{i}")));
170        assert_eq!(q.structural_problem(), None);
171    }
172
173    #[test]
174    fn empty_choice_and_short_score_are_structural_problems() {
175        assert!(Question::choice_plain::<&str>("?", []).structural_problem().is_some());
176        assert!(Question::score("?", ["one"]).structural_problem().is_some());
177        assert_eq!(Question::noul("?").structural_problem(), None);
178    }
179
180    #[test]
181    fn score_serializes_levels_in_order() {
182        let q = Question::score(json!({"rate": "frustration"}), ["Calm", "Frustrated", "Very angry"]);
183        assert_eq!(
184            serde_json::to_value(&q).unwrap(),
185            json!({"type": "score", "instructions": {"rate": "frustration"}, "criteria": ["Calm", "Frustrated", "Very angry"]})
186        );
187    }
188}