1#[cfg(feature = "ai-gateway")]
43mod gateway;
44mod service;
45
46use std::collections::BTreeMap;
47use std::sync::Arc;
48use std::time::Duration;
49
50use async_trait::async_trait;
51use serde::{Deserialize, Serialize};
52use serde_json::{Value, json};
53
54use crate::llm::LlmError;
55
56#[cfg(feature = "ai-gateway")]
57pub use gateway::GatewayDecisionModel;
58pub use service::{
59 Conversation, DEFAULT_BOOLEAN_THRESHOLD, DEFAULT_CERTAINTY_THRESHOLD, DEFAULT_DECISION_TIMEOUT,
60 DEFAULT_HISTORY_TURNS, Decision, Decisions, NONE_OPTION, Round,
61};
62
63pub const MAX_CHOICE_OPTIONS: usize = 255;
65pub const SCORE_LEVELS: std::ops::RangeInclusive<usize> = 2..=10;
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct BooleanCriteria {
71 #[serde(rename = "true")]
73 pub when_true: String,
74 #[serde(rename = "false")]
76 pub when_false: String,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
81#[serde(tag = "type", rename_all = "lowercase")]
82pub enum Question {
83 Boolean {
85 instructions: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 criteria: Option<BooleanCriteria>,
90 },
91 Choice {
93 instructions: String,
95 criteria: BTreeMap<String, String>,
97 },
98 Score {
100 instructions: String,
102 criteria: Vec<String>,
104 },
105}
106
107impl Question {
108 pub fn boolean(instructions: impl Into<String>) -> Self {
110 Self::Boolean {
111 instructions: instructions.into(),
112 criteria: None,
113 }
114 }
115
116 pub fn choice<K: Into<String>, D: Into<String>>(
118 instructions: impl Into<String>,
119 options: impl IntoIterator<Item = (K, D)>,
120 ) -> Self {
121 Self::Choice {
122 instructions: instructions.into(),
123 criteria: options
124 .into_iter()
125 .map(|(k, d)| (k.into(), d.into()))
126 .collect(),
127 }
128 }
129
130 pub fn score<L: Into<String>>(
132 instructions: impl Into<String>,
133 levels: impl IntoIterator<Item = L>,
134 ) -> Self {
135 Self::Score {
136 instructions: instructions.into(),
137 criteria: levels.into_iter().map(Into::into).collect(),
138 }
139 }
140
141 #[must_use]
144 pub fn when(mut self, when_true: impl Into<String>, when_false: impl Into<String>) -> Self {
145 if let Self::Boolean { criteria, .. } = &mut self {
146 *criteria = Some(BooleanCriteria {
147 when_true: when_true.into(),
148 when_false: when_false.into(),
149 });
150 }
151 self
152 }
153
154 pub fn instructions(&self) -> &str {
156 match self {
157 Self::Boolean { instructions, .. }
158 | Self::Choice { instructions, .. }
159 | Self::Score { instructions, .. } => instructions,
160 }
161 }
162
163 pub fn validate(&self) -> Result<(), String> {
166 if self.instructions().trim().is_empty() {
167 return Err("a question needs instructions".into());
168 }
169 match self {
170 Self::Boolean { .. } => Ok(()),
171 Self::Choice { criteria, .. } if criteria.is_empty() => {
172 Err("a choice question needs at least one option".into())
173 }
174 Self::Choice { criteria, .. } if criteria.len() > MAX_CHOICE_OPTIONS => Err(format!(
175 "a choice question takes at most {MAX_CHOICE_OPTIONS} options, not {}",
176 criteria.len()
177 )),
178 Self::Choice { .. } => Ok(()),
179 Self::Score { criteria, .. } if !SCORE_LEVELS.contains(&criteria.len()) => {
180 Err(format!(
181 "a score question takes {} to {} levels, not {}",
182 SCORE_LEVELS.start(),
183 SCORE_LEVELS.end(),
184 criteria.len()
185 ))
186 }
187 Self::Score { .. } => Ok(()),
188 }
189 }
190}
191
192#[derive(Debug, Clone, PartialEq)]
197pub enum Answer {
198 Boolean {
201 probability: f64,
203 },
204 Choice {
206 choice: String,
208 probabilities: BTreeMap<String, f64>,
210 confidence: Option<f64>,
213 },
214 Score {
216 score: f64,
218 probabilities: BTreeMap<String, f64>,
220 confidence: Option<f64>,
222 },
223 Refusal(Value),
225 Other(Value),
227}
228
229impl Answer {
230 pub fn boolean(probability: f64) -> Self {
232 Self::Boolean { probability }
233 }
234
235 pub fn choice(choice: impl Into<String>, confidence: f64) -> Self {
237 Self::Choice {
238 choice: choice.into(),
239 probabilities: BTreeMap::new(),
240 confidence: Some(confidence),
241 }
242 }
243
244 pub fn score(score: f64, confidence: f64) -> Self {
246 Self::Score {
247 score,
248 probabilities: BTreeMap::new(),
249 confidence: Some(confidence),
250 }
251 }
252
253 pub fn probability(&self) -> Option<f64> {
255 match self {
256 Self::Boolean { probability } => Some(*probability),
257 _ => None,
258 }
259 }
260
261 pub fn certainty(&self) -> Option<f64> {
265 match self {
266 Self::Choice {
267 choice,
268 probabilities,
269 confidence,
270 } => confidence.or_else(|| probabilities.get(choice).copied()),
271 Self::Score {
275 score,
276 probabilities,
277 confidence,
278 } if !probabilities.is_empty() => {
279 let level = |i: f64| {
280 probabilities
281 .get(&format!("{}", i as u64))
282 .copied()
283 .unwrap_or(0.0)
284 };
285 let (lo, hi) = (score.floor(), score.ceil());
286 let mass = if lo == hi {
287 level(lo)
288 } else {
289 level(lo) + level(hi)
290 };
291 Some(confidence.map_or(mass, |c| c.max(mass)))
292 }
293 Self::Score { confidence, .. } => *confidence,
294 _ => None,
295 }
296 }
297
298 pub fn from_value(value: Value) -> Self {
300 let number = |v: &Value, key: &str| v.get(key).and_then(Value::as_f64);
301 let distribution = |v: &Value| -> BTreeMap<String, f64> {
302 v.get("probabilities")
303 .and_then(Value::as_object)
304 .map(|m| {
305 m.iter()
306 .filter_map(|(k, p)| p.as_f64().map(|p| (k.clone(), p)))
307 .collect()
308 })
309 .unwrap_or_default()
310 };
311 match value.get("type").and_then(Value::as_str) {
312 Some("boolean") => match number(&value, "probability") {
313 Some(probability) => Self::Boolean { probability },
314 None => Self::Other(value),
315 },
316 Some("choice") => match value.get("choice").and_then(Value::as_str) {
317 Some(choice) => Self::Choice {
318 choice: choice.to_string(),
319 probabilities: distribution(&value),
320 confidence: number(&value, "confidence"),
321 },
322 None => Self::Other(value),
323 },
324 Some("score") => match number(&value, "score") {
325 Some(score) => Self::Score {
326 score,
327 probabilities: distribution(&value),
328 confidence: number(&value, "confidence"),
329 },
330 None => Self::Other(value),
331 },
332 Some("refusal") => Self::Refusal(value),
333 _ => Self::Other(value),
334 }
335 }
336
337 pub fn to_value(&self) -> Value {
339 match self {
340 Self::Boolean { probability } => {
341 json!({ "type": "boolean", "probability": probability })
342 }
343 Self::Choice {
344 choice,
345 probabilities,
346 confidence,
347 } => {
348 let mut v =
349 json!({ "type": "choice", "choice": choice, "probabilities": probabilities });
350 if let Some(c) = confidence {
351 v["confidence"] = json!(c);
352 }
353 v
354 }
355 Self::Score {
356 score,
357 probabilities,
358 confidence,
359 } => {
360 let mut v =
361 json!({ "type": "score", "score": score, "probabilities": probabilities });
362 if let Some(c) = confidence {
363 v["confidence"] = json!(c);
364 }
365 v
366 }
367 Self::Refusal(v) | Self::Other(v) => v.clone(),
368 }
369 }
370}
371
372#[derive(Debug, Clone, PartialEq)]
376pub enum FallbackWhen {
377 ConfidenceBelow {
380 question: Option<String>,
382 below: f64,
384 },
385 ProbabilityBetween {
387 question: Option<String>,
389 low: f64,
391 high: f64,
393 },
394 Any(Vec<FallbackWhen>),
396 All(Vec<FallbackWhen>),
398 AtLeast {
400 count: usize,
402 conditions: Vec<FallbackWhen>,
404 },
405}
406
407impl FallbackWhen {
408 pub fn to_value(&self) -> Value {
410 let with_question = |mut v: Value, question: &Option<String>| {
411 if let Some(q) = question {
412 v["question"] = json!(q);
413 }
414 v
415 };
416 match self {
417 Self::ConfidenceBelow { question, below } => {
418 with_question(json!({ "confidenceBelow": below }), question)
419 }
420 Self::ProbabilityBetween {
421 question,
422 low,
423 high,
424 } => with_question(json!({ "probabilityBetween": [low, high] }), question),
425 Self::Any(c) => json!({ "any": c.iter().map(Self::to_value).collect::<Vec<_>>() }),
426 Self::All(c) => json!({ "all": c.iter().map(Self::to_value).collect::<Vec<_>>() }),
427 Self::AtLeast { count, conditions } => json!({
428 "atLeast": {
429 "count": count,
430 "conditions": conditions.iter().map(Self::to_value).collect::<Vec<_>>(),
431 }
432 }),
433 }
434 }
435}
436
437#[derive(Debug, Clone, PartialEq)]
439pub struct DecisionRequest {
440 pub state: Value,
442 pub questions: BTreeMap<String, Question>,
444 pub provider_options: Option<Value>,
447}
448
449impl DecisionRequest {
450 pub fn new<K: Into<String>>(
452 state: Value,
453 questions: impl IntoIterator<Item = (K, Question)>,
454 ) -> Self {
455 Self {
456 state,
457 questions: questions.into_iter().map(|(k, q)| (k.into(), q)).collect(),
458 provider_options: None,
459 }
460 }
461}
462
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
465pub struct DecisionUsage {
466 pub input_tokens: u64,
468 pub output_tokens: u64,
470}
471
472#[derive(Debug, Clone, PartialEq)]
474pub struct DecisionResponse {
475 pub model: String,
477 pub answers: BTreeMap<String, Answer>,
479 pub usage: Option<DecisionUsage>,
481 pub cost: Option<String>,
483 pub fallback_triggered_by: Vec<(String, String)>,
487 pub latency: Duration,
489 pub provider_metadata: Value,
491}
492
493impl DecisionResponse {
494 pub fn with_answers(model: impl Into<String>, answers: BTreeMap<String, Answer>) -> Self {
496 Self {
497 model: model.into(),
498 answers,
499 usage: None,
500 cost: None,
501 fallback_triggered_by: Vec::new(),
502 latency: Duration::ZERO,
503 provider_metadata: Value::Null,
504 }
505 }
506}
507
508#[async_trait]
510pub trait DecisionModel: Send + Sync {
511 fn model_id(&self) -> &str;
513
514 async fn decide(&self, request: DecisionRequest) -> Result<DecisionResponse, LlmError>;
516}
517
518type MockAnswerFn =
519 dyn Fn(&DecisionRequest) -> Result<BTreeMap<String, Answer>, LlmError> + Send + Sync;
520
521pub struct MockDecisionModel {
524 answer: Arc<MockAnswerFn>,
525 requests: parking_lot::Mutex<Vec<DecisionRequest>>,
526 latency: Duration,
527}
528
529impl MockDecisionModel {
530 pub fn new(
532 answer: impl Fn(&DecisionRequest) -> Result<BTreeMap<String, Answer>, LlmError>
533 + Send
534 + Sync
535 + 'static,
536 ) -> Self {
537 Self {
538 answer: Arc::new(answer),
539 requests: parking_lot::Mutex::new(Vec::new()),
540 latency: Duration::ZERO,
541 }
542 }
543
544 #[must_use]
546 pub fn with_latency(mut self, latency: Duration) -> Self {
547 self.latency = latency;
548 self
549 }
550
551 pub fn requests(&self) -> Vec<DecisionRequest> {
553 self.requests.lock().clone()
554 }
555}
556
557#[async_trait]
558impl DecisionModel for MockDecisionModel {
559 fn model_id(&self) -> &str {
560 "mock-decision"
561 }
562
563 async fn decide(&self, request: DecisionRequest) -> Result<DecisionResponse, LlmError> {
564 self.requests.lock().push(request.clone());
565 if !self.latency.is_zero() {
566 tokio::time::sleep(self.latency).await;
567 }
568 let answers = (self.answer)(&request)?;
569 let mut response = DecisionResponse::with_answers("mock-decision", answers);
570 response.latency = self.latency;
571 Ok(response)
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578
579 #[test]
580 fn questions_serialize_as_the_decision_api_takes_them() {
581 let q = Question::boolean("Did the caller agree?").when("said yes", "anything else");
582 assert_eq!(
583 serde_json::to_value(&q).unwrap(),
584 json!({
585 "type": "boolean",
586 "instructions": "Did the caller agree?",
587 "criteria": { "true": "said yes", "false": "anything else" }
588 })
589 );
590 let q = Question::choice(
591 "Route it.",
592 [("billing", "charges"), ("shipping", "delivery")],
593 );
594 assert_eq!(
595 serde_json::to_value(&q).unwrap(),
596 json!({
597 "type": "choice",
598 "instructions": "Route it.",
599 "criteria": { "billing": "charges", "shipping": "delivery" }
600 })
601 );
602 let q = Question::score("How urgent?", ["low", "medium", "high"]);
603 assert_eq!(
604 serde_json::to_value(&q).unwrap(),
605 json!({ "type": "score", "instructions": "How urgent?", "criteria": ["low", "medium", "high"] })
606 );
607 }
608
609 #[test]
610 fn question_limits_are_checked() {
611 assert!(Question::boolean("").validate().is_err());
612 let none: [(&str, &str); 0] = [];
613 assert!(Question::choice("Pick.", none).validate().is_err());
614 let many = (0..256).map(|i| (format!("o{i}"), "x".to_string()));
615 assert!(Question::choice("Pick.", many).validate().is_err());
616 assert!(Question::score("Rate.", ["only one"]).validate().is_err());
617 assert!(Question::score("Rate.", ["a", "b"]).validate().is_ok());
618 }
619
620 #[test]
621 fn answers_are_read_from_the_decision_api() {
622 let a = Answer::from_value(json!({ "type": "boolean", "probability": 0.98 }));
623 assert_eq!(a.probability(), Some(0.98));
624
625 let a = Answer::from_value(json!({
626 "type": "choice", "choice": "billing",
627 "probabilities": { "billing": 0.9, "shipping": 0.1 }
628 }));
629 assert_eq!(
630 a.certainty(),
631 Some(0.9),
632 "no confidence: the chosen option's probability"
633 );
634 let a = Answer::from_value(json!({
635 "type": "choice", "choice": "billing", "confidence": 0.4,
636 "probabilities": { "billing": 0.9, "shipping": 0.1 }
637 }));
638 assert_eq!(a.certainty(), Some(0.4), "the reported confidence wins");
639
640 let a = Answer::from_value(json!({
641 "type": "score", "score": 2.97,
642 "probabilities": { "0": 0, "1": 0, "2": 0.02, "3": 0.98 }
643 }));
644 assert!(matches!(a, Answer::Score { score, .. } if (score - 2.97).abs() < 1e-9));
645 assert_eq!(
646 a.certainty(),
647 Some(1.0),
648 "the two levels the score lies between hold all the probability"
649 );
650
651 assert!(matches!(
652 Answer::from_value(json!({ "type": "refusal" })),
653 Answer::Refusal(_)
654 ));
655 assert!(matches!(
656 Answer::from_value(json!({ "type": "boolean" })),
657 Answer::Other(_)
658 ));
659 let round = json!({ "type": "choice", "choice": "a", "probabilities": { "a": 1.0 }, "confidence": 1.0 });
660 assert_eq!(Answer::from_value(round.clone()).to_value(), round);
661 }
662
663 #[test]
664 fn fallback_conditions_take_the_gateway_shape() {
665 let when = FallbackWhen::Any(vec![
666 FallbackWhen::ConfidenceBelow {
667 question: Some("route".into()),
668 below: 0.6,
669 },
670 FallbackWhen::ProbabilityBetween {
671 question: None,
672 low: 0.3,
673 high: 0.8,
674 },
675 FallbackWhen::AtLeast {
676 count: 1,
677 conditions: vec![FallbackWhen::All(vec![FallbackWhen::ConfidenceBelow {
678 question: None,
679 below: 0.5,
680 }])],
681 },
682 ]);
683 assert_eq!(
684 when.to_value(),
685 json!({ "any": [
686 { "question": "route", "confidenceBelow": 0.6 },
687 { "probabilityBetween": [0.3, 0.8] },
688 { "atLeast": { "count": 1, "conditions": [ { "all": [ { "confidenceBelow": 0.5 } ] } ] } }
689 ] })
690 );
691 }
692
693 #[tokio::test]
694 async fn the_mock_records_requests() {
695 let model = MockDecisionModel::new(|req| {
696 Ok(req
697 .questions
698 .keys()
699 .map(|k| (k.clone(), Answer::boolean(0.5)))
700 .collect())
701 });
702 let response = model
703 .decide(DecisionRequest::new(
704 json!("x"),
705 [("a", Question::boolean("A?"))],
706 ))
707 .await
708 .unwrap();
709 assert_eq!(response.answers.len(), 1);
710 assert_eq!(model.requests().len(), 1);
711 }
712}