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
84impl Question {
85 pub fn validate(&self) -> Result<(), JevError> {
98 match self {
99 Self::Choice { criteria, .. } if criteria.len() < MIN_CHOICE_OPTIONS => {
100 Err(JevError::InvalidQuestionSpec(format!(
101 "a choice question needs at least {MIN_CHOICE_OPTIONS} options, got {}",
102 criteria.len()
103 )))
104 }
105 Self::Score { criteria, .. } if criteria.len() < MIN_SCORE_LEVELS => {
106 Err(JevError::InvalidQuestionSpec(format!(
107 "a score question needs at least {MIN_SCORE_LEVELS} levels, got {}",
108 criteria.len()
109 )))
110 }
111 _ => Ok(()),
112 }
113 }
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
122pub struct NoulCriteria {
123 #[serde(rename = "true", skip_serializing_if = "Option::is_none")]
125 pub true_means: Option<String>,
126
127 #[serde(rename = "false", skip_serializing_if = "Option::is_none")]
129 pub false_means: Option<String>,
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
134pub struct SystemOneResponse {
135 pub model: String,
139
140 pub answers: BTreeMap<String, Answer>,
143
144 #[serde(default)]
147 pub usage: Usage,
148}
149
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
160#[serde(tag = "type", rename_all = "lowercase")]
161pub enum Answer {
162 Choice {
164 choice: String,
166 confidence: f64,
168 probabilities: BTreeMap<String, f64>,
170 },
171
172 Score {
174 score: f64,
177 confidence: f64,
179 legend: BTreeMap<String, String>,
181 probabilities: BTreeMap<String, f64>,
183 },
184
185 Noul {
191 noul: f64,
193 },
194}
195
196#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
198pub struct Usage {
199 pub input_tokens: u64,
201 pub output_tokens: u64,
203}
204
205#[cfg(test)]
206#[allow(clippy::unwrap_used, clippy::expect_used)]
207mod tests {
208 use super::*;
209
210 #[test]
213 fn choice_question_serialises_with_map_criteria() {
214 let question = Question::Choice {
215 instructions: "Route this".to_string(),
216 criteria: BTreeMap::from([
217 ("billing".to_string(), "Payments".to_string()),
218 ("technical".to_string(), "Bugs".to_string()),
219 ]),
220 };
221 let value = serde_json::to_value(&question).unwrap();
222 assert_eq!(
223 value,
224 serde_json::json!({
225 "type": "choice",
226 "instructions": "Route this",
227 "criteria": {"billing": "Payments", "technical": "Bugs"}
228 })
229 );
230 }
231
232 #[test]
233 fn score_question_serialises_with_ordered_array_criteria() {
234 let question = Question::Score {
235 instructions: "How urgent".to_string(),
236 criteria: vec!["Low".to_string(), "Medium".to_string(), "High".to_string()],
237 };
238 let value = serde_json::to_value(&question).unwrap();
239 assert_eq!(
240 value,
241 serde_json::json!({
242 "type": "score",
243 "instructions": "How urgent",
244 "criteria": ["Low", "Medium", "High"]
245 })
246 );
247 }
248
249 #[test]
250 fn noul_question_omits_criteria_when_none() {
251 let question = Question::Noul {
252 instructions: "Urgent?".to_string(),
253 criteria: None,
254 };
255 let value = serde_json::to_value(&question).unwrap();
256 assert_eq!(
257 value,
258 serde_json::json!({
259 "type": "noul",
260 "instructions": "Urgent?"
261 })
262 );
263 }
264
265 #[test]
266 fn noul_question_serialises_criteria_as_true_false_map() {
267 let question = Question::Noul {
268 instructions: "Urgent?".to_string(),
269 criteria: Some(NoulCriteria {
270 true_means: Some("Customer is frustrated".to_string()),
271 false_means: Some("Customer is calm".to_string()),
272 }),
273 };
274 let value = serde_json::to_value(&question).unwrap();
275 assert_eq!(
276 value,
277 serde_json::json!({
278 "type": "noul",
279 "instructions": "Urgent?",
280 "criteria": {
281 "true": "Customer is frustrated",
282 "false": "Customer is calm"
283 }
284 })
285 );
286 }
287
288 #[test]
289 fn noul_criteria_serialises_partial_sides() {
290 let criteria = NoulCriteria {
291 true_means: Some("yes".to_string()),
292 false_means: None,
293 };
294 let value = serde_json::to_value(&criteria).unwrap();
295 assert_eq!(value, serde_json::json!({"true": "yes"}));
296 }
297
298 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}}"#;
301
302 #[test]
303 fn live_response_deserialises() {
304 let response: SystemOneResponse = serde_json::from_str(LIVE_RESPONSE).unwrap();
305 assert_eq!(response.model, "jev-1.13.0");
306 assert_eq!(response.usage.input_tokens, 417);
307 assert_eq!(response.usage.output_tokens, 71);
308 assert_eq!(response.answers.len(), 3);
309
310 match &response.answers["department"] {
311 Answer::Choice {
312 choice,
313 confidence,
314 probabilities,
315 } => {
316 assert_eq!(choice, "billing");
317 assert!((confidence - 0.97).abs() < f64::EPSILON);
318 assert!((probabilities["billing"] - 0.98).abs() < f64::EPSILON);
319 }
320 other => panic!("expected Choice, got {other:?}"),
321 }
322
323 match &response.answers["frustration"] {
324 Answer::Score {
325 score,
326 confidence,
327 legend,
328 probabilities,
329 } => {
330 assert!((score - 1.01).abs() < f64::EPSILON);
331 assert!((confidence - 0.98).abs() < f64::EPSILON);
332 assert_eq!(legend["0"], "Calm, just stating facts");
333 assert!((probabilities["1"] - 0.99).abs() < f64::EPSILON);
334 }
335 other => panic!("expected Score, got {other:?}"),
336 }
337 }
338
339 #[test]
340 fn live_response_noul_answer_has_no_confidence_field() {
341 let response: SystemOneResponse = serde_json::from_str(LIVE_RESPONSE).unwrap();
342 match &response.answers["refund_requested"] {
343 Answer::Noul { noul } => assert!((noul - 0.98).abs() < f64::EPSILON),
344 other => panic!("expected Noul, got {other:?}"),
345 }
346 }
347
348 #[test]
349 fn unknown_extra_field_on_answer_is_ignored() {
350 let json = serde_json::json!({
351 "type": "noul",
352 "noul": 0.5,
353 "some_future_field": "unexpected"
354 });
355 let answer: Answer = serde_json::from_value(json).unwrap();
356 assert_eq!(answer, Answer::Noul { noul: 0.5 });
357 }
358
359 #[test]
360 fn usage_defaults_when_missing() {
361 let json = serde_json::json!({
362 "model": "jev-1.13.0",
363 "answers": {}
364 });
365 let response: SystemOneResponse = serde_json::from_value(json).unwrap();
366 assert_eq!(response.usage, Usage::default());
367 }
368
369 fn choice_with(n: usize) -> Question {
372 Question::Choice {
373 instructions: "Route this".to_string(),
374 criteria: (0..n)
375 .map(|i| (format!("o{i}"), "desc".to_string()))
376 .collect(),
377 }
378 }
379
380 fn score_with(n: usize) -> Question {
381 Question::Score {
382 instructions: "How urgent".to_string(),
383 criteria: (0..n).map(|i| format!("l{i}")).collect(),
384 }
385 }
386
387 #[test]
388 fn validate_rejects_choice_with_fewer_than_two_options() {
389 for n in [0, 1] {
390 let err = choice_with(n).validate().unwrap_err();
391 assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
392 assert!(err.to_string().contains(&format!("got {n}")));
393 }
394 choice_with(2).validate().unwrap();
395 }
396
397 #[test]
398 fn validate_rejects_score_with_fewer_than_two_levels() {
399 for n in [0, 1] {
400 let err = score_with(n).validate().unwrap_err();
401 assert!(matches!(err, JevError::InvalidQuestionSpec(_)));
402 assert!(err.to_string().contains(&format!("got {n}")));
403 }
404 score_with(2).validate().unwrap();
405 }
406
407 #[test]
408 fn validate_accepts_noul_without_criteria() {
409 Question::Noul {
410 instructions: "Urgent?".to_string(),
411 criteria: None,
412 }
413 .validate()
414 .unwrap();
415 }
416
417 #[test]
418 fn default_model_constant() {
419 assert_eq!(DEFAULT_MODEL, "jev-latest");
420 }
421}