Skip to main content

kime_core/
request.rs

1//! The `/v1/systemone` request, parsed from JSON and validated the way spec/03-api.md describes.
2//!
3//! Validation collects every problem instead of stopping at the first, and reports each one in
4//! FastAPI's shape (`loc`, `msg`, `type`, `input`), because that is what Jev returns and what its
5//! SDKs parse. Parsing works on a `serde_json::Value` built with `preserve_order`, so questions and
6//! choice labels keep the order they were sent in.
7
8use serde_json::{Map, Value};
9
10/// The three question types.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum QType {
13    /// Pick one of a set of labels.
14    Choice,
15    /// Pick a level on an ordered scale, answered as an expected value.
16    Score,
17    /// Yes or no, answered as the probability of yes.
18    Noul,
19}
20
21impl QType {
22    /// The name used on the wire and in Laya's rendered text.
23    #[must_use]
24    pub fn as_str(self) -> &'static str {
25        match self {
26            QType::Choice => "choice",
27            QType::Score => "score",
28            QType::Noul => "noul",
29        }
30    }
31
32    /// The index Laya's type embedding uses: choice 0, score 1, noul 2.
33    #[must_use]
34    pub fn index(self) -> usize {
35        match self {
36            QType::Choice => 0,
37            QType::Score => 1,
38            QType::Noul => 2,
39        }
40    }
41}
42
43/// One choice option. The description is `None` when it was null.
44#[derive(Debug, Clone, PartialEq)]
45pub struct ChoiceOption {
46    /// The label, exactly as sent.
47    pub label: String,
48    /// The description, or `None` for null or when the criteria were a list of labels.
49    pub description: Option<Value>,
50}
51
52/// The criteria of one question, in the shape its type takes.
53#[derive(Debug, Clone, PartialEq)]
54pub enum Criteria {
55    /// Options in the order they were sent.
56    Choice(Vec<ChoiceOption>),
57    /// Levels, index 0 first. Each is echoed back in the `legend` exactly as sent.
58    Score(Vec<Value>),
59    /// The optional descriptions of the false and true outcomes.
60    Noul {
61        /// What false means, when given.
62        when_false: Option<Value>,
63        /// What true means, when given.
64        when_true: Option<Value>,
65    },
66}
67
68impl Criteria {
69    /// The number of options the model scores for this question.
70    #[must_use]
71    pub fn len(&self) -> usize {
72        match self {
73            Criteria::Choice(o) => o.len(),
74            Criteria::Score(l) => l.len(),
75            Criteria::Noul { .. } => 2,
76        }
77    }
78
79    /// Never true for a validated question, but clippy wants it next to `len`.
80    #[must_use]
81    pub fn is_empty(&self) -> bool {
82        self.len() == 0
83    }
84}
85
86/// One validated question.
87#[derive(Debug, Clone, PartialEq)]
88pub struct Question {
89    /// The key it was sent under.
90    pub id: String,
91    /// Its type.
92    pub qtype: QType,
93    /// The instructions. `None` when the field was left out, which is not the same as null: Laya
94    /// renders null as the text `null`.
95    pub instructions: Option<Value>,
96    /// Its criteria.
97    pub criteria: Criteria,
98}
99
100/// A validated request.
101#[derive(Debug, Clone, PartialEq)]
102pub struct Request {
103    /// The state, as sent. Never null.
104    pub state: Value,
105    /// The requested model, if any.
106    pub model: Option<String>,
107    /// The questions in request order.
108    pub questions: Vec<Question>,
109    /// The `kime` extension object, not yet interpreted.
110    pub kime: Option<Map<String, Value>>,
111}
112
113/// What a request asks about: text, or any JSON value, which is rendered the way the model
114/// family expects.
115#[derive(Debug, Clone, PartialEq)]
116pub struct State(pub Value);
117
118impl State {
119    /// A structured state, such as a ticket or a record.
120    #[must_use]
121    pub fn json(v: &Value) -> Self {
122        State(v.clone())
123    }
124
125    /// A plain text state.
126    #[must_use]
127    pub fn text(s: impl Into<String>) -> Self {
128        State(Value::String(s.into()))
129    }
130}
131
132impl From<&str> for State {
133    fn from(s: &str) -> Self {
134        State::text(s)
135    }
136}
137
138impl From<String> for State {
139    fn from(s: String) -> Self {
140        State::text(s)
141    }
142}
143
144impl From<Value> for State {
145    fn from(v: Value) -> Self {
146        State(v)
147    }
148}
149
150/// Building a request in code. Nothing is checked until it is answered, where it goes through
151/// the same validation as a request that came in as JSON.
152impl Request {
153    /// A request about `state` with no questions yet.
154    #[must_use]
155    pub fn new(state: impl Into<State>) -> Self {
156        Request { state: state.into().0, model: None, questions: Vec::new(), kime: None }
157    }
158
159    /// Adds a question, or replaces the one with the same id where it stands, as a repeated key
160    /// in a JSON object would.
161    #[must_use]
162    pub fn question(mut self, q: Question) -> Self {
163        match self.questions.iter_mut().find(|x| x.id == q.id) {
164            Some(old) => *old = q,
165            None => self.questions.push(q),
166        }
167        self
168    }
169
170    /// Adds a choice question, with each option given as a label and its description.
171    #[must_use]
172    pub fn choice<L: Into<String>, D: Into<String>>(
173        self,
174        id: impl Into<String>,
175        instructions: impl Into<String>,
176        options: impl IntoIterator<Item = (L, D)>,
177    ) -> Self {
178        let options = options
179            .into_iter()
180            .map(|(l, d)| ChoiceOption {
181                label: l.into(),
182                description: Some(Value::String(d.into())),
183            })
184            .collect();
185        self.question(Question {
186            id: id.into(),
187            qtype: QType::Choice,
188            instructions: Some(Value::String(instructions.into())),
189            criteria: Criteria::Choice(options),
190        })
191    }
192
193    /// Adds a score question with its levels, lowest first.
194    #[must_use]
195    pub fn score<S: Into<String>>(
196        self,
197        id: impl Into<String>,
198        instructions: impl Into<String>,
199        levels: impl IntoIterator<Item = S>,
200    ) -> Self {
201        let levels = levels.into_iter().map(|l| Value::String(l.into())).collect();
202        self.question(Question {
203            id: id.into(),
204            qtype: QType::Score,
205            instructions: Some(Value::String(instructions.into())),
206            criteria: Criteria::Score(levels),
207        })
208    }
209
210    /// Adds a noul question: is `statement` true of the state.
211    #[must_use]
212    pub fn noul(self, id: impl Into<String>, statement: impl Into<String>) -> Self {
213        self.question(Question {
214            id: id.into(),
215            qtype: QType::Noul,
216            instructions: Some(Value::String(statement.into())),
217            criteria: Criteria::Noul { when_false: None, when_true: None },
218        })
219    }
220
221    /// The request as the JSON body [`parse`] reads.
222    #[must_use]
223    pub fn to_json(&self) -> Value {
224        let mut qs = Map::new();
225        for q in &self.questions {
226            let mut o = Map::new();
227            o.insert("type".into(), q.qtype.as_str().into());
228            if let Some(i) = &q.instructions {
229                o.insert("instructions".into(), i.clone());
230            }
231            let criteria = match &q.criteria {
232                Criteria::Choice(opts) => Some(Value::Object(
233                    opts.iter()
234                        .map(|c| (c.label.clone(), c.description.clone().unwrap_or(Value::Null)))
235                        .collect(),
236                )),
237                Criteria::Score(levels) => Some(Value::Array(levels.clone())),
238                Criteria::Noul { when_false: None, when_true: None } => None,
239                Criteria::Noul { when_false, when_true } => {
240                    let mut m = Map::new();
241                    for (k, v) in [("false", when_false), ("true", when_true)] {
242                        if let Some(v) = v {
243                            m.insert(k.into(), v.clone());
244                        }
245                    }
246                    Some(Value::Object(m))
247                }
248            };
249            if let Some(c) = criteria {
250                o.insert("criteria".into(), c);
251            }
252            qs.insert(q.id.clone(), Value::Object(o));
253        }
254        let mut body = Map::new();
255        body.insert("state".into(), self.state.clone());
256        if let Some(m) = &self.model {
257            body.insert("model".into(), m.clone().into());
258        }
259        body.insert("questions".into(), Value::Object(qs));
260        if let Some(k) = &self.kime {
261            body.insert("kime".into(), Value::Object(k.clone()));
262        }
263        Value::Object(body)
264    }
265}
266
267/// The limits and the few rules where Jev and Laya disagree.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub struct Limits {
270    /// The most questions in one request.
271    pub max_questions: usize,
272    /// The most options in one choice question.
273    pub max_options: usize,
274    /// The fewest levels a score question may have. Jev needs 2 and Laya takes 1.
275    pub min_levels: usize,
276    /// The most levels a score question may have.
277    pub max_levels: usize,
278    /// Whether an empty `questions` object is allowed. Jev rejects it and Laya answers it with no
279    /// answers.
280    pub allow_no_questions: bool,
281}
282
283impl Limits {
284    /// The defaults from spec/03-api.md.
285    pub const JEV: Limits = Limits {
286        max_questions: 256,
287        max_options: 255,
288        min_levels: 2,
289        max_levels: 32,
290        allow_no_questions: false,
291    };
292
293    /// What Laya accepts, for requests to a compat model that did not send a `kime` object.
294    pub const LAYA: Limits = Limits {
295        max_questions: 256,
296        max_options: 255,
297        min_levels: 1,
298        max_levels: 32,
299        allow_no_questions: true,
300    };
301}
302
303impl Default for Limits {
304    fn default() -> Self {
305        Limits::JEV
306    }
307}
308
309/// One element of an error location.
310#[derive(Debug, Clone, PartialEq, Eq)]
311pub enum Loc {
312    /// An object key.
313    Key(String),
314    /// An array index.
315    Index(usize),
316}
317
318impl From<&str> for Loc {
319    fn from(s: &str) -> Self {
320        Loc::Key(s.to_string())
321    }
322}
323
324/// One validation problem, in the shape FastAPI reports it.
325#[derive(Debug, Clone, PartialEq)]
326pub struct Problem {
327    /// Where the problem is, starting with `body`.
328    pub loc: Vec<Loc>,
329    /// A sentence for the person reading it.
330    pub msg: String,
331    /// A short machine readable kind, such as `missing` or `too_short`.
332    pub kind: &'static str,
333    /// The value that was rejected, or null when the field was missing.
334    pub input: Value,
335}
336
337impl Problem {
338    /// The FastAPI JSON form: `{"loc": [...], "msg": "...", "type": "...", "input": ...}`.
339    #[must_use]
340    pub fn to_json(&self) -> Value {
341        let loc: Vec<Value> = self
342            .loc
343            .iter()
344            .map(|l| match l {
345                Loc::Key(k) => Value::String(k.clone()),
346                Loc::Index(i) => Value::from(*i),
347            })
348            .collect();
349        serde_json::json!({"loc": loc, "msg": self.msg, "type": self.kind, "input": self.input})
350    }
351}
352
353struct Problems(Vec<Problem>);
354
355impl Problems {
356    fn add(&mut self, loc: &[Loc], kind: &'static str, msg: impl Into<String>, input: &Value) {
357        let mut full = vec![Loc::from("body")];
358        full.extend_from_slice(loc);
359        self.0.push(Problem { loc: full, msg: msg.into(), kind, input: input.clone() });
360    }
361}
362
363/// Parse and validate a request body.
364///
365/// # Errors
366///
367/// Every problem found, in the order the fields appear.
368pub fn parse(body: &Value, limits: &Limits) -> Result<Request, Vec<Problem>> {
369    let mut p = Problems(Vec::new());
370    let Some(obj) = body.as_object() else {
371        p.add(&[], "dict_type", "the request body must be a JSON object", body);
372        return Err(p.0);
373    };
374
375    let state = match obj.get("state") {
376        None => {
377            p.add(&["state".into()], "missing", "Field required", &Value::Null);
378            Value::Null
379        }
380        Some(Value::Null) => {
381            p.add(
382                &["state".into()],
383                "value_error",
384                "state must not be null, send \"\" for an empty state",
385                &Value::Null,
386            );
387            Value::Null
388        }
389        Some(s) => s.clone(),
390    };
391
392    let model = match obj.get("model") {
393        None | Some(Value::Null) => None,
394        Some(Value::String(s)) => Some(s.clone()),
395        Some(other) => {
396            p.add(&["model".into()], "string_type", "Input should be a valid string", other);
397            None
398        }
399    };
400
401    let kime = match obj.get("kime") {
402        None | Some(Value::Null) => None,
403        Some(Value::Object(m)) => Some(m.clone()),
404        Some(other) => {
405            p.add(&["kime".into()], "dict_type", "Input should be a valid dictionary", other);
406            None
407        }
408    };
409
410    let mut questions = Vec::new();
411    match obj.get("questions") {
412        None => p.add(&["questions".into()], "missing", "Field required", &Value::Null),
413        Some(Value::Object(qs)) => {
414            if qs.is_empty() && !limits.allow_no_questions {
415                p.add(
416                    &["questions".into()],
417                    "too_short",
418                    "questions must have at least one question",
419                    &Value::Object(qs.clone()),
420                );
421            }
422            if qs.len() > limits.max_questions {
423                p.add(
424                    &["questions".into()],
425                    "too_long",
426                    format!(
427                        "a request can have at most {} questions, got {}",
428                        limits.max_questions,
429                        qs.len()
430                    ),
431                    &Value::Null,
432                );
433            }
434            for (id, q) in qs {
435                if let Some(q) = question(id, q, limits, &mut p) {
436                    questions.push(q);
437                }
438            }
439        }
440        Some(other) => {
441            p.add(&["questions".into()], "dict_type", "Input should be a valid dictionary", other)
442        }
443    }
444
445    if p.0.is_empty() { Ok(Request { state, model, questions, kime }) } else { Err(p.0) }
446}
447
448fn question(id: &str, q: &Value, limits: &Limits, p: &mut Problems) -> Option<Question> {
449    let at = |rest: &[Loc]| {
450        let mut loc = vec![Loc::from("questions"), Loc::Key(id.to_string())];
451        loc.extend_from_slice(rest);
452        loc
453    };
454    let Some(obj) = q.as_object() else {
455        p.add(&at(&[]), "dict_type", "Input should be a valid dictionary", q);
456        return None;
457    };
458    let qtype = match obj.get("type") {
459        None => {
460            p.add(&at(&["type".into()]), "missing", "Field required", &Value::Null);
461            return None;
462        }
463        Some(Value::String(t)) if t == "choice" => QType::Choice,
464        Some(Value::String(t)) if t == "score" => QType::Score,
465        Some(Value::String(t)) if t == "noul" => QType::Noul,
466        Some(other) => {
467            p.add(
468                &at(&["type".into()]),
469                "literal_error",
470                "Input should be 'choice', 'score' or 'noul'",
471                other,
472            );
473            return None;
474        }
475    };
476    let instructions = obj.get("instructions").cloned();
477    let crit_loc = at(&["criteria".into()]);
478    let crit = obj.get("criteria");
479    let before = p.0.len();
480    let criteria = match qtype {
481        QType::Choice => choice(id, crit, limits, &crit_loc, p),
482        QType::Score => score(id, crit, limits, &crit_loc, p),
483        QType::Noul => noul(id, crit, &crit_loc, p),
484    };
485    if p.0.len() > before {
486        return None;
487    }
488    Some(Question { id: id.to_string(), qtype, instructions, criteria })
489}
490
491fn with(loc: &[Loc], last: Loc) -> Vec<Loc> {
492    let mut v = loc.to_vec();
493    v.push(last);
494    v
495}
496
497fn choice(
498    id: &str,
499    crit: Option<&Value>,
500    limits: &Limits,
501    loc: &[Loc],
502    p: &mut Problems,
503) -> Criteria {
504    let mut options = Vec::new();
505    match crit {
506        None => p.add(loc, "missing", "Field required", &Value::Null),
507        Some(Value::Object(m)) => {
508            for (label, desc) in m {
509                let description = if desc.is_null() { None } else { Some(desc.clone()) };
510                options.push(ChoiceOption { label: label.clone(), description });
511            }
512        }
513        Some(Value::Array(items)) => {
514            for (i, item) in items.iter().enumerate() {
515                match item {
516                    Value::String(label) => options.push(ChoiceOption { label: label.clone(), description: None }),
517                    other => p.add(&with(loc, Loc::Index(i)), "string_type", "Input should be a valid string", other),
518                }
519            }
520        }
521        Some(other) => p.add(
522            loc,
523            "choice_criteria_type",
524            format!("choice question '{id}' takes criteria as an object of label to description, or an array of labels"),
525            other,
526        ),
527    }
528    if let Some(c) = crit.filter(|c| c.is_object() || c.is_array()) {
529        let n = match c {
530            Value::Object(m) => m.len(),
531            Value::Array(a) => a.len(),
532            _ => 0,
533        };
534        if n == 0 {
535            p.add(
536                loc,
537                "too_short",
538                format!("choice question '{id}' needs at least 1 option, got 0"),
539                c,
540            );
541        }
542        if n > limits.max_options {
543            p.add(
544                loc,
545                "too_long",
546                format!(
547                    "choice question '{id}' has {n} options, the limit is {}",
548                    limits.max_options
549                ),
550                &Value::Null,
551            );
552        }
553    }
554    let mut seen = std::collections::HashSet::new();
555    for o in &options {
556        let trimmed = o.label.trim();
557        if !seen.insert(trimmed) {
558            p.add(
559                loc,
560                "duplicate_label",
561                format!("choice question '{id}' has the label '{trimmed}' more than once"),
562                &Value::String(o.label.clone()),
563            );
564        }
565    }
566    Criteria::Choice(options)
567}
568
569fn score(
570    id: &str,
571    crit: Option<&Value>,
572    limits: &Limits,
573    loc: &[Loc],
574    p: &mut Problems,
575) -> Criteria {
576    let levels: Vec<Value> = match crit {
577        None => {
578            p.add(loc, "missing", "Field required", &Value::Null);
579            return Criteria::Score(Vec::new());
580        }
581        Some(Value::Array(items)) => items.clone(),
582        // Old Python SDK clients send {"0": ..., "1": ...}. Accepted when the keys are exactly 0 to n-1.
583        Some(Value::Object(m)) if (0..m.len()).all(|i| m.contains_key(&i.to_string())) => {
584            (0..m.len()).map(|i| m[&i.to_string()].clone()).collect()
585        }
586        Some(other) => {
587            p.add(
588                loc,
589                "score_criteria_type",
590                format!("score question '{id}' takes criteria as an array of level descriptions, level 0 first"),
591                other,
592            );
593            return Criteria::Score(Vec::new());
594        }
595    };
596    let n = levels.len();
597    if n < limits.min_levels {
598        let unit = if limits.min_levels == 1 { "level" } else { "levels" };
599        p.add(
600            loc,
601            "too_short",
602            format!("score question '{id}' needs at least {} {unit}, got {n}", limits.min_levels),
603            crit.unwrap_or(&Value::Null),
604        );
605    }
606    if n > limits.max_levels {
607        p.add(
608            loc,
609            "too_long",
610            format!("score question '{id}' has {n} levels, the limit is {}", limits.max_levels),
611            &Value::Null,
612        );
613    }
614    Criteria::Score(levels)
615}
616
617fn noul(id: &str, crit: Option<&Value>, loc: &[Loc], p: &mut Problems) -> Criteria {
618    let mut when_false = None;
619    let mut when_true = None;
620    match crit {
621        None | Some(Value::Null) => {}
622        Some(Value::Object(m)) => {
623            // Keys match case insensitively, as in Laya, which lower cases str(key). A later key
624            // wins over an earlier one that lower cases the same, as it does in a Python dict.
625            for (k, v) in m {
626                match k.to_lowercase().as_str() {
627                    "true" => when_true = Some(v.clone()),
628                    "false" => when_false = Some(v.clone()),
629                    _ => p.add(
630                        &with(loc, Loc::Key(k.clone())),
631                        "noul_key",
632                        format!("noul question '{id}' takes only the keys true and false, got '{k}'"),
633                        v,
634                    ),
635                }
636            }
637        }
638        Some(other) => p.add(
639            loc,
640            "noul_criteria_type",
641            format!("noul question '{id}' takes criteria as an object with optional true and false descriptions, or none"),
642            other,
643        ),
644    }
645    Criteria::Noul { when_false, when_true }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use serde_json::json;
652
653    #[test]
654    fn built_requests_round_trip() {
655        let r = Request::new(State::json(&json!({"msg": "refund please"})))
656            .choice("dept", "Which team", [("billing", "Payment issues"), ("technical", "Bugs")])
657            .score("urgency", "How urgent", ["not urgent", "soon", "critical"])
658            .noul("churn", "The customer threatens to leave")
659            .noul("dept", "Replaced where it stood");
660        let back = parse(&r.to_json(), &Limits::JEV).unwrap();
661        assert_eq!(back, r);
662        assert_eq!(back.questions[0].qtype, QType::Noul);
663    }
664
665    #[test]
666    fn the_spec_example_parses() {
667        let body = json!({
668            "state": {"ticket": {"subject": "Refund", "messages": [{"text": "charged twice"}]}},
669            "model": "jev-latest",
670            "questions": {
671                "topic": {"type": "choice", "instructions": "Which team?", "criteria": {"billing": "money", "technical": null, "sales": ""}},
672                "urgency": {"type": "score", "instructions": "How upset?", "criteria": ["Calm", "Frustrated", "Very angry"]},
673                "refund": {"type": "noul", "instructions": "Wants a refund."}
674            }
675        });
676        let r = parse(&body, &Limits::JEV).unwrap();
677        assert_eq!(r.model.as_deref(), Some("jev-latest"));
678        let ids: Vec<&str> = r.questions.iter().map(|q| q.id.as_str()).collect();
679        assert_eq!(ids, ["topic", "urgency", "refund"]);
680        let Criteria::Choice(o) = &r.questions[0].criteria else { panic!() };
681        assert_eq!(o[1].description, None);
682        assert_eq!(r.questions[2].criteria, Criteria::Noul { when_false: None, when_true: None });
683    }
684
685    #[test]
686    fn every_problem_is_reported() {
687        let body = json!({
688            "questions": {
689                "a": {"type": "score", "criteria": ["only"]},
690                "b": {"type": "maybe"},
691                "c": {"type": "choice", "criteria": {"x": 1, " x": 2}},
692                "d": {"type": "noul", "criteria": {"True": "yes", "perhaps": "?"}},
693                "e": {"type": "choice", "criteria": []}
694            }
695        });
696        let errs = parse(&body, &Limits::JEV).unwrap_err();
697        let msgs: Vec<&str> = errs.iter().map(|e| e.msg.as_str()).collect();
698        assert_eq!(
699            msgs,
700            [
701                "Field required",
702                "score question 'a' needs at least 2 levels, got 1",
703                "Input should be 'choice', 'score' or 'noul'",
704                "choice question 'c' has the label 'x' more than once",
705                "noul question 'd' takes only the keys true and false, got 'perhaps'",
706                "choice question 'e' needs at least 1 option, got 0",
707            ]
708        );
709        assert_eq!(errs[1].to_json()["loc"], json!(["body", "questions", "a", "criteria"]));
710        assert_eq!(
711            errs[4].to_json()["loc"],
712            json!(["body", "questions", "d", "criteria", "perhaps"])
713        );
714    }
715
716    #[test]
717    fn laya_rules() {
718        let body = json!({"state": "", "questions": {}});
719        assert!(parse(&body, &Limits::JEV).is_err());
720        assert!(parse(&body, &Limits::LAYA).unwrap().questions.is_empty());
721        let one = json!({"state": "x", "questions": {"s": {"type": "score", "criteria": ["a"]}}});
722        assert!(parse(&one, &Limits::JEV).is_err());
723        assert!(parse(&one, &Limits::LAYA).is_ok());
724    }
725
726    #[test]
727    fn old_sdk_score_objects() {
728        let body = json!({"state": "x", "questions": {"s": {"type": "score", "criteria": {"1": "b", "0": "a"}}}});
729        let r = parse(&body, &Limits::JEV).unwrap();
730        assert_eq!(r.questions[0].criteria, Criteria::Score(vec![json!("a"), json!("b")]));
731        let bad = json!({"state": "x", "questions": {"s": {"type": "score", "criteria": {"0": "a", "2": "b"}}}});
732        assert!(parse(&bad, &Limits::JEV).is_err());
733    }
734}