1use std::sync::Arc;
8
9use async_trait::async_trait;
10use serde_json::Value;
11
12use crate::llm::{BaseLlm, LlmError, LlmRequest, LlmResponse};
13use crate::state::State;
14
15use super::phase::Phase;
16use super::transcript::TranscriptTurn;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ExtractionTrigger {
25 EveryTurn,
27 Interval(u32),
29 AfterToolCall,
31 OnPhaseChange,
33 OnGenerationComplete,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum MergePolicy {
42 KeepKnown,
44 Overwrite,
46}
47
48pub type PromotionPredicate = Arc<dyn Fn(&State, &Value) -> bool + Send + Sync>;
50
51#[derive(Clone)]
53pub struct FieldPromotion {
54 pub field: String,
56 pub state_key: String,
58 pub merge: MergePolicy,
60 pub accept: Option<PromotionPredicate>,
62}
63
64impl FieldPromotion {
65 pub fn keep_known(field: impl Into<String>) -> Self {
67 let field = field.into();
68 Self {
69 state_key: field.clone(),
70 field,
71 merge: MergePolicy::KeepKnown,
72 accept: None,
73 }
74 }
75
76 pub fn overwrite(field: impl Into<String>) -> Self {
78 let field = field.into();
79 Self {
80 state_key: field.clone(),
81 field,
82 merge: MergePolicy::Overwrite,
83 accept: None,
84 }
85 }
86
87 pub fn true_only(field: impl Into<String>) -> Self {
89 Self::overwrite(field).accept_when(|_, value| value.as_bool() == Some(true))
90 }
91
92 pub fn non_empty(field: impl Into<String>) -> Self {
94 Self::overwrite(field)
95 .accept_when(|_, value| value.as_str().is_some_and(|s| !s.trim().is_empty()))
96 }
97
98 pub fn to(mut self, state_key: impl Into<String>) -> Self {
100 self.state_key = state_key.into();
101 self
102 }
103
104 pub fn accept_when(
109 mut self,
110 predicate: impl Fn(&State, &Value) -> bool + Send + Sync + 'static,
111 ) -> Self {
112 self.accept = Some(Arc::new(predicate));
113 self
114 }
115
116 pub fn and_accept_when(
118 mut self,
119 predicate: impl Fn(&State, &Value) -> bool + Send + Sync + 'static,
120 ) -> Self {
121 let previous = self.accept.take();
122 self.accept = Some(Arc::new(move |state, value| {
123 previous.as_ref().is_none_or(|accept| accept(state, value)) && predicate(state, value)
124 }));
125 self
126 }
127
128 pub fn after_presented(self, concept: impl Into<String>) -> Self {
130 let concept = concept.into();
131 self.and_accept_when(move |state, _| Phase::is_presented(state, &concept))
132 }
133}
134
135pub(crate) fn nothing_new_from_caller(window: &[TranscriptTurn]) -> bool {
139 window.last().is_some_and(|t| t.user.trim().is_empty())
140 && window.iter().any(|t| !t.user.trim().is_empty())
141}
142
143fn strip_code_fences(text: &str) -> &str {
147 let trimmed = text.trim();
148 if let Some(rest) = trimmed.strip_prefix("```") {
149 let rest = rest.trim_start_matches(|c: char| c != '\n');
151 let rest = rest.strip_prefix('\n').unwrap_or(rest);
152 let rest = rest.trim_end();
154 rest.strip_suffix("```").unwrap_or(rest).trim()
155 } else {
156 trimmed
157 }
158}
159
160#[async_trait]
166pub trait TurnExtractor: Send + Sync {
167 fn name(&self) -> &str;
169
170 fn window_size(&self) -> usize;
172
173 fn should_extract(&self, window: &[TranscriptTurn]) -> bool {
181 let _ = window;
182 true
183 }
184
185 fn trigger(&self) -> ExtractionTrigger {
189 ExtractionTrigger::EveryTurn
190 }
191
192 fn promotion_rules(&self) -> &[FieldPromotion] {
199 &[]
200 }
201
202 fn may_write(&self, key: &str) -> bool {
209 let rules = self.promotion_rules();
210 rules.is_empty() || rules.iter().any(|r| r.state_key == key)
211 }
212
213 async fn extract(&self, window: &[TranscriptTurn]) -> Result<Value, LlmError>;
215
216 async fn extract_with_state(
222 &self,
223 window: &[TranscriptTurn],
224 state: &State,
225 ) -> Result<Value, LlmError> {
226 let _ = state;
227 self.extract(window).await
228 }
229
230 fn on_complete(&self) -> Option<OnComplete> {
234 None
235 }
236}
237
238#[derive(Clone, Debug)]
240pub enum PromotionEvent {
241 Promoted {
243 name: String,
245 value: Value,
247 },
248 Decision {
250 field: String,
252 state_key: String,
254 accepted: bool,
256 reason: String,
258 value: Value,
260 },
261}
262pub fn promote_fields(
264 extractor: &dyn TurnExtractor,
265 name: &str,
266 value: &Value,
267 state: &State,
268) -> Vec<PromotionEvent> {
269 let mut events = Vec::new();
270 let Some(object) = value.as_object() else {
271 return events;
272 };
273 let rules = extractor.promotion_rules();
274 if rules.is_empty() {
275 for (field, value) in object {
276 if value.is_null() {
277 continue;
278 }
279 let _ = state.set(field, value.clone());
280 events.push(PromotionEvent::Promoted {
281 name: format!("{name}.{field}"),
282 value: value.clone(),
283 });
284 }
285 return events;
286 }
287 for rule in rules {
288 let Some(value) = object.get(&rule.field) else {
289 continue;
290 };
291 let reason = if value.is_null() {
292 Some("extracted value was null")
293 } else if state
294 .get_raw(&rule.state_key)
295 .is_some_and(|known| crate::state::equivalent_values(&known, value))
296 {
297 Some("an equivalent value is already known")
300 } else if rule
301 .accept
302 .as_ref()
303 .is_some_and(|accept| !accept(state, value))
304 {
305 Some("promotion predicate rejected the value")
306 } else if matches!(rule.merge, MergePolicy::KeepKnown) && state.contains(&rule.state_key) {
307 Some("existing state value was kept")
308 } else {
309 None
310 };
311 if reason.is_none() {
312 let _ = state.set(&rule.state_key, value.clone());
313 let _ = state.set(
314 format!("state_meta:{}", rule.state_key),
315 serde_json::json!({"source":"extraction", "extractor":name, "field":rule.field}),
316 );
317 events.push(PromotionEvent::Promoted {
318 name: format!("{name}.{}", rule.field),
319 value: value.clone(),
320 });
321 }
322 events.push(PromotionEvent::Decision {
323 field: rule.field.clone(),
324 state_key: rule.state_key.clone(),
325 accepted: reason.is_none(),
326 reason: reason.unwrap_or("promotion rule accepted the value").into(),
327 value: value.clone(),
328 });
329 }
330 events
331}
332
333#[derive(Clone)]
335pub struct OnComplete {
336 pub agent: Arc<dyn crate::text::TextAgent>,
338 pub mode: crate::orchestration::AgentMode,
340}
341
342const NULL_GUIDANCE: &str = "\n\nUse null for any field the transcript does not state. \
345 Never guess, and never use placeholders such as \"unknown\", empty strings or zero.";
346
347const KNOWN_GUIDANCE: &str = "\n\nSome fields are already known (listed before the transcript). \
356 Compare each known field only with the user's latest turn, quoted after the transcript: \
357 return it only if that turn gives a different value. Agreeing, confirming, repeating or \
358 rewording a known value is not a change: return null.";
359
360fn nullable_fields(schema: &Value) -> Value {
363 let mut schema = schema.clone();
364 let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else {
365 return schema;
366 };
367 for property in properties.values_mut() {
368 let Some(object) = property.as_object_mut() else {
369 continue;
370 };
371 match object.get_mut("type") {
372 Some(Value::String(t)) if t != "null" => {
373 let t = t.clone();
374 object.insert("type".into(), serde_json::json!([t, "null"]));
375 }
376 Some(Value::Array(types)) => {
377 if !types.iter().any(|t| t == "null") {
378 types.push(Value::String("null".into()));
379 }
380 }
381 Some(_) => {}
382 None => {
383 let inner = Value::Object(std::mem::take(object));
384 object.insert(
385 "anyOf".into(),
386 serde_json::json!([inner, { "type": "null" }]),
387 );
388 }
389 }
390 }
391 schema
392}
393
394pub struct LlmExtractor {
397 name: String,
398 llm: Arc<dyn BaseLlm>,
399 prompt: String,
400 window_size: usize,
401 schema: Option<Value>,
402 schema_str: Option<String>,
404 min_words: usize,
406 trigger: ExtractionTrigger,
408 promotion_rules: Vec<FieldPromotion>,
411 thinking_budget: Option<u32>,
414 budget_step: std::sync::atomic::AtomicU8,
418}
419
420pub const FALLBACK_THINKING_BUDGET: u32 = 64;
425
426impl LlmExtractor {
427 pub fn new(
434 name: impl Into<String>,
435 llm: Arc<dyn BaseLlm>,
436 prompt: impl Into<String>,
437 window_size: usize,
438 ) -> Self {
439 Self {
440 name: name.into(),
441 llm,
442 prompt: prompt.into(),
443 window_size,
444 schema: None,
445 schema_str: None,
446 min_words: 0,
447 trigger: ExtractionTrigger::EveryTurn,
448 promotion_rules: Vec::new(),
449 thinking_budget: Some(0),
450 budget_step: std::sync::atomic::AtomicU8::new(0),
451 }
452 }
453
454 pub fn with_thinking_budget(mut self, budget: Option<u32>) -> Self {
465 self.thinking_budget = budget;
466 self
467 }
468
469 pub fn with_min_words(mut self, n: usize) -> Self {
474 self.min_words = n;
475 self
476 }
477
478 pub fn with_schema(mut self, schema: Value) -> Self {
483 self.schema_str = serde_json::to_string_pretty(&schema).ok();
484 self.schema = Some(schema);
485 self
486 }
487
488 pub fn with_trigger(mut self, trigger: ExtractionTrigger) -> Self {
490 self.trigger = trigger;
491 self
492 }
493
494 pub fn with_promotions(mut self, rules: Vec<FieldPromotion>) -> Self {
499 self.promotion_rules = rules;
500 self
501 }
502
503 fn known_fields(&self, state: &State) -> serde_json::Map<String, Value> {
507 let pairs: Vec<(String, String)> = if self.promotion_rules.is_empty() {
508 self.schema
509 .as_ref()
510 .and_then(|s| s.get("properties"))
511 .and_then(Value::as_object)
512 .map(|p| p.keys().map(|k| (k.clone(), k.clone())).collect())
513 .unwrap_or_default()
514 } else {
515 self.promotion_rules
516 .iter()
517 .map(|r| (r.field.clone(), r.state_key.clone()))
518 .collect()
519 };
520 pairs
521 .into_iter()
522 .filter_map(|(field, key)| {
523 state
524 .get_raw(&key)
525 .filter(|v| !v.is_null())
526 .map(|v| (field, v))
527 })
528 .collect()
529 }
530
531 async fn extract_knowing(
532 &self,
533 window: &[TranscriptTurn],
534 known: &serde_json::Map<String, Value>,
535 ) -> Result<Value, LlmError> {
536 let transcript = Self::format_transcript(window);
537 let (preamble, latest, guidance) = if known.is_empty() {
538 (String::new(), String::new(), "")
539 } else {
540 let latest = window
541 .iter()
542 .rev()
543 .map(|t| t.user.trim())
544 .find(|u| !u.is_empty())
545 .unwrap_or_default();
546 (
547 format!("Already known: {}\n\n", Value::Object(known.clone())),
548 format!(
549 "The user's latest turn: {}\n\n",
550 Value::String(latest.to_string())
551 ),
552 KNOWN_GUIDANCE,
553 )
554 };
555 let mut request = LlmRequest::from_text(format!(
556 "{preamble}Transcript:\n{transcript}\n{latest}Extract the requested information."
557 ));
558 request.system_instruction = Some(format!("{}{NULL_GUIDANCE}{guidance}", self.prompt));
559
560 if let Some(ref schema) = self.schema {
566 request.response_mime_type = Some("application/json".to_string());
567 request.response_json_schema = Some(nullable_fields(schema));
568 } else {
569 request.response_mime_type = Some("application/json".to_string());
570 }
571
572 let response = self.generate(request).await?;
573 let text = response.text();
574
575 let cleaned = strip_code_fences(&text);
577
578 serde_json::from_str(cleaned).map_err(|e| {
579 LlmError::Other(format!(
580 "Failed to parse extraction result as JSON: {e}. Raw: {text}"
581 ))
582 })
583 }
584
585 async fn generate(&self, mut request: LlmRequest) -> Result<LlmResponse, LlmError> {
589 use std::sync::atomic::Ordering;
590 loop {
591 let step = self.budget_step.load(Ordering::Relaxed);
592 let budget = match step {
593 0 => self.thinking_budget,
594 1 => Some(FALLBACK_THINKING_BUDGET),
595 _ => None,
596 };
597 request.thinking_budget = budget;
598 match self.llm.generate(request.clone()).await {
599 Err(LlmError::Api { status: 400, .. }) if budget.is_some() => {
600 let next = if step == 0 && budget != Some(FALLBACK_THINKING_BUDGET) {
601 1
602 } else {
603 2
604 };
605 tracing::warn!(
606 extractor = %self.name,
607 "the extraction model rejected thinking budget {budget:?}; \
608 trying {:?}",
609 if next == 1 { Some(FALLBACK_THINKING_BUDGET) } else { None }
610 );
611 self.budget_step.fetch_max(next, Ordering::Relaxed);
612 }
613 Err(e) if e.is_retryable() => {
614 tracing::warn!(extractor = %self.name, "extraction failed ({e}); retrying once");
615 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
616 return self.llm.generate(request).await;
617 }
618 other => return other,
619 }
620 }
621 }
622
623 fn format_transcript(window: &[TranscriptTurn]) -> String {
625 let mut out = String::new();
626 for turn in window {
627 if !turn.user.is_empty() {
628 out.push_str("User: ");
629 out.push_str(turn.user.trim());
630 out.push('\n');
631 }
632 if !turn.model.is_empty() {
633 out.push_str("Assistant: ");
634 out.push_str(turn.model.trim());
635 out.push('\n');
636 }
637 out.push('\n');
638 }
639 out
640 }
641}
642
643#[async_trait]
644impl TurnExtractor for LlmExtractor {
645 fn name(&self) -> &str {
646 &self.name
647 }
648
649 fn window_size(&self) -> usize {
650 self.window_size
651 }
652
653 fn should_extract(&self, window: &[TranscriptTurn]) -> bool {
654 if self.trigger == ExtractionTrigger::EveryTurn && nothing_new_from_caller(window) {
661 return false;
662 }
663 if self.min_words == 0 {
664 return true;
665 }
666 window
668 .iter()
669 .rev()
670 .find(|t| !t.user.is_empty())
671 .is_some_and(|t| t.user.split_whitespace().count() >= self.min_words)
672 }
673
674 fn trigger(&self) -> ExtractionTrigger {
675 self.trigger.clone()
676 }
677
678 fn promotion_rules(&self) -> &[FieldPromotion] {
679 &self.promotion_rules
680 }
681
682 fn may_write(&self, key: &str) -> bool {
683 if self.promotion_rules.is_empty()
686 && let Some(properties) = self
687 .schema
688 .as_ref()
689 .and_then(|s| s.get("properties"))
690 .and_then(Value::as_object)
691 {
692 return properties.contains_key(key);
693 }
694 self.promotion_rules.is_empty() || self.promotion_rules.iter().any(|r| r.state_key == key)
695 }
696
697 async fn extract(&self, window: &[TranscriptTurn]) -> Result<Value, LlmError> {
698 self.extract_knowing(window, &serde_json::Map::new()).await
699 }
700
701 async fn extract_with_state(
704 &self,
705 window: &[TranscriptTurn],
706 state: &State,
707 ) -> Result<Value, LlmError> {
708 self.extract_knowing(window, &self.known_fields(state))
709 .await
710 }
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716 use crate::llm::LlmResponse;
717 use gemini_genai_rs::prelude::{Content, Part, Role};
718 use std::time::Instant;
719
720 struct MockLlm {
721 response: String,
722 }
723
724 #[async_trait]
725 impl BaseLlm for MockLlm {
726 fn model_id(&self) -> &str {
727 "mock"
728 }
729 async fn generate(&self, _request: LlmRequest) -> Result<LlmResponse, LlmError> {
730 Ok(LlmResponse {
731 content: Content {
732 role: Some(Role::Model),
733 parts: vec![Part::Text {
734 text: self.response.clone(),
735 }],
736 },
737 finish_reason: Some("STOP".into()),
738 usage: None,
739 })
740 }
741 }
742
743 fn make_turns(pairs: &[(&str, &str)]) -> Vec<TranscriptTurn> {
744 pairs
745 .iter()
746 .enumerate()
747 .map(|(i, (user, model))| TranscriptTurn {
748 turn_number: i as u32,
749 user: user.to_string(),
750 model: model.to_string(),
751 tool_calls: Vec::new(),
752 timestamp: Instant::now(),
753 })
754 .collect()
755 }
756
757 #[tokio::test]
758 async fn llm_extractor_produces_json() {
759 let llm = Arc::new(MockLlm {
760 response: r#"{"phase": "ordering", "items": ["pizza"]}"#.to_string(),
761 });
762
763 let extractor = LlmExtractor::new("OrderState", llm, "Extract order state", 3);
764
765 let turns = make_turns(&[
766 ("I'd like a pizza", "Great! What size?"),
767 ("Large please", "Coming right up!"),
768 ]);
769
770 let result = extractor.extract(&turns).await.unwrap();
771 assert_eq!(result["phase"], "ordering");
772 assert_eq!(result["items"][0], "pizza");
773 }
774
775 #[tokio::test]
776 async fn llm_extractor_with_schema() {
777 let llm = Arc::new(MockLlm {
778 response: r#"{"sentiment": "positive", "score": 0.9}"#.to_string(),
779 });
780
781 let schema = serde_json::json!({
782 "type": "object",
783 "properties": {
784 "sentiment": {"type": "string", "enum": ["positive", "neutral", "negative"]},
785 "score": {"type": "number"}
786 }
787 });
788
789 let extractor =
790 LlmExtractor::new("Sentiment", llm, "Rate sentiment", 1).with_schema(schema);
791
792 let turns = make_turns(&[("This is great!", "Glad you think so!")]);
793 let result = extractor.extract(&turns).await.unwrap();
794 assert_eq!(result["sentiment"], "positive");
795 }
796
797 #[tokio::test]
798 async fn llm_extractor_invalid_json_returns_error() {
799 let llm = Arc::new(MockLlm {
800 response: "not json at all".to_string(),
801 });
802
803 let extractor = LlmExtractor::new("Bad", llm, "Extract", 1);
804 let turns = make_turns(&[("hi", "hello")]);
805 let result = extractor.extract(&turns).await;
806 assert!(result.is_err());
807 }
808
809 #[test]
810 fn format_transcript_readable() {
811 let turns = make_turns(&[("Hello", "Hi there!"), ("How are you?", "I'm doing well")]);
812 let formatted = LlmExtractor::format_transcript(&turns);
813 assert!(formatted.contains("User: Hello"));
814 assert!(formatted.contains("Assistant: Hi there!"));
815 assert!(formatted.contains("User: How are you?"));
816 }
817
818 #[tokio::test]
819 async fn llm_extractor_handles_markdown_fenced_json() {
820 let llm = Arc::new(MockLlm {
821 response: "```json\n{\"status\": \"ok\"}\n```".to_string(),
822 });
823
824 let extractor = LlmExtractor::new("Fenced", llm, "Extract", 1);
825 let turns = make_turns(&[("test", "reply")]);
826 let result = extractor.extract(&turns).await.unwrap();
827 assert_eq!(result["status"], "ok");
828 }
829
830 #[test]
831 fn strip_code_fences_variants() {
832 assert_eq!(super::strip_code_fences("```json\n{}\n```"), "{}");
833 assert_eq!(super::strip_code_fences("```\n{}\n```"), "{}");
834 assert_eq!(
835 super::strip_code_fences(" ```json\n{\"a\":1}\n``` "),
836 "{\"a\":1}"
837 );
838 assert_eq!(
839 super::strip_code_fences("{\"bare\":true}"),
840 "{\"bare\":true}"
841 );
842 }
843
844 #[test]
845 fn every_field_of_the_extraction_schema_may_be_null() {
846 let schema = serde_json::json!({
847 "type": "object",
848 "properties": {
849 "party_size": { "type": "integer" },
850 "slot": { "type": ["string"] },
851 "kind": { "enum": ["a", "b"] },
852 "note": { "type": ["string", "null"] }
853 }
854 });
855 let nullable = nullable_fields(&schema);
856 let props = &nullable["properties"];
857 assert_eq!(
858 props["party_size"]["type"],
859 serde_json::json!(["integer", "null"])
860 );
861 assert_eq!(props["slot"]["type"], serde_json::json!(["string", "null"]));
862 assert_eq!(
863 props["kind"]["anyOf"],
864 serde_json::json!([{ "enum": ["a", "b"] }, { "type": "null" }])
865 );
866 assert_eq!(props["note"]["type"], serde_json::json!(["string", "null"]));
867 assert_eq!(
869 nullable_fields(&serde_json::json!({ "type": "object" })),
870 serde_json::json!({ "type": "object" })
871 );
872 }
873
874 #[tokio::test]
875 async fn the_extraction_request_allows_and_asks_for_null() {
876 struct Capture(parking_lot::Mutex<Option<LlmRequest>>);
877 #[async_trait]
878 impl BaseLlm for Capture {
879 fn model_id(&self) -> &str {
880 "capture"
881 }
882 async fn generate(&self, request: LlmRequest) -> Result<LlmResponse, LlmError> {
883 *self.0.lock() = Some(request);
884 Ok(LlmResponse {
885 content: Content {
886 role: Some(Role::Model),
887 parts: vec![Part::Text { text: "{}".into() }],
888 },
889 finish_reason: Some("STOP".into()),
890 usage: None,
891 })
892 }
893 }
894 let llm = Arc::new(Capture(parking_lot::Mutex::new(None)));
895 let extractor = LlmExtractor::new("booking", llm.clone(), "Extract the booking.", 3)
896 .with_schema(serde_json::json!({
897 "type": "object",
898 "properties": { "party_size": { "type": "integer" } }
899 }));
900 extractor
901 .extract(&make_turns(&[("hi", "hello")]))
902 .await
903 .unwrap();
904 let request = llm.0.lock().take().unwrap();
905 assert_eq!(
906 request.response_json_schema.unwrap()["properties"]["party_size"]["type"],
907 serde_json::json!(["integer", "null"])
908 );
909 let system = request.system_instruction.unwrap();
910 assert!(system.starts_with("Extract the booking."));
911 assert!(system.contains("Use null for any field the transcript does not state"));
912 }
913
914 #[tokio::test]
915 async fn known_fields_are_sent_with_the_transcript() {
916 struct Capture(parking_lot::Mutex<Vec<LlmRequest>>);
917 #[async_trait]
918 impl BaseLlm for Capture {
919 fn model_id(&self) -> &str {
920 "capture"
921 }
922 async fn generate(&self, request: LlmRequest) -> Result<LlmResponse, LlmError> {
923 self.0.lock().push(request);
924 Ok(LlmResponse {
925 content: Content {
926 role: Some(Role::Model),
927 parts: vec![Part::Text { text: "{}".into() }],
928 },
929 finish_reason: Some("STOP".into()),
930 usage: None,
931 })
932 }
933 }
934 let llm = Arc::new(Capture(parking_lot::Mutex::new(Vec::new())));
935 let extractor = LlmExtractor::new("booking", llm.clone(), "Extract the booking.", 3)
936 .with_promotions(vec![
937 FieldPromotion::overwrite("party_size"),
938 FieldPromotion::overwrite("slot").to("requested_slot"),
939 ]);
940 let state = State::new();
941 let window = make_turns(&[("seven is perfect", "great")]);
942
943 extractor.extract_with_state(&window, &state).await.unwrap();
945 let _ = state.set("party_size", 4);
947 let _ = state.set("requested_slot", "tomorrow at 7 pm");
948 extractor.extract_with_state(&window, &state).await.unwrap();
949
950 let requests = llm.0.lock();
951 let text = |r: &LlmRequest| {
952 r.contents[0]
953 .parts
954 .iter()
955 .filter_map(|p| match p {
956 Part::Text { text } => Some(text.clone()),
957 _ => None,
958 })
959 .collect::<String>()
960 };
961 assert!(text(&requests[0]).starts_with("Transcript:"));
962 assert!(
963 !requests[0]
964 .system_instruction
965 .as_ref()
966 .unwrap()
967 .contains("already known")
968 );
969 let second = text(&requests[1]);
970 assert!(second.starts_with("Already known: "), "{second}");
971 assert!(
972 second.contains("The user's latest turn: \"seven is perfect\""),
973 "{second}"
974 );
975 assert!(!text(&requests[0]).contains("latest turn"));
976 assert!(
977 second.contains(r#""party_size":4"#) && second.contains(r#""slot":"tomorrow at 7 pm""#)
978 );
979 assert!(
980 requests[1]
981 .system_instruction
982 .as_ref()
983 .unwrap()
984 .contains("Some fields are already known")
985 );
986 }
987
988 #[test]
989 fn a_restated_value_is_not_promoted_again() {
990 let llm = Arc::new(MockLlm {
991 response: "{}".into(),
992 });
993 let extractor = LlmExtractor::new("booking", llm, "x", 3)
994 .with_promotions(vec![FieldPromotion::overwrite("slot")]);
995 let state = State::new();
996 let _ = state.set("slot", "tomorrow at 7 pm");
997 let before = state.get_raw("slot");
998 let events = promote_fields(
999 &extractor,
1000 "booking",
1001 &serde_json::json!({ "slot": "Tomorrow at 7 PM." }),
1002 &state,
1003 );
1004 assert_eq!(state.get_raw("slot"), before, "the known wording is kept");
1005 assert!(events.iter().any(|e| matches!(
1006 e,
1007 PromotionEvent::Decision { accepted: false, reason, .. }
1008 if reason == "an equivalent value is already known"
1009 )));
1010
1011 promote_fields(
1013 &extractor,
1014 "booking",
1015 &serde_json::json!({ "slot": "8 pm" }),
1016 &state,
1017 );
1018 assert_eq!(state.get::<String>("slot").as_deref(), Some("8 pm"));
1019 }
1020
1021 #[test]
1022 fn extractor_name_and_window_size() {
1023 let llm = Arc::new(MockLlm {
1024 response: "{}".to_string(),
1025 });
1026 let ext = LlmExtractor::new("TestExtractor", llm, "test", 5);
1027 assert_eq!(ext.name(), "TestExtractor");
1028 assert_eq!(ext.window_size(), 5);
1029 }
1030
1031 #[test]
1032 fn extractor_default_trigger_is_every_turn() {
1033 let llm = Arc::new(MockLlm {
1034 response: "{}".to_string(),
1035 });
1036 let ext = LlmExtractor::new("Test", llm, "test", 5);
1037 assert_eq!(ext.trigger(), ExtractionTrigger::EveryTurn);
1038 }
1039
1040 #[test]
1041 fn extractor_with_trigger() {
1042 let llm = Arc::new(MockLlm {
1043 response: "{}".to_string(),
1044 });
1045 let ext = LlmExtractor::new("Test", llm, "test", 5)
1046 .with_trigger(ExtractionTrigger::AfterToolCall);
1047 assert_eq!(ext.trigger(), ExtractionTrigger::AfterToolCall);
1048 }
1049
1050 struct Scripted {
1052 replies: parking_lot::Mutex<Vec<Result<&'static str, LlmError>>>,
1053 requests: parking_lot::Mutex<Vec<LlmRequest>>,
1054 }
1055
1056 impl Scripted {
1057 fn new(replies: Vec<Result<&'static str, LlmError>>) -> Arc<Self> {
1058 Arc::new(Self {
1059 replies: parking_lot::Mutex::new(replies),
1060 requests: parking_lot::Mutex::new(Vec::new()),
1061 })
1062 }
1063 fn budgets(&self) -> Vec<Option<u32>> {
1064 self.requests
1065 .lock()
1066 .iter()
1067 .map(|r| r.thinking_budget)
1068 .collect()
1069 }
1070 }
1071
1072 #[async_trait]
1073 impl BaseLlm for Scripted {
1074 fn model_id(&self) -> &str {
1075 "scripted"
1076 }
1077 async fn generate(&self, request: LlmRequest) -> Result<LlmResponse, LlmError> {
1078 self.requests.lock().push(request);
1079 let text = self.replies.lock().remove(0)?;
1080 Ok(LlmResponse {
1081 content: Content {
1082 role: Some(Role::Model),
1083 parts: vec![Part::Text { text: text.into() }],
1084 },
1085 finish_reason: Some("STOP".into()),
1086 usage: None,
1087 })
1088 }
1089 }
1090
1091 fn bad_request() -> LlmError {
1092 LlmError::Api {
1093 status: 400,
1094 message: "Budget 0 is invalid. This model only works in thinking mode.".into(),
1095 }
1096 }
1097
1098 #[tokio::test]
1099 async fn extraction_does_not_think_by_default() {
1100 let llm = Scripted::new(vec![Ok("{}")]);
1101 let extractor = LlmExtractor::new("x", llm.clone(), "Extract.", 2);
1102 extractor
1103 .extract(&make_turns(&[("hi", "hello")]))
1104 .await
1105 .unwrap();
1106 assert_eq!(llm.budgets(), [Some(0)]);
1107
1108 let llm = Scripted::new(vec![Ok("{}")]);
1109 let extractor =
1110 LlmExtractor::new("x", llm.clone(), "Extract.", 2).with_thinking_budget(None);
1111 extractor
1112 .extract(&make_turns(&[("hi", "hello")]))
1113 .await
1114 .unwrap();
1115 assert_eq!(llm.budgets(), [None], "None leaves the model's default");
1116 }
1117
1118 #[tokio::test]
1119 async fn a_rejected_budget_of_zero_falls_back_to_the_smallest_accepted() {
1120 let llm = Scripted::new(vec![Err(bad_request()), Ok(r#"{"a": 1}"#), Ok("{}")]);
1121 let extractor = LlmExtractor::new("x", llm.clone(), "Extract.", 2);
1122 let window = make_turns(&[("hi", "hello")]);
1123 assert_eq!(extractor.extract(&window).await.unwrap()["a"], 1);
1124 extractor.extract(&window).await.unwrap();
1125 assert_eq!(
1126 llm.budgets(),
1127 [
1128 Some(0),
1129 Some(FALLBACK_THINKING_BUDGET),
1130 Some(FALLBACK_THINKING_BUDGET)
1131 ],
1132 "the accepted fallback is kept"
1133 );
1134 }
1135
1136 #[tokio::test]
1137 async fn a_model_that_rejects_every_budget_gets_none() {
1138 let llm = Scripted::new(vec![
1139 Err(bad_request()),
1140 Err(bad_request()),
1141 Ok(r#"{"a": 1}"#),
1142 Ok("{}"),
1143 ]);
1144 let extractor = LlmExtractor::new("x", llm.clone(), "Extract.", 2);
1145 let window = make_turns(&[("hi", "hello")]);
1146 assert_eq!(extractor.extract(&window).await.unwrap()["a"], 1);
1147 extractor.extract(&window).await.unwrap();
1148 assert_eq!(
1149 llm.budgets(),
1150 [Some(0), Some(FALLBACK_THINKING_BUDGET), None, None]
1151 );
1152
1153 let llm = Scripted::new(vec![Err(bad_request()), Ok("{}")]);
1156 let extractor = LlmExtractor::new("x", llm.clone(), "Extract.", 2)
1157 .with_thinking_budget(Some(FALLBACK_THINKING_BUDGET));
1158 extractor.extract(&window).await.unwrap();
1159 assert_eq!(llm.budgets(), [Some(FALLBACK_THINKING_BUDGET), None]);
1160 }
1161
1162 #[tokio::test]
1163 async fn a_bad_request_without_a_budget_is_not_retried() {
1164 let llm = Scripted::new(vec![Err(bad_request())]);
1165 let extractor =
1166 LlmExtractor::new("x", llm.clone(), "Extract.", 2).with_thinking_budget(None);
1167 assert!(
1168 extractor
1169 .extract(&make_turns(&[("hi", "hello")]))
1170 .await
1171 .is_err()
1172 );
1173 assert_eq!(llm.requests.lock().len(), 1);
1174 }
1175
1176 #[tokio::test]
1177 async fn a_transient_failure_is_retried_once() {
1178 let unavailable = || LlmError::Api {
1179 status: 503,
1180 message: "Service Unavailable".into(),
1181 };
1182 let llm = Scripted::new(vec![Err(unavailable()), Ok(r#"{"a": 1}"#)]);
1183 let extractor = LlmExtractor::new("x", llm.clone(), "Extract.", 2);
1184 let window = make_turns(&[("hi", "hello")]);
1185 assert_eq!(extractor.extract(&window).await.unwrap()["a"], 1);
1186
1187 let llm = Scripted::new(vec![Err(unavailable()), Err(unavailable())]);
1188 let extractor = LlmExtractor::new("x", llm.clone(), "Extract.", 2);
1189 assert!(extractor.extract(&window).await.is_err());
1190 assert_eq!(llm.requests.lock().len(), 2, "one retry, not more");
1191 }
1192
1193 #[test]
1194 fn may_write_follows_the_rules_or_the_schema() {
1195 let llm = Scripted::new(vec![]);
1196 let with_rules = LlmExtractor::new("x", llm.clone(), "Extract.", 2)
1197 .with_promotions(vec![FieldPromotion::true_only("ok").to("confirmed")]);
1198 assert!(with_rules.may_write("confirmed"));
1199 assert!(!with_rules.may_write("ok"));
1200
1201 let flattened = LlmExtractor::new("x", llm.clone(), "Extract.", 2).with_schema(
1202 serde_json::json!({ "type": "object", "properties": { "confirmed": { "type": "boolean" } } }),
1203 );
1204 assert!(flattened.may_write("confirmed"));
1205 assert!(!flattened.may_write("slot"));
1206
1207 let unknown = LlmExtractor::new("x", llm, "Extract.", 2);
1208 assert!(
1209 unknown.may_write("anything"),
1210 "no schema, no rules: it may write any key"
1211 );
1212 }
1213
1214 #[test]
1215 fn a_turn_without_the_caller_is_not_extracted() {
1216 let llm = Scripted::new(vec![]);
1217 let extractor = LlmExtractor::new("x", llm, "Extract.", 3);
1218 assert!(!extractor.should_extract(&make_turns(&[
1220 ("Four at seven.", "Let me check."),
1221 ("", "Seven is free."),
1222 ])));
1223 assert!(extractor.should_extract(&make_turns(&[
1224 ("", "Seven is free."),
1225 ("Book it.", "Done."),
1226 ])));
1227 assert!(extractor.should_extract(&make_turns(&[("", "Hello.")])));
1229 let on_generation = LlmExtractor::new("y", Scripted::new(vec![]), "Extract.", 3)
1231 .with_trigger(ExtractionTrigger::OnGenerationComplete);
1232 assert!(on_generation.should_extract(&make_turns(&[
1233 ("Four at seven.", "Let me check."),
1234 ("", "Seven is free."),
1235 ])));
1236 }
1237}