1use serde_json::{Map, Value};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub enum QType {
13 Choice,
15 Score,
17 Noul,
19}
20
21impl QType {
22 #[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 #[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#[derive(Debug, Clone, PartialEq)]
45pub struct ChoiceOption {
46 pub label: String,
48 pub description: Option<Value>,
50}
51
52#[derive(Debug, Clone, PartialEq)]
54pub enum Criteria {
55 Choice(Vec<ChoiceOption>),
57 Score(Vec<Value>),
59 Noul {
61 when_false: Option<Value>,
63 when_true: Option<Value>,
65 },
66}
67
68impl Criteria {
69 #[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 #[must_use]
81 pub fn is_empty(&self) -> bool {
82 self.len() == 0
83 }
84}
85
86#[derive(Debug, Clone, PartialEq)]
88pub struct Question {
89 pub id: String,
91 pub qtype: QType,
93 pub instructions: Option<Value>,
96 pub criteria: Criteria,
98}
99
100#[derive(Debug, Clone, PartialEq)]
102pub struct Request {
103 pub state: Value,
105 pub model: Option<String>,
107 pub questions: Vec<Question>,
109 pub kime: Option<Map<String, Value>>,
111}
112
113#[derive(Debug, Clone, PartialEq)]
116pub struct State(pub Value);
117
118impl State {
119 #[must_use]
121 pub fn json(v: &Value) -> Self {
122 State(v.clone())
123 }
124
125 #[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
150impl Request {
153 #[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 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub struct Limits {
270 pub max_questions: usize,
272 pub max_options: usize,
274 pub min_levels: usize,
276 pub max_levels: usize,
278 pub allow_no_questions: bool,
281 pub lenient: bool,
285}
286
287impl Limits {
288 pub const JEV: Limits = Limits {
290 max_questions: 256,
291 max_options: 255,
292 min_levels: 2,
293 max_levels: 32,
294 allow_no_questions: false,
295 lenient: false,
296 };
297
298 pub const LAYA: Limits = Limits {
300 max_questions: 256,
301 max_options: 255,
302 min_levels: 1,
303 max_levels: 32,
304 allow_no_questions: true,
305 lenient: true,
306 };
307}
308
309impl Default for Limits {
310 fn default() -> Self {
311 Limits::JEV
312 }
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum Loc {
318 Key(String),
320 Index(usize),
322}
323
324impl From<&str> for Loc {
325 fn from(s: &str) -> Self {
326 Loc::Key(s.to_string())
327 }
328}
329
330#[derive(Debug, Clone, PartialEq)]
332pub struct Problem {
333 pub loc: Vec<Loc>,
335 pub msg: String,
337 pub kind: &'static str,
339 pub input: Value,
341}
342
343impl Problem {
344 #[must_use]
346 pub fn to_json(&self) -> Value {
347 let loc: Vec<Value> = self
348 .loc
349 .iter()
350 .map(|l| match l {
351 Loc::Key(k) => Value::String(k.clone()),
352 Loc::Index(i) => Value::from(*i),
353 })
354 .collect();
355 serde_json::json!({"loc": loc, "msg": self.msg, "type": self.kind, "input": self.input})
356 }
357}
358
359struct Problems(Vec<Problem>);
360
361impl Problems {
362 fn add(&mut self, loc: &[Loc], kind: &'static str, msg: impl Into<String>, input: &Value) {
363 let mut full = vec![Loc::from("body")];
364 full.extend_from_slice(loc);
365 self.0.push(Problem { loc: full, msg: msg.into(), kind, input: input.clone() });
366 }
367}
368
369pub fn parse(body: &Value, limits: &Limits) -> Result<Request, Vec<Problem>> {
375 let mut p = Problems(Vec::new());
376 let Some(obj) = body.as_object() else {
377 p.add(&[], "dict_type", "the request body must be a JSON object", body);
378 return Err(p.0);
379 };
380
381 let state = match obj.get("state") {
382 None | Some(Value::Null) if limits.lenient => Value::Null,
383 None => {
384 p.add(&["state".into()], "missing", "Field required", &Value::Null);
385 Value::Null
386 }
387 Some(Value::Null) => {
388 p.add(
389 &["state".into()],
390 "value_error",
391 "state must not be null, send \"\" for an empty state",
392 &Value::Null,
393 );
394 Value::Null
395 }
396 Some(s) => s.clone(),
397 };
398
399 let model = match obj.get("model") {
400 None | Some(Value::Null) => None,
401 Some(Value::String(s)) => Some(s.clone()),
402 Some(other) => {
403 p.add(&["model".into()], "string_type", "Input should be a valid string", other);
404 None
405 }
406 };
407
408 let kime = match obj.get("kime") {
409 None | Some(Value::Null) => None,
410 Some(Value::Object(m)) => Some(m.clone()),
411 Some(other) => {
412 p.add(&["kime".into()], "dict_type", "Input should be a valid dictionary", other);
413 None
414 }
415 };
416
417 let mut questions = Vec::new();
418 match obj.get("questions") {
419 None => p.add(&["questions".into()], "missing", "Field required", &Value::Null),
420 Some(Value::Object(qs)) => {
421 if qs.is_empty() && !limits.allow_no_questions {
422 p.add(
423 &["questions".into()],
424 "too_short",
425 "questions must have at least one question",
426 &Value::Object(qs.clone()),
427 );
428 }
429 if qs.len() > limits.max_questions {
430 p.add(
431 &["questions".into()],
432 "too_long",
433 format!(
434 "a request can have at most {} questions, got {}",
435 limits.max_questions,
436 qs.len()
437 ),
438 &Value::Null,
439 );
440 }
441 for (id, q) in qs {
442 if let Some(q) = question(id, q, limits, &mut p) {
443 questions.push(q);
444 }
445 }
446 }
447 Some(other) => {
448 p.add(&["questions".into()], "dict_type", "Input should be a valid dictionary", other)
449 }
450 }
451
452 if p.0.is_empty() { Ok(Request { state, model, questions, kime }) } else { Err(p.0) }
453}
454
455fn question(id: &str, q: &Value, limits: &Limits, p: &mut Problems) -> Option<Question> {
456 let at = |rest: &[Loc]| {
457 let mut loc = vec![Loc::from("questions"), Loc::Key(id.to_string())];
458 loc.extend_from_slice(rest);
459 loc
460 };
461 let Some(obj) = q.as_object() else {
462 p.add(&at(&[]), "dict_type", "Input should be a valid dictionary", q);
463 return None;
464 };
465 let qtype = match obj.get("type") {
466 None => {
467 p.add(&at(&["type".into()]), "missing", "Field required", &Value::Null);
468 return None;
469 }
470 Some(Value::String(t)) if t == "choice" => QType::Choice,
471 Some(Value::String(t)) if t == "score" => QType::Score,
472 Some(Value::String(t)) if t == "noul" => QType::Noul,
473 Some(other) => {
474 p.add(
475 &at(&["type".into()]),
476 "literal_error",
477 "Input should be 'choice', 'score' or 'noul'",
478 other,
479 );
480 return None;
481 }
482 };
483 let instructions = obj.get("instructions").cloned();
484 let crit_loc = at(&["criteria".into()]);
485 let crit = obj.get("criteria");
486 let before = p.0.len();
487 let criteria = match qtype {
488 QType::Choice => choice(id, crit, limits, &crit_loc, p),
489 QType::Score => score(id, crit, limits, &crit_loc, p),
490 QType::Noul => noul(id, crit, &crit_loc, limits, p),
491 };
492 if p.0.len() > before {
493 return None;
494 }
495 Some(Question { id: id.to_string(), qtype, instructions, criteria })
496}
497
498fn with(loc: &[Loc], last: Loc) -> Vec<Loc> {
499 let mut v = loc.to_vec();
500 v.push(last);
501 v
502}
503
504fn choice(
505 id: &str,
506 crit: Option<&Value>,
507 limits: &Limits,
508 loc: &[Loc],
509 p: &mut Problems,
510) -> Criteria {
511 let mut options = Vec::new();
512 match crit {
513 None => p.add(loc, "missing", "Field required", &Value::Null),
514 Some(Value::Object(m)) => {
515 for (label, desc) in m {
516 let description = if desc.is_null() { None } else { Some(desc.clone()) };
517 options.push(ChoiceOption { label: label.clone(), description });
518 }
519 }
520 Some(Value::Array(items)) => {
521 for (i, item) in items.iter().enumerate() {
522 match item {
523 Value::String(label) => options.push(ChoiceOption { label: label.clone(), description: None }),
524 other => p.add(&with(loc, Loc::Index(i)), "string_type", "Input should be a valid string", other),
525 }
526 }
527 }
528 Some(other) => p.add(
529 loc,
530 "choice_criteria_type",
531 format!("choice question '{id}' takes criteria as an object of label to description, or an array of labels"),
532 other,
533 ),
534 }
535 if let Some(c) = crit.filter(|c| c.is_object() || c.is_array()) {
536 let n = match c {
537 Value::Object(m) => m.len(),
538 Value::Array(a) => a.len(),
539 _ => 0,
540 };
541 if n == 0 {
542 p.add(
543 loc,
544 "too_short",
545 format!("choice question '{id}' needs at least 1 option, got 0"),
546 c,
547 );
548 }
549 if n > limits.max_options {
550 p.add(
551 loc,
552 "too_long",
553 format!(
554 "choice question '{id}' has {n} options, the limit is {}",
555 limits.max_options
556 ),
557 &Value::Null,
558 );
559 }
560 }
561 let mut seen = std::collections::HashSet::new();
562 for o in &options {
563 let trimmed = o.label.trim();
564 if !seen.insert(trimmed) {
565 p.add(
566 loc,
567 "duplicate_label",
568 format!("choice question '{id}' has the label '{trimmed}' more than once"),
569 &Value::String(o.label.clone()),
570 );
571 }
572 }
573 Criteria::Choice(options)
574}
575
576fn score(
577 id: &str,
578 crit: Option<&Value>,
579 limits: &Limits,
580 loc: &[Loc],
581 p: &mut Problems,
582) -> Criteria {
583 let levels: Vec<Value> = match crit {
584 None => {
585 p.add(loc, "missing", "Field required", &Value::Null);
586 return Criteria::Score(Vec::new());
587 }
588 Some(Value::Array(items)) => items.clone(),
589 Some(Value::Object(m)) if (0..m.len()).all(|i| m.contains_key(&i.to_string())) => {
591 (0..m.len()).map(|i| m[&i.to_string()].clone()).collect()
592 }
593 Some(other) => {
594 p.add(
595 loc,
596 "score_criteria_type",
597 format!("score question '{id}' takes criteria as an array of level descriptions, level 0 first"),
598 other,
599 );
600 return Criteria::Score(Vec::new());
601 }
602 };
603 let n = levels.len();
604 if n < limits.min_levels {
605 let unit = if limits.min_levels == 1 { "level" } else { "levels" };
606 p.add(
607 loc,
608 "too_short",
609 format!("score question '{id}' needs at least {} {unit}, got {n}", limits.min_levels),
610 crit.unwrap_or(&Value::Null),
611 );
612 }
613 if n > limits.max_levels {
614 p.add(
615 loc,
616 "too_long",
617 format!("score question '{id}' has {n} levels, the limit is {}", limits.max_levels),
618 &Value::Null,
619 );
620 }
621 Criteria::Score(levels)
622}
623
624fn noul(
625 id: &str,
626 crit: Option<&Value>,
627 loc: &[Loc],
628 limits: &Limits,
629 p: &mut Problems,
630) -> Criteria {
631 let mut when_false = None;
632 let mut when_true = None;
633 match crit {
634 None | Some(Value::Null) => {}
635 Some(Value::Object(m)) => {
636 for (k, v) in m {
639 match k.to_lowercase().as_str() {
640 "true" => when_true = Some(v.clone()),
641 "false" => when_false = Some(v.clone()),
642 _ if limits.lenient => {}
643 _ => p.add(
644 &with(loc, Loc::Key(k.clone())),
645 "noul_key",
646 format!("noul question '{id}' takes only the keys true and false, got '{k}'"),
647 v,
648 ),
649 }
650 }
651 }
652 Some(other) => p.add(
653 loc,
654 "noul_criteria_type",
655 format!("noul question '{id}' takes criteria as an object with optional true and false descriptions, or none"),
656 other,
657 ),
658 }
659 Criteria::Noul { when_false, when_true }
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use serde_json::json;
666
667 #[test]
668 fn built_requests_round_trip() {
669 let r = Request::new(State::json(&json!({"msg": "refund please"})))
670 .choice("dept", "Which team", [("billing", "Payment issues"), ("technical", "Bugs")])
671 .score("urgency", "How urgent", ["not urgent", "soon", "critical"])
672 .noul("churn", "The customer threatens to leave")
673 .noul("dept", "Replaced where it stood");
674 let back = parse(&r.to_json(), &Limits::JEV).unwrap();
675 assert_eq!(back, r);
676 assert_eq!(back.questions[0].qtype, QType::Noul);
677 }
678
679 #[test]
680 fn the_spec_example_parses() {
681 let body = json!({
682 "state": {"ticket": {"subject": "Refund", "messages": [{"text": "charged twice"}]}},
683 "model": "jev-latest",
684 "questions": {
685 "topic": {"type": "choice", "instructions": "Which team?", "criteria": {"billing": "money", "technical": null, "sales": ""}},
686 "urgency": {"type": "score", "instructions": "How upset?", "criteria": ["Calm", "Frustrated", "Very angry"]},
687 "refund": {"type": "noul", "instructions": "Wants a refund."}
688 }
689 });
690 let r = parse(&body, &Limits::JEV).unwrap();
691 assert_eq!(r.model.as_deref(), Some("jev-latest"));
692 let ids: Vec<&str> = r.questions.iter().map(|q| q.id.as_str()).collect();
693 assert_eq!(ids, ["topic", "urgency", "refund"]);
694 let Criteria::Choice(o) = &r.questions[0].criteria else { panic!() };
695 assert_eq!(o[1].description, None);
696 assert_eq!(r.questions[2].criteria, Criteria::Noul { when_false: None, when_true: None });
697 }
698
699 #[test]
700 fn every_problem_is_reported() {
701 let body = json!({
702 "questions": {
703 "a": {"type": "score", "criteria": ["only"]},
704 "b": {"type": "maybe"},
705 "c": {"type": "choice", "criteria": {"x": 1, " x": 2}},
706 "d": {"type": "noul", "criteria": {"True": "yes", "perhaps": "?"}},
707 "e": {"type": "choice", "criteria": []}
708 }
709 });
710 let errs = parse(&body, &Limits::JEV).unwrap_err();
711 let msgs: Vec<&str> = errs.iter().map(|e| e.msg.as_str()).collect();
712 assert_eq!(
713 msgs,
714 [
715 "Field required",
716 "score question 'a' needs at least 2 levels, got 1",
717 "Input should be 'choice', 'score' or 'noul'",
718 "choice question 'c' has the label 'x' more than once",
719 "noul question 'd' takes only the keys true and false, got 'perhaps'",
720 "choice question 'e' needs at least 1 option, got 0",
721 ]
722 );
723 assert_eq!(errs[1].to_json()["loc"], json!(["body", "questions", "a", "criteria"]));
724 assert_eq!(
725 errs[4].to_json()["loc"],
726 json!(["body", "questions", "d", "criteria", "perhaps"])
727 );
728 }
729
730 #[test]
731 fn laya_rules() {
732 let body = json!({"state": "", "questions": {}});
733 assert!(parse(&body, &Limits::JEV).is_err());
734 assert!(parse(&body, &Limits::LAYA).unwrap().questions.is_empty());
735 let one = json!({"state": "x", "questions": {"s": {"type": "score", "criteria": ["a"]}}});
736 assert!(parse(&one, &Limits::JEV).is_err());
737 assert!(parse(&one, &Limits::LAYA).is_ok());
738 for body in [json!({"state": null, "questions": {}}), json!({"questions": {}})] {
740 assert!(parse(&body, &Limits::JEV).is_err());
741 assert_eq!(parse(&body, &Limits::LAYA).unwrap().state, Value::Null);
742 }
743 let noul = json!({"state": "x", "questions": {"n": {"type": "noul", "instructions": "i", "criteria": {"maybe": "m", "True": "yes"}}}});
745 assert!(parse(&noul, &Limits::JEV).is_err());
746 assert_eq!(
747 parse(&noul, &Limits::LAYA).unwrap().questions[0].criteria,
748 Criteria::Noul { when_false: None, when_true: Some(json!("yes")) }
749 );
750 }
751
752 #[test]
753 fn old_sdk_score_objects() {
754 let body = json!({"state": "x", "questions": {"s": {"type": "score", "criteria": {"1": "b", "0": "a"}}}});
755 let r = parse(&body, &Limits::JEV).unwrap();
756 assert_eq!(r.questions[0].criteria, Criteria::Score(vec![json!("a"), json!("b")]));
757 let bad = json!({"state": "x", "questions": {"s": {"type": "score", "criteria": {"0": "a", "2": "b"}}}});
758 assert!(parse(&bad, &Limits::JEV).is_err());
759 }
760}