1use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::jev::error::JevError;
18
19pub const DEFAULT_MODEL: &str = "jev-latest";
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct SystemOneRequest {
26 pub state: serde_json::Value,
29
30 pub model: String,
32
33 pub questions: BTreeMap<String, Question>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "type", rename_all = "lowercase")]
46pub enum Question {
47 Choice {
49 instructions: String,
51 criteria: BTreeMap<String, String>,
55 },
56
57 Score {
59 instructions: String,
61 criteria: Vec<String>,
64 },
65
66 Noul {
68 instructions: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
73 criteria: Option<NoulCriteria>,
74 },
75}
76
77pub const MIN_CHOICE_OPTIONS: usize = 2;
80
81pub const MIN_SCORE_LEVELS: usize = 2;
83
84pub const MAX_CHOICE_OPTIONS: usize = 255;
86
87pub const MAX_SCORE_LEVELS: usize = 10;
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct QuestionLimits {
99 pub max_choice_options: Option<usize>,
101 pub max_score_levels: Option<usize>,
103}
104
105impl QuestionLimits {
106 pub const UNLIMITED: Self = Self {
108 max_choice_options: None,
109 max_score_levels: None,
110 };
111
112 const JEV_1_13: Self = Self {
114 max_choice_options: Some(MAX_CHOICE_OPTIONS),
115 max_score_levels: Some(MAX_SCORE_LEVELS),
116 };
117
118 #[must_use]
130 pub fn for_model(model: &str) -> Self {
131 let is_1_13 = model == "jev-1.13"
132 || model.strip_prefix("jev-1.13.").is_some_and(|patch| {
133 !patch.is_empty() && patch.bytes().all(|b| b.is_ascii_digit())
134 });
135 if model == DEFAULT_MODEL || is_1_13 {
136 Self::JEV_1_13
137 } else {
138 Self::UNLIMITED
139 }
140 }
141}
142
143impl Question {
144 pub fn validate(&self) -> Result<(), JevError> {
157 self.check_minimums()
158 }
159
160 pub fn validate_for_model(&self, model: &str) -> Result<(), JevError> {
165 self.check_minimums()?;
166 let limits = QuestionLimits::for_model(model);
167 let (kind, unit, count, max) = match self {
168 Self::Choice { criteria, .. } => (
169 "choice",
170 "options",
171 criteria.len(),
172 limits.max_choice_options,
173 ),
174 Self::Score { criteria, .. } => {
175 ("score", "levels", criteria.len(), limits.max_score_levels)
176 }
177 Self::Noul { .. } => return Ok(()),
178 };
179 match max {
180 Some(max) if count > max => Err(JevError::InvalidQuestionSpec(format!(
181 "a {kind} question accepts at most {max} {unit} on {model}, got {count}"
182 ))),
183 _ => Ok(()),
184 }
185 }
186
187 fn check_minimums(&self) -> Result<(), JevError> {
188 match self {
189 Self::Choice { criteria, .. } if criteria.len() < MIN_CHOICE_OPTIONS => {
190 Err(JevError::InvalidQuestionSpec(format!(
191 "a choice question needs at least {MIN_CHOICE_OPTIONS} options, got {}",
192 criteria.len()
193 )))
194 }
195 Self::Score { criteria, .. } if criteria.len() < MIN_SCORE_LEVELS => {
196 Err(JevError::InvalidQuestionSpec(format!(
197 "a score question needs at least {MIN_SCORE_LEVELS} levels, got {}",
198 criteria.len()
199 )))
200 }
201 _ => Ok(()),
202 }
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
212pub struct NoulCriteria {
213 #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
215 pub true_means: Option<String>,
216
217 #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
219 pub false_means: Option<String>,
220}
221
222#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
224pub struct SystemOneResponse {
225 pub model: String,
229
230 pub answers: BTreeMap<String, Answer>,
233
234 #[serde(default)]
237 pub usage: Usage,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
250#[serde(tag = "type", rename_all = "lowercase")]
251pub enum Answer {
252 Choice {
254 choice: String,
256 confidence: f64,
258 probabilities: BTreeMap<String, f64>,
260 },
261
262 Score {
264 score: f64,
267 confidence: f64,
269 legend: BTreeMap<String, String>,
271 probabilities: BTreeMap<String, f64>,
273 },
274
275 Noul {
281 noul: f64,
283 },
284}
285
286#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
288pub struct Usage {
289 pub input_tokens: u64,
291 pub output_tokens: u64,
293}
294
295#[cfg(test)]
296#[allow(clippy::unwrap_used, clippy::expect_used)]
297mod tests {
298 use super::*;
299
300 #[test]
303 fn choice_question_serialises_with_map_criteria() {
304 let question = Question::Choice {
305 instructions: "Route this".to_string(),
306 criteria: BTreeMap::from([
307 ("billing".to_string(), "Payments".to_string()),
308 ("technical".to_string(), "Bugs".to_string()),
309 ]),
310 };
311 let value = serde_json::to_value(&question).unwrap();
312 assert_eq!(
313 value,
314 serde_json::json!({
315 "type": "choice",
316 "instructions": "Route this",
317 "criteria": {"billing": "Payments", "technical": "Bugs"}
318 })
319 );
320 }
321
322 #[test]
323 fn score_question_serialises_with_ordered_array_criteria() {
324 let question = Question::Score {
325 instructions: "How urgent".to_string(),
326 criteria: vec!["Low".to_string(), "Medium".to_string(), "High".to_string()],
327 };
328 let value = serde_json::to_value(&question).unwrap();
329 assert_eq!(
330 value,
331 serde_json::json!({
332 "type": "score",
333 "instructions": "How urgent",
334 "criteria": ["Low", "Medium", "High"]
335 })
336 );
337 }
338
339 #[test]
340 fn noul_question_omits_criteria_when_none() {
341 let question = Question::Noul {
342 instructions: "Urgent?".to_string(),
343 criteria: None,
344 };
345 let value = serde_json::to_value(&question).unwrap();
346 assert_eq!(
347 value,
348 serde_json::json!({
349 "type": "noul",
350 "instructions": "Urgent?"
351 })
352 );
353 }
354
355 #[test]
356 fn noul_question_serialises_criteria_as_true_false_map() {
357 let question = Question::Noul {
358 instructions: "Urgent?".to_string(),
359 criteria: Some(NoulCriteria {
360 true_means: Some("Customer is frustrated".to_string()),
361 false_means: Some("Customer is calm".to_string()),
362 }),
363 };
364 let value = serde_json::to_value(&question).unwrap();
365 assert_eq!(
366 value,
367 serde_json::json!({
368 "type": "noul",
369 "instructions": "Urgent?",
370 "criteria": {
371 "true": "Customer is frustrated",
372 "false": "Customer is calm"
373 }
374 })
375 );
376 }
377
378 #[test]
379 fn noul_criteria_serialises_partial_sides() {
380 let criteria = NoulCriteria {
381 true_means: Some("yes".to_string()),
382 false_means: None,
383 };
384 let value = serde_json::to_value(&criteria).unwrap();
385 assert_eq!(value, serde_json::json!({"true": "yes"}));
386 }
387
388 const LIVE_RESPONSE: &str = r#"{"model":"jev-1.13.0","answers":{"department":{"type":"choice","choice":"billing","confidence":0.97,"probabilities":{"sales":0.0,"technical":0.02,"billing":0.98}},"frustration":{"type":"score","score":1.01,"confidence":0.98,"legend":{"0":"Calm, just stating facts","1":"Frustrated but civil","2":"Very angry, strong language"},"probabilities":{"0":0.0,"1":0.99,"2":0.01}},"refund_requested":{"type":"noul","noul":0.98}},"usage":{"input_tokens":417,"output_tokens":71}}"#;
391
392 #[test]
393 fn live_response_deserialises() {
394 let response: SystemOneResponse = serde_json::from_str(LIVE_RESPONSE).unwrap();
395 assert_eq!(response.model, "jev-1.13.0");
396 assert_eq!(response.usage.input_tokens, 417);
397 assert_eq!(response.usage.output_tokens, 71);
398 assert_eq!(response.answers.len(), 3);
399
400 match &response.answers["department"] {
401 Answer::Choice {
402 choice,
403 confidence,
404 probabilities,
405 } => {
406 assert_eq!(choice, "billing");
407 assert!((confidence - 0.97).abs() < f64::EPSILON);
408 assert!((probabilities["billing"] - 0.98).abs() < f64::EPSILON);
409 }
410 other => panic!("expected Choice, got {other:?}"),
411 }
412
413 match &response.answers["frustration"] {
414 Answer::Score {
415 score,
416 confidence,
417 legend,
418 probabilities,
419 } => {
420 assert!((score - 1.01).abs() < f64::EPSILON);
421 assert!((confidence - 0.98).abs() < f64::EPSILON);
422 assert_eq!(legend["0"], "Calm, just stating facts");
423 assert!((probabilities["1"] - 0.99).abs() < f64::EPSILON);
424 }
425 other => panic!("expected Score, got {other:?}"),
426 }
427 }
428
429 #[test]
430 fn live_response_noul_answer_has_no_confidence_field() {
431 let response: SystemOneResponse = serde_json::from_str(LIVE_RESPONSE).unwrap();
432 match &response.answers["refund_requested"] {
433 Answer::Noul { noul } => assert!((noul - 0.98).abs() < f64::EPSILON),
434 other => panic!("expected Noul, got {other:?}"),
435 }
436 }
437
438 #[test]
439 fn unknown_extra_field_on_answer_is_ignored() {
440 let json = serde_json::json!({
441 "type": "noul",
442 "noul": 0.5,
443 "some_future_field": "unexpected"
444 });
445 let answer: Answer = serde_json::from_value(json).unwrap();
446 assert_eq!(answer, Answer::Noul { noul: 0.5 });
447 }
448
449 #[test]
450 fn usage_defaults_when_missing() {
451 let json = serde_json::json!({
452 "model": "jev-1.13.0",
453 "answers": {}
454 });
455 let response: SystemOneResponse = serde_json::from_value(json).unwrap();
456 assert_eq!(response.usage, Usage::default());
457 }
458
459 fn choice_with(n: usize) -> Question {
462 Question::Choice {
463 instructions: "Route this".to_string(),
464 criteria: (0..n)
465 .map(|i| (format!("o{i}"), "desc".to_string()))
466 .collect(),
467 }
468 }
469
470 fn score_with(n: usize) -> Question {
471 Question::Score {
472 instructions: "How urgent".to_string(),
473 criteria: (0..n).map(|i| format!("l{i}")).collect(),
474 }
475 }
476
477 #[test]
478 fn validate_rejects_choice_with_fewer_than_two_options() {
479 for n in [0, 1] {
480 let err = choice_with(n).validate().unwrap_err();
481 assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
482 assert!(err.to_string().contains(&format!("got {n}")));
483 }
484 choice_with(2).validate().unwrap();
485 }
486
487 #[test]
488 fn validate_rejects_score_with_fewer_than_two_levels() {
489 for n in [0, 1] {
490 let err = score_with(n).validate().unwrap_err();
491 assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
492 assert!(err.to_string().contains(&format!("got {n}")));
493 }
494 score_with(2).validate().unwrap();
495 }
496
497 #[test]
498 fn validate_for_model_enforces_the_caps_at_their_boundary() {
499 choice_with(255).validate_for_model("jev-1.13.0").unwrap();
500 let err = choice_with(256)
501 .validate_for_model("jev-1.13.0")
502 .unwrap_err();
503 assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
504 assert_eq!(
505 err.to_string(),
506 "Invalid question specification: a choice question accepts at most 255 options on jev-1.13.0, got 256"
507 );
508 score_with(10).validate_for_model("jev-1.13.0").unwrap();
509 let err = score_with(11).validate_for_model("jev-1.13.0").unwrap_err();
510 assert!(err
511 .to_string()
512 .contains("a score question accepts at most 10 levels on jev-1.13.0, got 11"));
513 }
514
515 #[test]
516 fn validate_for_model_enforces_the_caps_for_the_default_alias() {
517 assert!(choice_with(256).validate_for_model(DEFAULT_MODEL).is_err());
518 assert!(score_with(11).validate_for_model(DEFAULT_MODEL).is_err());
519 }
520
521 #[test]
522 fn validate_for_model_leaves_unknown_models_uncapped() {
523 for model in [
524 "jev-2.0",
525 "jev-1.130",
526 "jev-1.13.",
527 "jev-1.13.x",
528 "jev-1.14.0",
529 "",
530 ] {
531 choice_with(300).validate_for_model(model).unwrap();
532 score_with(20).validate_for_model(model).unwrap();
533 }
534 }
535
536 #[test]
537 fn validate_for_model_keeps_the_minimums_for_every_model() {
538 for model in ["jev-1.13.0", "jev-2.0"] {
539 assert!(choice_with(1).validate_for_model(model).is_err());
540 assert!(score_with(1).validate_for_model(model).is_err());
541 }
542 }
543
544 #[test]
545 fn for_model_matches_the_1_13_family_only() {
546 let capped = QuestionLimits {
547 max_choice_options: Some(255),
548 max_score_levels: Some(10),
549 };
550 for model in ["jev-latest", "jev-1.13", "jev-1.13.0", "jev-1.13.7"] {
551 assert_eq!(QuestionLimits::for_model(model), capped, "{model}");
552 }
553 for model in ["jev-1.130", "jev-1.12.0", "jev-2.0.0", "jev-latest-x"] {
554 assert_eq!(
555 QuestionLimits::for_model(model),
556 QuestionLimits::UNLIMITED,
557 "{model}"
558 );
559 }
560 }
561
562 #[test]
563 fn validate_accepts_noul_without_criteria() {
564 Question::Noul {
565 instructions: "Urgent?".to_string(),
566 criteria: None,
567 }
568 .validate()
569 .unwrap();
570 }
571
572 #[test]
573 fn default_model_constant() {
574 assert_eq!(DEFAULT_MODEL, "jev-latest");
575 }
576}