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}
282
283impl Limits {
284 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 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#[derive(Debug, Clone, PartialEq, Eq)]
311pub enum Loc {
312 Key(String),
314 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#[derive(Debug, Clone, PartialEq)]
326pub struct Problem {
327 pub loc: Vec<Loc>,
329 pub msg: String,
331 pub kind: &'static str,
333 pub input: Value,
335}
336
337impl Problem {
338 #[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
363pub 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 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 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}