Skip to main content

kunobi_decision/
questions.rs

1//! Question types, builders and typed answer keys.
2
3use std::fmt;
4use std::marker::PhantomData;
5
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10use crate::answers::{AnswerKind, ChoiceAnswer, NoulAnswer, ScoreAnswer, TypedChoiceAnswer};
11use crate::error::{Error, Result};
12use crate::labels::Labels;
13use crate::types::Entry;
14
15/// Most options a Choice question may offer.
16///
17/// "A Choice question accepts up to 255 options", says the documentation. Past
18/// that the server refuses the request, so the check happens here rather than
19/// after a paid round trip.
20pub const MAX_CHOICE_OPTIONS: usize = 255;
21
22/// Most levels a Score question may rate against.
23///
24/// "Needs at least two levels and takes up to 10."
25pub const MAX_SCORE_LEVELS: usize = 10;
26
27/// A question, tagged by `type` on the wire.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "lowercase")]
30pub enum Question {
31    /// A yes/no question.
32    Noul(NoulQuestion),
33    /// A choice between labels.
34    Choice(ChoiceQuestion),
35    /// A score from an ordered rubric.
36    Score(ScoreQuestion),
37    /// A question this crate does not model, sent as written.
38    ///
39    /// The escape hatch both official SDKs document: a question type the API
40    /// gains before this crate does can be sent today, as long as the JSON
41    /// carries its own `type`.
42    ///
43    /// ```
44    /// use kunobi_decision::{Question, Questions};
45    /// use serde_json::json;
46    ///
47    /// let mut questions = Questions::new();
48    /// questions.insert(
49    ///     "ranking",
50    ///     Question::raw(json!({"type": "rank", "instructions": "Order these", "criteria": ["a", "b"]})),
51    /// );
52    /// ```
53    #[serde(untagged)]
54    Raw(Value),
55}
56
57impl Question {
58    /// A question sent exactly as written, for a type this crate does not model.
59    pub fn raw(question: impl Into<Value>) -> Self {
60        Question::Raw(question.into())
61    }
62}
63
64/// A yes/no question. The answer is the probability of yes.
65#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
66pub struct NoulQuestion {
67    /// The question as text, a JSON object or array, or null.
68    #[serde(default)]
69    pub instructions: Entry,
70    /// Optional descriptions of the yes and no outcomes.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub criteria: Option<NoulCriteria>,
73    /// Fields this crate does not model yet, sent as written.
74    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
75    pub extra: Map<String, Value>,
76}
77
78impl NoulQuestion {
79    /// Send a field this crate does not model yet.
80    ///
81    /// The forward-compatibility hook both official SDKs document, for a
82    /// question field the API gains before this crate does.
83    pub fn extra(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
84        self.extra.insert(key.into(), value.into());
85        self
86    }
87}
88
89/// Descriptions of the yes and no outcomes of a [`NoulQuestion`].
90#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
91pub struct NoulCriteria {
92    /// Description of the yes outcome.
93    #[serde(rename = "true", default, skip_serializing_if = "Option::is_none")]
94    pub when_true: Option<Entry>,
95    /// Description of the no outcome.
96    #[serde(rename = "false", default, skip_serializing_if = "Option::is_none")]
97    pub when_false: Option<Entry>,
98}
99
100/// A question that selects one of several named labels.
101#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
102pub struct ChoiceQuestion {
103    /// The question as text, a JSON object or array, or null.
104    #[serde(default)]
105    pub instructions: Entry,
106    /// Labels mapped to descriptions; [`Entry::Null`] leaves a label undescribed.
107    pub criteria: IndexMap<String, Entry>,
108    /// Fields this crate does not model yet, sent as written.
109    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
110    pub extra: Map<String, Value>,
111}
112
113impl ChoiceQuestion {
114    /// Send a field this crate does not model yet.
115    pub fn extra(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
116        self.extra.insert(key.into(), value.into());
117        self
118    }
119}
120
121/// A question that assigns a score from an ordered rubric.
122#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
123pub struct ScoreQuestion {
124    /// The question as text, a JSON object or array, or null.
125    #[serde(default)]
126    pub instructions: Entry,
127    /// At least two level descriptions, indexed by score from zero.
128    pub criteria: Vec<Entry>,
129    /// Fields this crate does not model yet, sent as written.
130    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
131    pub extra: Map<String, Value>,
132}
133
134impl ScoreQuestion {
135    /// Send a field this crate does not model yet.
136    pub fn extra(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
137        self.extra.insert(key.into(), value.into());
138        self
139    }
140}
141
142impl NoulQuestion {
143    /// Describe the yes outcome.
144    pub fn when_true(mut self, description: impl Into<Entry>) -> Self {
145        self.criteria.get_or_insert_default().when_true = Some(description.into());
146        self
147    }
148
149    /// Describe the no outcome.
150    pub fn when_false(mut self, description: impl Into<Entry>) -> Self {
151        self.criteria.get_or_insert_default().when_false = Some(description.into());
152        self
153    }
154}
155
156/// Create a yes/no question.
157///
158/// Pass [`Entry::Null`] to ask without instructions.
159pub fn noul(instructions: impl Into<Entry>) -> NoulQuestion {
160    NoulQuestion {
161        instructions: instructions.into(),
162        criteria: None,
163        extra: Map::new(),
164    }
165}
166
167/// Create a choice between labels, each with a description.
168///
169/// ```
170/// use kunobi_decision::choice;
171///
172/// let q = choice("Which team should handle this?", [
173///     ("billing", "Payment or subscription issues"),
174///     ("technical", "Bugs or integration problems"),
175/// ]);
176/// assert_eq!(q.criteria.len(), 2);
177/// ```
178pub fn choice<K, V>(
179    instructions: impl Into<Entry>,
180    criteria: impl IntoIterator<Item = (K, V)>,
181) -> ChoiceQuestion
182where
183    K: Into<String>,
184    V: Into<Entry>,
185{
186    ChoiceQuestion {
187        instructions: instructions.into(),
188        criteria: criteria
189            .into_iter()
190            .map(|(label, description)| (label.into(), description.into()))
191            .collect(),
192        extra: Map::new(),
193    }
194}
195
196/// Create a choice between undescribed labels.
197pub fn choice_labels<K: Into<String>>(
198    instructions: impl Into<Entry>,
199    labels: impl IntoIterator<Item = K>,
200) -> ChoiceQuestion {
201    choice(
202        instructions,
203        labels.into_iter().map(|label| (label, Entry::Null)),
204    )
205}
206
207/// A choice whose labels come from a [`Labels`] enum; answered with [`TypedChoiceAnswer`].
208///
209/// Created by [`choice_of`].
210pub struct TypedChoice<L> {
211    question: ChoiceQuestion,
212    labels: PhantomData<fn() -> L>,
213}
214
215impl<L> TypedChoice<L> {
216    /// The untyped question sent on the wire.
217    pub fn question(&self) -> &ChoiceQuestion {
218        &self.question
219    }
220}
221
222impl<L> Clone for TypedChoice<L> {
223    fn clone(&self) -> Self {
224        Self {
225            question: self.question.clone(),
226            labels: PhantomData,
227        }
228    }
229}
230
231impl<L> PartialEq for TypedChoice<L> {
232    fn eq(&self, other: &Self) -> bool {
233        self.question == other.question
234    }
235}
236
237impl<L> fmt::Debug for TypedChoice<L> {
238    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239        f.debug_tuple("TypedChoice").field(&self.question).finish()
240    }
241}
242
243/// Create a choice between the labels of a [`Labels`] enum, in declaration order.
244///
245/// ```
246/// use kunobi_decision::{Questions, choice_of};
247///
248/// kunobi_decision::labels! {
249///     pub enum Tone {
250///         Calm = "calm",
251///         Angry = "angry": "Strong language or threats",
252///     }
253/// }
254///
255/// let mut questions = Questions::new();
256/// let tone = questions.add("tone", choice_of::<Tone>("What is the tone?"));
257/// ```
258pub fn choice_of<L: Labels>(instructions: impl Into<Entry>) -> TypedChoice<L> {
259    TypedChoice {
260        question: choice(
261            instructions,
262            L::ALL
263                .iter()
264                .map(|(_, label, description)| (*label, description.map(Entry::from))),
265        ),
266        labels: PhantomData,
267    }
268}
269
270impl<L: Labels> From<TypedChoice<L>> for Question {
271    fn from(question: TypedChoice<L>) -> Self {
272        Question::Choice(question.question)
273    }
274}
275
276impl<L: Labels> QuestionKind for TypedChoice<L> {
277    type Answer = TypedChoiceAnswer<L>;
278}
279
280/// Create a score question from level descriptions, lowest score first.
281pub fn score<V: Into<Entry>>(
282    instructions: impl Into<Entry>,
283    levels: impl IntoIterator<Item = V>,
284) -> ScoreQuestion {
285    ScoreQuestion {
286        instructions: instructions.into(),
287        criteria: levels.into_iter().map(Into::into).collect(),
288        extra: Map::new(),
289    }
290}
291
292impl From<NoulQuestion> for Question {
293    fn from(question: NoulQuestion) -> Self {
294        Question::Noul(question)
295    }
296}
297
298impl From<ChoiceQuestion> for Question {
299    fn from(question: ChoiceQuestion) -> Self {
300        Question::Choice(question)
301    }
302}
303
304impl From<ScoreQuestion> for Question {
305    fn from(question: ScoreQuestion) -> Self {
306        Question::Score(question)
307    }
308}
309
310/// A question type with a known answer type.
311pub trait QuestionKind: Into<Question> {
312    /// The answer this question produces.
313    type Answer: AnswerKind;
314}
315
316impl QuestionKind for NoulQuestion {
317    type Answer = NoulAnswer;
318}
319
320impl QuestionKind for ChoiceQuestion {
321    type Answer = ChoiceAnswer;
322}
323
324impl QuestionKind for ScoreQuestion {
325    type Answer = ScoreAnswer;
326}
327
328/// A handle to a named question that reads back its typed answer.
329///
330/// Returned by [`Questions::add`]; pass it to
331/// [`SystemOneResult::answer`](crate::SystemOneResult::answer).
332pub struct AnswerKey<A> {
333    name: String,
334    kind: PhantomData<fn() -> A>,
335}
336
337impl<A> AnswerKey<A> {
338    /// A key for an answer by name, for questions added without [`Questions::add`].
339    pub fn new(name: impl Into<String>) -> Self {
340        Self {
341            name: name.into(),
342            kind: PhantomData,
343        }
344    }
345
346    /// The question name.
347    pub fn name(&self) -> &str {
348        &self.name
349    }
350}
351
352impl<A> Clone for AnswerKey<A> {
353    fn clone(&self) -> Self {
354        Self::new(self.name.clone())
355    }
356}
357
358impl<A: AnswerKind> fmt::Debug for AnswerKey<A> {
359    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
360        f.debug_struct("AnswerKey")
361            .field("name", &self.name)
362            .field("kind", &A::TYPE)
363            .finish()
364    }
365}
366
367/// Questions keyed by the names used to identify their answers, in insertion order.
368#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
369#[serde(transparent)]
370pub struct Questions(IndexMap<String, Question>);
371
372impl Questions {
373    /// An empty set of questions.
374    pub fn new() -> Self {
375        Self::default()
376    }
377
378    /// Add a question and get a key for its typed answer.
379    ///
380    /// A question with the same name is replaced.
381    pub fn add<Q: QuestionKind>(
382        &mut self,
383        name: impl Into<String>,
384        question: Q,
385    ) -> AnswerKey<Q::Answer> {
386        let name = name.into();
387        self.0.insert(name.clone(), question.into());
388        AnswerKey::new(name)
389    }
390
391    /// Insert a question, returning the one it replaced.
392    pub fn insert(
393        &mut self,
394        name: impl Into<String>,
395        question: impl Into<Question>,
396    ) -> Option<Question> {
397        self.0.insert(name.into(), question.into())
398    }
399
400    /// The question with this name.
401    pub fn get(&self, name: &str) -> Option<&Question> {
402        self.0.get(name)
403    }
404
405    /// Number of questions.
406    pub fn len(&self) -> usize {
407        self.0.len()
408    }
409
410    /// Whether there are no questions.
411    pub fn is_empty(&self) -> bool {
412        self.0.is_empty()
413    }
414
415    /// Questions in insertion order.
416    pub fn iter(&self) -> impl Iterator<Item = (&str, &Question)> {
417        self.0
418            .iter()
419            .map(|(name, question)| (name.as_str(), question))
420    }
421
422    /// Reject a request the API documents as invalid, before it is sent.
423    ///
424    /// An empty set, a Choice with no options or more than
425    /// [`MAX_CHOICE_OPTIONS`], and a Score outside two to
426    /// [`MAX_SCORE_LEVELS`] levels.
427    pub fn validate(&self) -> Result<()> {
428        if self.0.is_empty() {
429            return Err(Error::InvalidRequest(
430                "At least one question is required.".into(),
431            ));
432        }
433        for (name, question) in &self.0 {
434            match question {
435                Question::Score(score) if score.criteria.len() < 2 => {
436                    return Err(Error::InvalidRequest(format!(
437                        "Score question \"{name}\" has {} criteria; at least two scores are required.",
438                        score.criteria.len()
439                    )));
440                }
441                Question::Score(score) if score.criteria.len() > MAX_SCORE_LEVELS => {
442                    return Err(Error::InvalidRequest(format!(
443                        "Score question \"{name}\" has {} criteria; at most {MAX_SCORE_LEVELS} are accepted.",
444                        score.criteria.len()
445                    )));
446                }
447                Question::Choice(choice) if choice.criteria.is_empty() => {
448                    return Err(Error::InvalidRequest(format!(
449                        "Choice question \"{name}\" has no options."
450                    )));
451                }
452                Question::Choice(choice) if choice.criteria.len() > MAX_CHOICE_OPTIONS => {
453                    return Err(Error::InvalidRequest(format!(
454                        "Choice question \"{name}\" has {} options; at most {MAX_CHOICE_OPTIONS} are accepted.",
455                        choice.criteria.len()
456                    )));
457                }
458                _ => {}
459            }
460        }
461        Ok(())
462    }
463}
464
465impl<K: Into<String>, Q: Into<Question>> FromIterator<(K, Q)> for Questions {
466    fn from_iter<I: IntoIterator<Item = (K, Q)>>(iter: I) -> Self {
467        Self(
468            iter.into_iter()
469                .map(|(name, question)| (name.into(), question.into()))
470                .collect(),
471        )
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use serde_json::json;
479
480    #[test]
481    fn builders_serialize_to_the_wire_shape() {
482        let mut questions = Questions::new();
483        questions.add("urgent", noul("Does this message express urgency?"));
484        questions.add("bare", noul(Entry::Null));
485        questions.add(
486            "described",
487            noul("Refund?").when_true("asks for money back"),
488        );
489        questions.add(
490            "department",
491            choice(
492                "Which team?",
493                [("billing", Some("Payments")), ("other", None)],
494            ),
495        );
496        questions.add("tone", choice_labels("Tone?", ["calm", "angry"]));
497        questions.add(
498            "frustration",
499            score("How frustrated?", ["calm", "civil", "angry"]),
500        );
501
502        assert_eq!(
503            serde_json::to_value(&questions).unwrap(),
504            json!({
505                "urgent": {"type": "noul", "instructions": "Does this message express urgency?"},
506                "bare": {"type": "noul", "instructions": null},
507                "described": {
508                    "type": "noul",
509                    "instructions": "Refund?",
510                    "criteria": {"true": "asks for money back"}
511                },
512                "department": {
513                    "type": "choice",
514                    "instructions": "Which team?",
515                    "criteria": {"billing": "Payments", "other": null}
516                },
517                "tone": {
518                    "type": "choice",
519                    "instructions": "Tone?",
520                    "criteria": {"calm": null, "angry": null}
521                },
522                "frustration": {
523                    "type": "score",
524                    "instructions": "How frustrated?",
525                    "criteria": ["calm", "civil", "angry"]
526                }
527            })
528        );
529    }
530
531    #[test]
532    fn typed_choices_send_labels_and_descriptions_in_order() {
533        crate::labels! {
534            enum Team {
535                Billing = "billing": "Payments",
536                Other = "other",
537            }
538        }
539        let mut questions = Questions::new();
540        let key = questions.add("team", choice_of::<Team>("Which team?"));
541        assert_eq!(key.name(), "team");
542        assert_eq!(
543            serde_json::to_value(&questions).unwrap(),
544            json!({"team": {
545                "type": "choice",
546                "instructions": "Which team?",
547                "criteria": {"billing": "Payments", "other": null}
548            }})
549        );
550    }
551
552    /// The API can gain a field, or a whole question type, before this crate
553    /// does. Both official SDKs let a caller send those anyway.
554    #[test]
555    fn unmodelled_fields_and_types_are_sent_as_written() {
556        let mut questions = Questions::new();
557        questions.add("weighted", noul("Urgent?").extra("weight", 2));
558        questions.add(
559            "tone",
560            choice_labels("Tone?", ["calm", "angry"]).extra("beam_width", 4),
561        );
562        questions.add(
563            "level",
564            score("How bad?", ["low", "high"]).extra("strict", true),
565        );
566        questions.insert(
567            "ranking",
568            Question::raw(json!({"type": "rank", "criteria": ["a", "b"]})),
569        );
570
571        assert_eq!(
572            serde_json::to_value(&questions).unwrap(),
573            json!({
574                "weighted": {"type": "noul", "instructions": "Urgent?", "weight": 2},
575                "tone": {
576                    "type": "choice",
577                    "instructions": "Tone?",
578                    "criteria": {"calm": null, "angry": null},
579                    "beam_width": 4
580                },
581                "level": {
582                    "type": "score",
583                    "instructions": "How bad?",
584                    "criteria": ["low", "high"],
585                    "strict": true
586                },
587                "ranking": {"type": "rank", "criteria": ["a", "b"]}
588            })
589        );
590        // A raw question passes validation: its shape is the server's business.
591        assert!(questions.validate().is_ok());
592    }
593
594    #[test]
595    fn preserves_insertion_order() {
596        let questions: Questions = [("z", noul("z")), ("a", noul("a")), ("m", noul("m"))]
597            .into_iter()
598            .collect();
599        let names: Vec<_> = questions.iter().map(|(name, _)| name).collect();
600        assert_eq!(names, ["z", "a", "m"]);
601        let wire = serde_json::to_string(&questions).unwrap();
602        assert!(wire.find("\"z\"").unwrap() < wire.find("\"a\"").unwrap());
603    }
604
605    #[test]
606    fn validation_rejects_what_the_api_documents_as_invalid() {
607        // Option lists are often built from data, which is how they cross 255
608        // without anyone noticing.
609        let mut wide = Questions::new();
610        wide.add(
611            "team",
612            choice_labels(
613                "Which team?",
614                (0..=MAX_CHOICE_OPTIONS).map(|n| format!("team-{n}")),
615            ),
616        );
617        let err = wide.validate().unwrap_err();
618        assert_eq!(
619            err.to_string(),
620            "Choice question \"team\" has 256 options; at most 255 are accepted."
621        );
622
623        let mut exact = Questions::new();
624        exact.add(
625            "team",
626            choice_labels(
627                "Which team?",
628                (0..MAX_CHOICE_OPTIONS).map(|n| format!("team-{n}")),
629            ),
630        );
631        assert!(exact.validate().is_ok(), "255 options are allowed");
632
633        let mut empty_choice = Questions::new();
634        empty_choice.add("team", choice_labels("Which team?", Vec::<String>::new()));
635        assert_eq!(
636            empty_choice.validate().unwrap_err().to_string(),
637            "Choice question \"team\" has no options."
638        );
639
640        let mut deep = Questions::new();
641        deep.add(
642            "level",
643            score(
644                "How bad?",
645                (0..=MAX_SCORE_LEVELS).map(|n| format!("level {n}")),
646            ),
647        );
648        assert_eq!(
649            deep.validate().unwrap_err().to_string(),
650            "Score question \"level\" has 11 criteria; at most 10 are accepted."
651        );
652
653        let mut ten = Questions::new();
654        ten.add(
655            "level",
656            score(
657                "How bad?",
658                (0..MAX_SCORE_LEVELS).map(|n| format!("level {n}")),
659            ),
660        );
661        assert!(ten.validate().is_ok(), "10 levels are allowed");
662    }
663
664    #[test]
665    fn validation_rejects_empty_sets_and_short_rubrics() {
666        let err = Questions::new().validate().unwrap_err();
667        assert_eq!(err.to_string(), "At least one question is required.");
668
669        let mut questions = Questions::new();
670        questions.add("level", score("How?", ["only one"]));
671        assert_eq!(
672            questions.validate().unwrap_err().to_string(),
673            "Score question \"level\" has 1 criteria; at least two scores are required."
674        );
675
676        let mut ok = Questions::new();
677        ok.add("level", score("How?", ["low", "high"]));
678        assert!(ok.validate().is_ok());
679    }
680
681    #[test]
682    fn adding_a_duplicate_name_replaces_the_question() {
683        let mut questions = Questions::new();
684        questions.add("q", noul("first"));
685        let key = questions.add("q", choice_labels("second", ["a", "b"]));
686        assert_eq!(questions.len(), 1);
687        assert!(matches!(
688            questions.get(key.name()),
689            Some(Question::Choice(_))
690        ));
691    }
692
693    #[test]
694    fn questions_round_trip_through_json() {
695        let mut questions = Questions::new();
696        questions.add("n", noul("x").when_false("no"));
697        questions.add("s", score(Entry::Null, ["a", "b"]));
698        let json = serde_json::to_value(&questions).unwrap();
699        let back: Questions = serde_json::from_value(json).unwrap();
700        assert_eq!(back, questions);
701    }
702}