use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::jev::error::JevError;
pub const DEFAULT_MODEL: &str = "jev-latest";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SystemOneRequest {
pub state: serde_json::Value,
pub model: String,
pub questions: BTreeMap<String, Question>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Question {
Choice {
instructions: String,
criteria: BTreeMap<String, String>,
},
Score {
instructions: String,
criteria: Vec<String>,
},
Noul {
instructions: String,
#[serde(skip_serializing_if = "Option::is_none")]
criteria: Option<NoulCriteria>,
},
}
pub const MIN_CHOICE_OPTIONS: usize = 2;
pub const MIN_SCORE_LEVELS: usize = 2;
pub const MAX_CHOICE_OPTIONS: usize = 255;
pub const MAX_SCORE_LEVELS: usize = 10;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct QuestionLimits {
pub max_choice_options: Option<usize>,
pub max_score_levels: Option<usize>,
}
impl QuestionLimits {
pub const UNLIMITED: Self = Self {
max_choice_options: None,
max_score_levels: None,
};
const JEV_1_13: Self = Self {
max_choice_options: Some(MAX_CHOICE_OPTIONS),
max_score_levels: Some(MAX_SCORE_LEVELS),
};
#[must_use]
pub fn for_model(model: &str) -> Self {
let is_1_13 = model == "jev-1.13"
|| model.strip_prefix("jev-1.13.").is_some_and(|patch| {
!patch.is_empty() && patch.bytes().all(|b| b.is_ascii_digit())
});
if model == DEFAULT_MODEL || is_1_13 {
Self::JEV_1_13
} else {
Self::UNLIMITED
}
}
}
impl Question {
pub fn validate(&self) -> Result<(), JevError> {
self.check_minimums()
}
pub fn validate_for_model(&self, model: &str) -> Result<(), JevError> {
self.check_minimums()?;
let limits = QuestionLimits::for_model(model);
let (kind, unit, count, max) = match self {
Self::Choice { criteria, .. } => (
"choice",
"options",
criteria.len(),
limits.max_choice_options,
),
Self::Score { criteria, .. } => {
("score", "levels", criteria.len(), limits.max_score_levels)
}
Self::Noul { .. } => return Ok(()),
};
match max {
Some(max) if count > max => Err(JevError::InvalidQuestionSpec(format!(
"a {kind} question accepts at most {max} {unit} on {model}, got {count}"
))),
_ => Ok(()),
}
}
fn check_minimums(&self) -> Result<(), JevError> {
match self {
Self::Choice { criteria, .. } if criteria.len() < MIN_CHOICE_OPTIONS => {
Err(JevError::InvalidQuestionSpec(format!(
"a choice question needs at least {MIN_CHOICE_OPTIONS} options, got {}",
criteria.len()
)))
}
Self::Score { criteria, .. } if criteria.len() < MIN_SCORE_LEVELS => {
Err(JevError::InvalidQuestionSpec(format!(
"a score question needs at least {MIN_SCORE_LEVELS} levels, got {}",
criteria.len()
)))
}
_ => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct NoulCriteria {
#[serde(rename = "true", skip_serializing_if = "Option::is_none")]
pub true_means: Option<String>,
#[serde(rename = "false", skip_serializing_if = "Option::is_none")]
pub false_means: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SystemOneResponse {
pub model: String,
pub answers: BTreeMap<String, Answer>,
#[serde(default)]
pub usage: Usage,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum Answer {
Choice {
choice: String,
confidence: f64,
probabilities: BTreeMap<String, f64>,
},
Score {
score: f64,
confidence: f64,
legend: BTreeMap<String, String>,
probabilities: BTreeMap<String, f64>,
},
Noul {
noul: f64,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn choice_question_serialises_with_map_criteria() {
let question = Question::Choice {
instructions: "Route this".to_string(),
criteria: BTreeMap::from([
("billing".to_string(), "Payments".to_string()),
("technical".to_string(), "Bugs".to_string()),
]),
};
let value = serde_json::to_value(&question).unwrap();
assert_eq!(
value,
serde_json::json!({
"type": "choice",
"instructions": "Route this",
"criteria": {"billing": "Payments", "technical": "Bugs"}
})
);
}
#[test]
fn score_question_serialises_with_ordered_array_criteria() {
let question = Question::Score {
instructions: "How urgent".to_string(),
criteria: vec!["Low".to_string(), "Medium".to_string(), "High".to_string()],
};
let value = serde_json::to_value(&question).unwrap();
assert_eq!(
value,
serde_json::json!({
"type": "score",
"instructions": "How urgent",
"criteria": ["Low", "Medium", "High"]
})
);
}
#[test]
fn noul_question_omits_criteria_when_none() {
let question = Question::Noul {
instructions: "Urgent?".to_string(),
criteria: None,
};
let value = serde_json::to_value(&question).unwrap();
assert_eq!(
value,
serde_json::json!({
"type": "noul",
"instructions": "Urgent?"
})
);
}
#[test]
fn noul_question_serialises_criteria_as_true_false_map() {
let question = Question::Noul {
instructions: "Urgent?".to_string(),
criteria: Some(NoulCriteria {
true_means: Some("Customer is frustrated".to_string()),
false_means: Some("Customer is calm".to_string()),
}),
};
let value = serde_json::to_value(&question).unwrap();
assert_eq!(
value,
serde_json::json!({
"type": "noul",
"instructions": "Urgent?",
"criteria": {
"true": "Customer is frustrated",
"false": "Customer is calm"
}
})
);
}
#[test]
fn noul_criteria_serialises_partial_sides() {
let criteria = NoulCriteria {
true_means: Some("yes".to_string()),
false_means: None,
};
let value = serde_json::to_value(&criteria).unwrap();
assert_eq!(value, serde_json::json!({"true": "yes"}));
}
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}}"#;
#[test]
fn live_response_deserialises() {
let response: SystemOneResponse = serde_json::from_str(LIVE_RESPONSE).unwrap();
assert_eq!(response.model, "jev-1.13.0");
assert_eq!(response.usage.input_tokens, 417);
assert_eq!(response.usage.output_tokens, 71);
assert_eq!(response.answers.len(), 3);
match &response.answers["department"] {
Answer::Choice {
choice,
confidence,
probabilities,
} => {
assert_eq!(choice, "billing");
assert!((confidence - 0.97).abs() < f64::EPSILON);
assert!((probabilities["billing"] - 0.98).abs() < f64::EPSILON);
}
other => panic!("expected Choice, got {other:?}"),
}
match &response.answers["frustration"] {
Answer::Score {
score,
confidence,
legend,
probabilities,
} => {
assert!((score - 1.01).abs() < f64::EPSILON);
assert!((confidence - 0.98).abs() < f64::EPSILON);
assert_eq!(legend["0"], "Calm, just stating facts");
assert!((probabilities["1"] - 0.99).abs() < f64::EPSILON);
}
other => panic!("expected Score, got {other:?}"),
}
}
#[test]
fn live_response_noul_answer_has_no_confidence_field() {
let response: SystemOneResponse = serde_json::from_str(LIVE_RESPONSE).unwrap();
match &response.answers["refund_requested"] {
Answer::Noul { noul } => assert!((noul - 0.98).abs() < f64::EPSILON),
other => panic!("expected Noul, got {other:?}"),
}
}
#[test]
fn unknown_extra_field_on_answer_is_ignored() {
let json = serde_json::json!({
"type": "noul",
"noul": 0.5,
"some_future_field": "unexpected"
});
let answer: Answer = serde_json::from_value(json).unwrap();
assert_eq!(answer, Answer::Noul { noul: 0.5 });
}
#[test]
fn usage_defaults_when_missing() {
let json = serde_json::json!({
"model": "jev-1.13.0",
"answers": {}
});
let response: SystemOneResponse = serde_json::from_value(json).unwrap();
assert_eq!(response.usage, Usage::default());
}
fn choice_with(n: usize) -> Question {
Question::Choice {
instructions: "Route this".to_string(),
criteria: (0..n)
.map(|i| (format!("o{i}"), "desc".to_string()))
.collect(),
}
}
fn score_with(n: usize) -> Question {
Question::Score {
instructions: "How urgent".to_string(),
criteria: (0..n).map(|i| format!("l{i}")).collect(),
}
}
#[test]
fn validate_rejects_choice_with_fewer_than_two_options() {
for n in [0, 1] {
let err = choice_with(n).validate().unwrap_err();
assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
assert!(err.to_string().contains(&format!("got {n}")));
}
choice_with(2).validate().unwrap();
}
#[test]
fn validate_rejects_score_with_fewer_than_two_levels() {
for n in [0, 1] {
let err = score_with(n).validate().unwrap_err();
assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
assert!(err.to_string().contains(&format!("got {n}")));
}
score_with(2).validate().unwrap();
}
#[test]
fn validate_for_model_enforces_the_caps_at_their_boundary() {
choice_with(255).validate_for_model("jev-1.13.0").unwrap();
let err = choice_with(256)
.validate_for_model("jev-1.13.0")
.unwrap_err();
assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
assert_eq!(
err.to_string(),
"Invalid question specification: a choice question accepts at most 255 options on jev-1.13.0, got 256"
);
score_with(10).validate_for_model("jev-1.13.0").unwrap();
let err = score_with(11).validate_for_model("jev-1.13.0").unwrap_err();
assert!(err
.to_string()
.contains("a score question accepts at most 10 levels on jev-1.13.0, got 11"));
}
#[test]
fn validate_for_model_enforces_the_caps_for_the_default_alias() {
assert!(choice_with(256).validate_for_model(DEFAULT_MODEL).is_err());
assert!(score_with(11).validate_for_model(DEFAULT_MODEL).is_err());
}
#[test]
fn validate_for_model_leaves_unknown_models_uncapped() {
for model in [
"jev-2.0",
"jev-1.130",
"jev-1.13.",
"jev-1.13.x",
"jev-1.14.0",
"",
] {
choice_with(300).validate_for_model(model).unwrap();
score_with(20).validate_for_model(model).unwrap();
}
}
#[test]
fn validate_for_model_keeps_the_minimums_for_every_model() {
for model in ["jev-1.13.0", "jev-2.0"] {
assert!(choice_with(1).validate_for_model(model).is_err());
assert!(score_with(1).validate_for_model(model).is_err());
}
}
#[test]
fn for_model_matches_the_1_13_family_only() {
let capped = QuestionLimits {
max_choice_options: Some(255),
max_score_levels: Some(10),
};
for model in ["jev-latest", "jev-1.13", "jev-1.13.0", "jev-1.13.7"] {
assert_eq!(QuestionLimits::for_model(model), capped, "{model}");
}
for model in ["jev-1.130", "jev-1.12.0", "jev-2.0.0", "jev-latest-x"] {
assert_eq!(
QuestionLimits::for_model(model),
QuestionLimits::UNLIMITED,
"{model}"
);
}
}
#[test]
fn validate_accepts_noul_without_criteria() {
Question::Noul {
instructions: "Urgent?".to_string(),
criteria: None,
}
.validate()
.unwrap();
}
#[test]
fn default_model_constant() {
assert_eq!(DEFAULT_MODEL, "jev-latest");
}
}