1use std::collections::HashMap;
2use std::sync::Arc;
3
4use async_trait::async_trait;
5use serde_json::Value;
6
7use ai_agents_core::{ChatMessage, LLMProvider, Result};
8
9use super::config::{CompareOp, ContextMatcher, GuardConditions, Transition, TransitionGuard};
10
11pub struct TransitionContext {
12 pub user_message: String,
13 pub assistant_response: String,
14 pub current_state: String,
15 pub context: HashMap<String, Value>,
16}
17
18impl TransitionContext {
19 pub fn new(user_message: &str, assistant_response: &str, current_state: &str) -> Self {
20 Self {
21 user_message: user_message.to_string(),
22 assistant_response: assistant_response.to_string(),
23 current_state: current_state.to_string(),
24 context: HashMap::new(),
25 }
26 }
27
28 pub fn with_context(mut self, context: HashMap<String, Value>) -> Self {
29 self.context = context;
30 self
31 }
32}
33
34#[async_trait]
35pub trait TransitionEvaluator: Send + Sync {
36 async fn select_transition(
37 &self,
38 transitions: &[Transition],
39 context: &TransitionContext,
40 ) -> Result<Option<usize>>;
41}
42
43pub struct LLMTransitionEvaluator {
44 llm: Arc<dyn LLMProvider>,
45}
46
47impl LLMTransitionEvaluator {
48 pub fn new(llm: Arc<dyn LLMProvider>) -> Self {
49 Self { llm }
50 }
51}
52
53pub fn evaluate_guard(guard: &TransitionGuard, ctx: &TransitionContext) -> bool {
56 match guard {
57 TransitionGuard::Expression(expr) => evaluate_expression(expr, ctx),
58 TransitionGuard::Conditions(conditions) => evaluate_conditions(conditions, ctx),
59 }
60}
61
62pub fn evaluate_expression(expr: &str, ctx: &TransitionContext) -> bool {
63 let expr = expr.trim();
64
65 if !expr.contains("{{") {
66 return !expr.is_empty();
67 }
68
69 let inner = expr.trim_start_matches("{{").trim_end_matches("}}").trim();
70
71 evaluate_simple_expression(inner, ctx)
72}
73
74fn evaluate_simple_expression(expr: &str, ctx: &TransitionContext) -> bool {
75 if let Some(path) = expr.strip_prefix("context.") {
76 return get_context_value(path, &ctx.context).is_some();
77 }
78
79 if let Some(field) = expr.strip_prefix("state.") {
80 return evaluate_state_expression(field, ctx);
81 }
82
83 if let Some(idx) = expr.find('>') {
84 let (left, right) = expr.split_at(idx);
85 let op = if right.starts_with(">=") { ">=" } else { ">" };
86 let right = right.trim_start_matches(op).trim();
87 let left = left.trim();
88
89 if let (Some(left_val), Ok(right_val)) = (resolve_value(left, ctx), right.parse::<f64>())
90 && let Some(left_num) = left_val.as_f64()
91 {
92 return if op == ">=" {
93 left_num >= right_val
94 } else {
95 left_num > right_val
96 };
97 }
98 }
99
100 if let Some(idx) = expr.find('<') {
101 let (left, right) = expr.split_at(idx);
102 let op = if right.starts_with("<=") { "<=" } else { "<" };
103 let right = right.trim_start_matches(op).trim();
104 let left = left.trim();
105
106 if let (Some(left_val), Ok(right_val)) = (resolve_value(left, ctx), right.parse::<f64>())
107 && let Some(left_num) = left_val.as_f64()
108 {
109 return if op == "<=" {
110 left_num <= right_val
111 } else {
112 left_num < right_val
113 };
114 }
115 }
116
117 if let Some(idx) = expr.find("==") {
118 let (left, right) = expr.split_at(idx);
119 let right = &right[2..].trim();
120 let left = left.trim();
121
122 if let Some(left_val) = resolve_value(left, ctx) {
123 let right_val: Value = if right.starts_with('"') && right.ends_with('"') {
124 Value::String(right[1..right.len() - 1].to_string())
125 } else if *right == "true" {
126 Value::Bool(true)
127 } else if *right == "false" {
128 Value::Bool(false)
129 } else if let Ok(n) = right.parse::<f64>() {
130 serde_json::json!(n)
131 } else {
132 Value::String(right.to_string())
133 };
134
135 return left_val == right_val;
136 }
137 }
138
139 if let Some(idx) = expr.find("!=") {
140 let (left, right) = expr.split_at(idx);
141 let right = &right[2..].trim();
142 let left = left.trim();
143
144 if let Some(left_val) = resolve_value(left, ctx) {
145 let right_val: Value = if right.starts_with('"') && right.ends_with('"') {
146 Value::String(right[1..right.len() - 1].to_string())
147 } else if *right == "true" {
148 Value::Bool(true)
149 } else if *right == "false" {
150 Value::Bool(false)
151 } else if let Ok(n) = right.parse::<f64>() {
152 serde_json::json!(n)
153 } else {
154 Value::String(right.to_string())
155 };
156
157 return left_val != right_val;
158 }
159 }
160
161 false
162}
163
164fn resolve_value(expr: &str, ctx: &TransitionContext) -> Option<Value> {
165 let expr = expr.trim();
166 if let Some(path) = expr.strip_prefix("context.") {
167 return get_context_value(path, &ctx.context);
168 }
169 if let Some(field) = expr.strip_prefix("state.") {
170 return get_state_value(field, ctx);
171 }
172 None
173}
174
175fn evaluate_state_expression(field: &str, _ctx: &TransitionContext) -> bool {
176 matches!(field, "turn_count")
177}
178
179fn get_state_value(field: &str, ctx: &TransitionContext) -> Option<Value> {
180 match field {
181 "current" => Some(Value::String(ctx.current_state.clone())),
182 _ => None,
183 }
184}
185
186pub fn evaluate_conditions(conditions: &GuardConditions, ctx: &TransitionContext) -> bool {
187 match conditions {
188 GuardConditions::All(exprs) => exprs.iter().all(|e| evaluate_expression(e, ctx)),
189 GuardConditions::Any(exprs) => exprs.iter().any(|e| evaluate_expression(e, ctx)),
190 GuardConditions::Context(matchers) => evaluate_context_matchers(matchers, &ctx.context),
191 }
192}
193
194pub fn evaluate_context_matchers(
195 matchers: &HashMap<String, ContextMatcher>,
196 context: &HashMap<String, Value>,
197) -> bool {
198 for (path, matcher) in matchers {
199 let value = get_context_value(path, context);
200 if !match_value(value.as_ref(), matcher) {
201 return false;
202 }
203 }
204 true
205}
206
207pub fn get_context_value(path: &str, context: &HashMap<String, Value>) -> Option<Value> {
208 ai_agents_core::get_dot_path_from_map(context, path)
209}
210
211pub fn match_value(value: Option<&Value>, matcher: &ContextMatcher) -> bool {
212 match matcher {
213 ContextMatcher::Exact(expected) => value.map(|v| v == expected).unwrap_or(false),
214 ContextMatcher::Exists { exists } => {
215 let has_value = value.is_some() && value != Some(&Value::Null);
216 *exists == has_value
217 }
218 ContextMatcher::Compare(op) => {
219 let Some(val) = value else {
220 return false;
221 };
222 compare_value(val, op)
223 }
224 }
225}
226
227fn values_equal_coerced(value: &Value, expected: &Value) -> bool {
230 if value == expected {
231 return true;
232 }
233 if let Some(s) = value.as_str() {
235 match expected {
236 Value::Bool(b) => match s {
237 "true" => return *b,
238 "false" => return !*b,
239 _ => {}
240 },
241 Value::Number(n) => {
242 if let Ok(parsed) = s.parse::<f64>()
243 && let Some(expected_f) = n.as_f64()
244 {
245 return (parsed - expected_f).abs() < f64::EPSILON;
246 }
247 }
248 _ => {}
249 }
250 }
251 if let Some(s) = expected.as_str() {
253 match value {
254 Value::Bool(b) => match s {
255 "true" => return *b,
256 "false" => return !*b,
257 _ => {}
258 },
259 Value::Number(n) => {
260 if let Ok(parsed) = s.parse::<f64>()
261 && let Some(val_f) = n.as_f64()
262 {
263 return (parsed - val_f).abs() < f64::EPSILON;
264 }
265 }
266 _ => {}
267 }
268 }
269 false
270}
271
272pub fn compare_value(value: &Value, op: &CompareOp) -> bool {
273 match op {
274 CompareOp::Eq(expected) => values_equal_coerced(value, expected),
275 CompareOp::Neq(expected) => !values_equal_coerced(value, expected),
276 CompareOp::Gt(n) => value.as_f64().map(|v| v > *n).unwrap_or(false),
277 CompareOp::Gte(n) => value.as_f64().map(|v| v >= *n).unwrap_or(false),
278 CompareOp::Lt(n) => value.as_f64().map(|v| v < *n).unwrap_or(false),
279 CompareOp::Lte(n) => value.as_f64().map(|v| v <= *n).unwrap_or(false),
280 CompareOp::In(values) => values.contains(value),
281 CompareOp::Contains(s) => value
282 .as_str()
283 .map(|v| v.contains(s))
284 .or_else(|| {
285 value
286 .as_array()
287 .map(|arr| arr.iter().any(|v| v.as_str() == Some(s)))
288 })
289 .unwrap_or(false),
290 }
291}
292
293#[async_trait]
294impl TransitionEvaluator for LLMTransitionEvaluator {
295 async fn select_transition(
296 &self,
297 transitions: &[Transition],
298 context: &TransitionContext,
299 ) -> Result<Option<usize>> {
300 if transitions.is_empty() {
301 return Ok(None);
302 }
303
304 for (i, transition) in transitions.iter().enumerate() {
306 if let Some(ref guard) = transition.guard
307 && evaluate_guard(guard, context)
308 {
309 return Ok(Some(i));
310 }
311 }
312
313 if let Some(resolved) = context.context.get("resolved_intent")
318 && let Some(resolved_str) = resolved.as_str()
319 && !resolved_str.is_empty()
321 {
322 for (i, transition) in transitions.iter().enumerate() {
323 if let Some(ref intent) = transition.intent
324 && intent == resolved_str
325 {
326 tracing::debug!(
327 resolved_intent = resolved_str,
328 target = %transition.to,
329 "Deterministic routing via resolved_intent"
330 );
331 return Ok(Some(i));
332 }
333 }
334 }
335
336 let llm_transitions: Vec<(usize, &Transition)> = transitions
338 .iter()
339 .enumerate()
340 .filter(|(_, t)| !t.when.is_empty() && t.guard.is_none())
341 .collect();
342
343 if llm_transitions.is_empty() {
344 return Ok(None);
345 }
346
347 let conditions: Vec<String> = llm_transitions
348 .iter()
349 .enumerate()
350 .map(|(display_idx, (_, t))| format!("{}. {}", display_idx + 1, t.when))
351 .collect();
352
353 let prompt = format!(
354 r#"Based on the conversation, which condition is met?
355
356Current state: {}
357User message: {}
358Assistant response: {}
359
360Conditions:
361{}
3620. None of the above
363
364Reply with ONLY the number (0-{})."#,
365 context.current_state,
366 context.user_message,
367 context.assistant_response,
368 conditions.join("\n"),
369 llm_transitions.len()
370 );
371
372 let messages = vec![ChatMessage::user(&prompt)];
373 let response = self.llm.complete(&messages, None).await?;
374
375 let choice: usize = response.content.trim().parse().unwrap_or(0);
376
377 if choice == 0 || choice > llm_transitions.len() {
378 Ok(None)
379 } else {
380 Ok(Some(llm_transitions[choice - 1].0))
381 }
382 }
383}
384
385pub struct GuardOnlyEvaluator;
386
387impl GuardOnlyEvaluator {
388 pub fn new() -> Self {
389 Self
390 }
391
392 pub fn evaluate_guard(&self, guard: &TransitionGuard, ctx: &TransitionContext) -> bool {
393 evaluate_guard(guard, ctx)
394 }
395
396 pub fn evaluate_guards(
397 &self,
398 transitions: &[Transition],
399 ctx: &TransitionContext,
400 ) -> Option<usize> {
401 for (i, transition) in transitions.iter().enumerate() {
402 if let Some(ref guard) = transition.guard
403 && evaluate_guard(guard, ctx)
404 {
405 return Some(i);
406 }
407 }
408 None
409 }
410}
411
412impl Default for GuardOnlyEvaluator {
413 fn default() -> Self {
414 Self::new()
415 }
416}
417
418#[cfg(test)]
419mod tests {
420 use super::super::config::TransitionTiming;
421 use super::*;
422 use ai_agents_core::{FinishReason, LLMResponse};
423 use ai_agents_llm::mock::MockLLMProvider;
424
425 #[tokio::test]
426 async fn test_select_transition_none() {
427 let mut mock = MockLLMProvider::new("evaluator_test");
428 mock.add_response(LLMResponse::new("0", FinishReason::Stop));
429 let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
430
431 let transitions = vec![Transition {
432 to: "next".into(),
433 when: "user says goodbye".into(),
434 guard: None,
435 intent: None,
436 auto: true,
437 priority: 0,
438 cooldown_turns: None,
439 timing: TransitionTiming::PostResponse,
440 requires_response: false,
441 run_extractors: false,
442 }];
443
444 let context = TransitionContext::new("hello", "hi there", "greeting");
445
446 let result = evaluator.select_transition(&transitions, &context).await;
447 assert!(result.is_ok());
448 assert!(result.unwrap().is_none());
449 }
450
451 #[tokio::test]
452 async fn test_select_transition_match() {
453 let mut mock = MockLLMProvider::new("evaluator_test");
454 mock.add_response(LLMResponse::new("1", FinishReason::Stop));
455 let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
456
457 let transitions = vec![
458 Transition {
459 to: "support".into(),
460 when: "user needs help".into(),
461 guard: None,
462 intent: None,
463 auto: true,
464 priority: 10,
465 cooldown_turns: None,
466 timing: TransitionTiming::PostResponse,
467 requires_response: false,
468 run_extractors: false,
469 },
470 Transition {
471 to: "sales".into(),
472 when: "user wants to buy".into(),
473 guard: None,
474 intent: None,
475 auto: true,
476 priority: 5,
477 cooldown_turns: None,
478 timing: TransitionTiming::PostResponse,
479 requires_response: false,
480 run_extractors: false,
481 },
482 ];
483
484 let context = TransitionContext::new("I need help", "Sure!", "greeting");
485
486 let result = evaluator.select_transition(&transitions, &context).await;
487 assert!(result.is_ok());
488 assert_eq!(result.unwrap(), Some(0));
489 }
490
491 #[tokio::test]
492 async fn test_empty_transitions() {
493 let mock = MockLLMProvider::new("evaluator_test");
494 let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
495
496 let context = TransitionContext::new("hi", "hello", "start");
497
498 let result = evaluator.select_transition(&[], &context).await;
499 assert!(result.is_ok());
500 assert!(result.unwrap().is_none());
501 }
502
503 #[test]
504 fn test_guard_expression_simple() {
505 let mut context_map = HashMap::new();
506 context_map.insert("has_data".to_string(), Value::Bool(true));
507
508 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
509
510 let guard = TransitionGuard::Expression("{{ context.has_data }}".into());
511 assert!(evaluate_guard(&guard, &ctx));
512 }
513
514 #[test]
515 fn test_guard_expression_missing() {
516 let ctx = TransitionContext::new("hi", "hello", "start").with_context(HashMap::new());
517
518 let guard = TransitionGuard::Expression("{{ context.has_data }}".into());
519 assert!(!evaluate_guard(&guard, &ctx));
520 }
521
522 #[test]
523 fn test_guard_with_nested_context() {
524 let mut context_map = HashMap::new();
525 context_map.insert(
526 "user".to_string(),
527 serde_json::json!({
528 "name": "Alice",
529 "verified": true
530 }),
531 );
532
533 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
534
535 let guard = TransitionGuard::Expression("{{ context.user.verified }}".into());
536 assert!(evaluate_guard(&guard, &ctx));
537 }
538
539 #[test]
540 fn test_guard_conditions_all() {
541 let mut context_map = HashMap::new();
542 context_map.insert("has_name".to_string(), Value::Bool(true));
543 context_map.insert("has_email".to_string(), Value::Bool(true));
544
545 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
546
547 let guard = TransitionGuard::Conditions(GuardConditions::All(vec![
548 "{{ context.has_name }}".into(),
549 "{{ context.has_email }}".into(),
550 ]));
551 assert!(evaluate_guard(&guard, &ctx));
552 }
553
554 #[test]
555 fn test_guard_conditions_any() {
556 let mut context_map = HashMap::new();
557 context_map.insert("is_vip".to_string(), Value::Bool(true));
558
559 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
560
561 let guard = TransitionGuard::Conditions(GuardConditions::Any(vec![
562 "{{ context.is_admin }}".into(),
563 "{{ context.is_vip }}".into(),
564 ]));
565 assert!(evaluate_guard(&guard, &ctx));
566 }
567
568 #[test]
569 fn test_guard_context_matchers() {
570 let mut context_map = HashMap::new();
571 context_map.insert(
572 "user".to_string(),
573 serde_json::json!({
574 "tier": "premium",
575 "balance": 100.0
576 }),
577 );
578
579 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
580
581 let mut matchers = HashMap::new();
582 matchers.insert(
583 "user.tier".to_string(),
584 ContextMatcher::Exact(Value::String("premium".into())),
585 );
586 matchers.insert(
587 "user.balance".to_string(),
588 ContextMatcher::Compare(CompareOp::Gte(50.0)),
589 );
590
591 let guard = TransitionGuard::Conditions(GuardConditions::Context(matchers));
592 assert!(evaluate_guard(&guard, &ctx));
593 }
594
595 #[tokio::test]
596 async fn test_guard_priority_over_llm() {
597 let mock = MockLLMProvider::new("guard_test");
598 let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
599
600 let mut context_map = HashMap::new();
601 context_map.insert("ready".to_string(), Value::Bool(true));
602
603 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
604
605 let transitions = vec![
606 Transition {
607 to: "llm_based".into(),
608 when: "user wants to proceed".into(),
609 guard: None,
610 intent: None,
611 auto: true,
612 priority: 10,
613 cooldown_turns: None,
614 timing: TransitionTiming::PostResponse,
615 requires_response: false,
616 run_extractors: false,
617 },
618 Transition {
619 to: "guard_based".into(),
620 when: String::new(),
621 guard: Some(TransitionGuard::Expression("{{ context.ready }}".into())),
622 intent: None,
623 auto: true,
624 priority: 5,
625 cooldown_turns: None,
626 timing: TransitionTiming::PostResponse,
627 requires_response: false,
628 run_extractors: false,
629 },
630 ];
631
632 let result = evaluator.select_transition(&transitions, &ctx).await;
633 assert_eq!(result.unwrap(), Some(1));
634 }
635
636 #[test]
637 fn test_guard_only_evaluator() {
638 let evaluator = GuardOnlyEvaluator::new();
639
640 let mut context_map = HashMap::new();
641 context_map.insert("ready".to_string(), Value::Bool(true));
642
643 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
644
645 let transitions = vec![
646 Transition {
647 to: "no_guard".into(),
648 when: "some condition".into(),
649 guard: None,
650 intent: None,
651 auto: true,
652 priority: 10,
653 cooldown_turns: None,
654 timing: TransitionTiming::PostResponse,
655 requires_response: false,
656 run_extractors: false,
657 },
658 Transition {
659 to: "with_guard".into(),
660 when: String::new(),
661 guard: Some(TransitionGuard::Expression("{{ context.ready }}".into())),
662 intent: None,
663 auto: true,
664 priority: 5,
665 cooldown_turns: None,
666 timing: TransitionTiming::PostResponse,
667 requires_response: false,
668 run_extractors: false,
669 },
670 ];
671
672 let result = evaluator.evaluate_guards(&transitions, &ctx);
673 assert_eq!(result, Some(1));
674 }
675
676 #[test]
677 fn test_context_matcher_exists() {
678 let mut context_map = HashMap::new();
679 context_map.insert("name".to_string(), Value::String("Alice".into()));
680
681 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
682
683 let mut matchers = HashMap::new();
684 matchers.insert("name".to_string(), ContextMatcher::Exists { exists: true });
685 matchers.insert(
686 "email".to_string(),
687 ContextMatcher::Exists { exists: false },
688 );
689
690 let guard = TransitionGuard::Conditions(GuardConditions::Context(matchers));
691 assert!(evaluate_guard(&guard, &ctx));
692 }
693
694 #[test]
695 fn test_compare_contains() {
696 let mut context_map = HashMap::new();
697 context_map.insert("message".to_string(), Value::String("hello world".into()));
698 context_map.insert("tags".to_string(), serde_json::json!(["urgent", "support"]));
699
700 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
701
702 let mut matchers1 = HashMap::new();
703 matchers1.insert(
704 "message".to_string(),
705 ContextMatcher::Compare(CompareOp::Contains("world".into())),
706 );
707 let guard1 = TransitionGuard::Conditions(GuardConditions::Context(matchers1));
708 assert!(evaluate_guard(&guard1, &ctx));
709
710 let mut matchers2 = HashMap::new();
711 matchers2.insert(
712 "tags".to_string(),
713 ContextMatcher::Compare(CompareOp::Contains("urgent".into())),
714 );
715 let guard2 = TransitionGuard::Conditions(GuardConditions::Context(matchers2));
716 assert!(evaluate_guard(&guard2, &ctx));
717 }
718
719 #[test]
720 fn test_compare_in() {
721 let mut context_map = HashMap::new();
722 context_map.insert("tier".to_string(), Value::String("premium".into()));
723
724 let ctx = TransitionContext::new("hi", "hello", "start").with_context(context_map);
725
726 let mut matchers = HashMap::new();
727 matchers.insert(
728 "tier".to_string(),
729 ContextMatcher::Compare(CompareOp::In(vec![
730 Value::String("premium".into()),
731 Value::String("enterprise".into()),
732 ])),
733 );
734
735 let guard = TransitionGuard::Conditions(GuardConditions::Context(matchers));
736 assert!(evaluate_guard(&guard, &ctx));
737 }
738
739 #[tokio::test]
741 async fn test_intent_based_routing_deterministic() {
742 let mock = MockLLMProvider::new("intent_test");
744 let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
745
746 let transitions = vec![
747 Transition {
748 to: "cancel_order".into(),
749 when: "User wants to cancel an order".into(),
750 guard: None,
751 intent: Some("cancel_order".into()),
752 auto: true,
753 priority: 10,
754 cooldown_turns: None,
755 timing: TransitionTiming::PostResponse,
756 requires_response: false,
757 run_extractors: false,
758 },
759 Transition {
760 to: "cancel_reservation".into(),
761 when: "User wants to cancel a reservation".into(),
762 guard: None,
763 intent: Some("cancel_reservation".into()),
764 auto: true,
765 priority: 10,
766 cooldown_turns: None,
767 timing: TransitionTiming::PostResponse,
768 requires_response: false,
769 run_extractors: false,
770 },
771 Transition {
772 to: "cancel_subscription".into(),
773 when: "User wants to cancel a subscription".into(),
774 guard: None,
775 intent: Some("cancel_subscription".into()),
776 auto: true,
777 priority: 10,
778 cooldown_turns: None,
779 timing: TransitionTiming::PostResponse,
780 requires_response: false,
781 run_extractors: false,
782 },
783 ];
784
785 let mut context_map = HashMap::new();
787 context_map.insert(
788 "resolved_intent".to_string(),
789 Value::String("cancel_reservation".into()),
790 );
791
792 let ctx =
793 TransitionContext::new("あれキャンセルして", "", "greeting").with_context(context_map);
794
795 let result = evaluator
796 .select_transition(&transitions, &ctx)
797 .await
798 .unwrap();
799 assert_eq!(result, Some(1));
801 }
802
803 #[tokio::test]
805 async fn test_intent_routing_falls_back_to_llm_when_no_resolved_intent() {
806 let mut mock = MockLLMProvider::new("intent_fallback_test");
807 mock.add_response(LLMResponse::new("1", FinishReason::Stop));
809 let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
810
811 let transitions = vec![
812 Transition {
813 to: "cancel_order".into(),
814 when: "User wants to cancel an order".into(),
815 guard: None,
816 intent: Some("cancel_order".into()),
817 auto: true,
818 priority: 10,
819 cooldown_turns: None,
820 timing: TransitionTiming::PostResponse,
821 requires_response: false,
822 run_extractors: false,
823 },
824 Transition {
825 to: "cancel_reservation".into(),
826 when: "User wants to cancel a reservation".into(),
827 guard: None,
828 intent: Some("cancel_reservation".into()),
829 auto: true,
830 priority: 10,
831 cooldown_turns: None,
832 timing: TransitionTiming::PostResponse,
833 requires_response: false,
834 run_extractors: false,
835 },
836 ];
837
838 let ctx = TransitionContext::new("Cancel order ORD-1042", "", "greeting")
840 .with_context(HashMap::new());
841
842 let result = evaluator
843 .select_transition(&transitions, &ctx)
844 .await
845 .unwrap();
846 assert_eq!(result, Some(0));
848 }
849
850 #[tokio::test]
852 async fn test_no_routing_when_resolved_intent_doesnt_match() {
853 let mut mock = MockLLMProvider::new("intent_nomatch_test");
854 mock.add_response(LLMResponse::new("0", FinishReason::Stop));
856 let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
857
858 let transitions = vec![
859 Transition {
860 to: "cancel_order".into(),
861 when: "User wants to cancel an order".into(),
862 guard: None,
863 intent: Some("cancel_order".into()),
864 auto: true,
865 priority: 10,
866 cooldown_turns: None,
867 timing: TransitionTiming::PostResponse,
868 requires_response: false,
869 run_extractors: false,
870 },
871 Transition {
872 to: "cancel_reservation".into(),
873 when: "User wants to cancel a reservation".into(),
874 guard: None,
875 intent: Some("cancel_reservation".into()),
876 auto: true,
877 priority: 10,
878 cooldown_turns: None,
879 timing: TransitionTiming::PostResponse,
880 requires_response: false,
881 run_extractors: false,
882 },
883 ];
884
885 let mut context_map = HashMap::new();
887 context_map.insert(
888 "resolved_intent".to_string(),
889 Value::String("something_else".into()),
890 );
891
892 let ctx = TransitionContext::new("do something", "", "greeting").with_context(context_map);
893
894 let result = evaluator
895 .select_transition(&transitions, &ctx)
896 .await
897 .unwrap();
898 assert_eq!(result, None);
900 }
901
902 #[tokio::test]
904 async fn test_null_resolved_intent_is_ignored() {
905 let mut mock = MockLLMProvider::new("intent_null_test");
906 mock.add_response(LLMResponse::new("1", FinishReason::Stop));
907 let evaluator = LLMTransitionEvaluator::new(Arc::new(mock));
908
909 let transitions = vec![Transition {
910 to: "cancel_order".into(),
911 when: "User wants to cancel an order".into(),
912 guard: None,
913 intent: Some("cancel_order".into()),
914 auto: true,
915 priority: 10,
916 cooldown_turns: None,
917 timing: TransitionTiming::PostResponse,
918 requires_response: false,
919 run_extractors: false,
920 }];
921
922 let mut context_map = HashMap::new();
924 context_map.insert("resolved_intent".to_string(), Value::Null);
925
926 let ctx =
927 TransitionContext::new("Cancel my order", "", "greeting").with_context(context_map);
928
929 let result = evaluator
930 .select_transition(&transitions, &ctx)
931 .await
932 .unwrap();
933 assert_eq!(result, Some(0));
935 }
936}