1use std::future::Future;
25use std::sync::Arc;
26use std::time::{Duration, Instant};
27
28use async_trait::async_trait;
29use dashmap::DashMap;
30use regex::Regex;
31use serde_json::Value;
32use std::sync::LazyLock;
33
34use crate::AsyncSourceFn;
35use crate::live::extractor::{ExtractionTrigger, FieldPromotion, OnComplete, TurnExtractor};
36use crate::live::transcript::TranscriptTurn;
37use crate::llm::LlmError;
38use crate::orchestration::AgentMode;
39use crate::state::State;
40use crate::text::TextAgent;
41
42static MONEY_RE: LazyLock<Regex> =
43 LazyLock::new(|| Regex::new(r"\$?\s?(\d{1,3}(?:,\d{3})*|\d+)(?:\.(\d{1,2}))?").unwrap());
44static INT_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"-?\d+").unwrap());
45static DATE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\b\d{4}-\d{2}-\d{2}\b").unwrap());
46static TIME12_RE: LazyLock<Regex> =
48 LazyLock::new(|| Regex::new(r"(?i)\b(\d{1,2})(?::(\d{2}))?\s*([ap]m)\b").unwrap());
49static TIME24_RE: LazyLock<Regex> =
51 LazyLock::new(|| Regex::new(r"\b([01]?\d|2[0-3]):([0-5]\d)\b").unwrap());
52
53fn parse_time(text: &str) -> Option<String> {
55 if let Some(c) = TIME12_RE.captures(text) {
56 let mut hour: u32 = c.get(1)?.as_str().parse().ok()?;
57 let min: u32 = c.get(2).map(|m| m.as_str()).unwrap_or("00").parse().ok()?;
58 let pm = c.get(3)?.as_str().eq_ignore_ascii_case("pm");
59 if hour > 12 {
60 return None; }
62 if pm && hour < 12 {
63 hour += 12;
64 } else if !pm && hour == 12 {
65 hour = 0; }
67 return Some(format!("{hour:02}:{min:02}"));
68 }
69 let c = TIME24_RE.captures(text)?;
70 Some(format!(
71 "{:02}:{}",
72 c.get(1)?.as_str().parse::<u32>().ok()?,
73 c.get(2)?.as_str()
74 ))
75}
76
77#[derive(Clone)]
83pub enum Recognizer {
84 Integer {
87 near: Vec<String>,
89 },
90 Money,
92 Regex(Regex),
94 OneOf(Vec<String>),
96 Fuzzy {
99 options: Vec<String>,
101 min: f64,
103 },
104 YesNo,
106 DateTime,
112}
113
114impl Recognizer {
115 pub fn integer() -> Self {
117 Recognizer::Integer { near: Vec::new() }
118 }
119 pub fn integer_near<I, S>(anchors: I) -> Self
121 where
122 I: IntoIterator<Item = S>,
123 S: Into<String>,
124 {
125 Recognizer::Integer {
126 near: anchors.into_iter().map(Into::into).collect(),
127 }
128 }
129 pub fn money() -> Self {
131 Recognizer::Money
132 }
133 pub fn regex(pattern: &str) -> Self {
135 Recognizer::Regex(Regex::new(pattern).expect("invalid recognizer regex"))
136 }
137 pub fn one_of<I, S>(options: I) -> Self
139 where
140 I: IntoIterator<Item = S>,
141 S: Into<String>,
142 {
143 Recognizer::OneOf(options.into_iter().map(Into::into).collect())
144 }
145 pub fn fuzzy<I, S>(options: I) -> Self
147 where
148 I: IntoIterator<Item = S>,
149 S: Into<String>,
150 {
151 Recognizer::Fuzzy {
152 options: options.into_iter().map(Into::into).collect(),
153 min: 0.85,
154 }
155 }
156 pub fn yes_no() -> Self {
158 Recognizer::YesNo
159 }
160 pub fn datetime() -> Self {
162 Recognizer::DateTime
163 }
164
165 pub fn recognize(&self, text: &str) -> Option<(Value, f32)> {
167 let lower = text.to_lowercase();
168 match self {
169 Recognizer::Integer { near } => {
170 if !near.is_empty() && !near.iter().any(|a| lower.contains(&a.to_lowercase())) {
171 return None;
172 }
173 let m = INT_RE.find(text)?;
174 m.as_str()
175 .parse::<i64>()
176 .ok()
177 .map(|n| (Value::from(n), 1.0))
178 }
179 Recognizer::Money => {
180 let caps = MONEY_RE.captures(text)?;
181 let whole = caps.get(1)?.as_str().replace(',', "");
182 let cents = caps.get(2).map(|c| c.as_str()).unwrap_or("0");
183 let amount: f64 = format!("{whole}.{cents:0<2}").parse().ok()?;
184 Some((Value::from(amount), 1.0))
185 }
186 Recognizer::Regex(re) => {
187 let caps = re.captures(text)?;
188 let s = caps.get(1).or_else(|| caps.get(0))?.as_str().to_string();
189 Some((Value::from(s), 1.0))
190 }
191 Recognizer::OneOf(options) => options
192 .iter()
193 .find(|o| lower.contains(&o.to_lowercase()))
194 .map(|o| (Value::from(o.clone()), 1.0)),
195 Recognizer::Fuzzy { options, min } => {
196 let mut best: Option<(&String, f64)> = None;
197 for opt in options {
198 let ol = opt.to_lowercase();
199 let sim = std::iter::once(lower.as_str())
201 .chain(lower.split_whitespace())
202 .map(|w| strsim::jaro_winkler(&ol, w))
203 .fold(0.0_f64, f64::max);
204 if sim >= *min && best.map(|(_, b)| sim > b).unwrap_or(true) {
205 best = Some((opt, sim));
206 }
207 }
208 best.map(|(opt, sim)| (Value::from(opt.clone()), sim as f32))
209 }
210 Recognizer::YesNo => {
211 const NO: &[&str] = &[
215 "no",
216 "nope",
217 "nah",
218 "not",
219 "don't",
220 "dont",
221 "doesn't",
222 "isn't",
223 "won't",
224 "never",
225 "incorrect",
226 "wrong",
227 "negative",
228 ];
229 const YES: &[&str] = &[
230 "yes",
231 "yeah",
232 "yep",
233 "yup",
234 "sure",
235 "correct",
236 "confirm",
237 "confirmed",
238 "ok",
239 "okay",
240 "right",
241 "affirmative",
242 ];
243 let tokens: Vec<&str> = lower
244 .split(|c: char| !(c.is_alphanumeric() || c == '\''))
245 .filter(|t| !t.is_empty())
246 .collect();
247 if tokens.iter().any(|t| NO.contains(t)) {
248 Some((Value::Bool(false), 0.9))
249 } else if tokens.iter().any(|t| YES.contains(t)) {
250 Some((Value::Bool(true), 0.9))
251 } else {
252 None
253 }
254 }
255 Recognizer::DateTime => {
256 const WEEKDAYS: &[&str] = &[
257 "monday",
258 "tuesday",
259 "wednesday",
260 "thursday",
261 "friday",
262 "saturday",
263 "sunday",
264 ];
265 let mut obj = serde_json::Map::new();
266 if let Some(m) = DATE_RE.find(text) {
267 obj.insert("date".into(), Value::from(m.as_str().to_string()));
268 }
269 if let Some(t) = parse_time(&lower) {
270 obj.insert("time".into(), Value::from(t));
271 }
272 if let Some(d) = ["today", "tomorrow", "tonight", "yesterday"]
273 .into_iter()
274 .find(|d| lower.contains(d))
275 {
276 obj.insert("day".into(), Value::from(d));
277 }
278 if let Some(w) = WEEKDAYS.iter().find(|w| lower.contains(*w)) {
279 obj.insert("weekday".into(), Value::from(*w));
280 }
281 if let Some(p) = ["morning", "afternoon", "evening", "noon", "midnight"]
282 .into_iter()
283 .find(|p| lower.contains(p))
284 {
285 obj.insert("part".into(), Value::from(p));
286 }
287 if obj.is_empty() {
288 None
289 } else {
290 Some((Value::Object(obj), 1.0))
291 }
292 }
293 }
294 }
295}
296
297#[derive(Clone)]
299enum Source {
300 Recognize(Recognizer),
302 Resolve {
304 args: Vec<String>,
306 ttl: Option<Duration>,
308 fetch: AsyncSourceFn<Value>,
311 },
312}
313
314type FieldValidator = Arc<dyn Fn(&Value) -> bool + Send + Sync>;
317
318#[derive(Clone)]
320pub struct Field {
321 name: String,
322 source: Source,
323 state_key: String,
324 overwrite: bool,
325 validate: Option<FieldValidator>,
327}
328
329#[derive(Clone)]
332pub struct Extract {
333 name: String,
334 fields: Vec<Field>,
335 window: usize,
336 trigger: ExtractionTrigger,
337 on_complete: Option<OnComplete>,
338}
339
340impl Extract {
341 pub fn record(name: impl Into<String>) -> ExtractBuilder {
343 ExtractBuilder {
344 name: name.into(),
345 fields: Vec::new(),
346 window: 3,
347 trigger: ExtractionTrigger::EveryTurn,
348 on_complete: None,
349 }
350 }
351
352 pub fn into_extractor(self) -> Arc<dyn TurnExtractor> {
354 Arc::new(RecordExtractor::new(self))
355 }
356
357 pub fn field_state_keys(&self) -> Vec<(String, String)> {
361 self.fields
362 .iter()
363 .map(|f| (f.name.clone(), f.state_key.clone()))
364 .collect()
365 }
366}
367
368pub struct ExtractBuilder {
370 name: String,
371 fields: Vec<Field>,
372 window: usize,
373 trigger: ExtractionTrigger,
374 on_complete: Option<OnComplete>,
375}
376
377impl ExtractBuilder {
378 pub fn field(mut self, name: impl Into<String>, recognizer: Recognizer) -> Self {
380 let name = name.into();
381 self.fields.push(Field {
382 state_key: name.clone(),
383 name,
384 source: Source::Recognize(recognizer),
385 overwrite: false,
386 validate: None,
387 });
388 self
389 }
390 pub fn field_to(
392 mut self,
393 name: impl Into<String>,
394 state_key: impl Into<String>,
395 recognizer: Recognizer,
396 ) -> Self {
397 self.fields.push(Field {
398 name: name.into(),
399 state_key: state_key.into(),
400 source: Source::Recognize(recognizer),
401 overwrite: false,
402 validate: None,
403 });
404 self
405 }
406 pub fn field_resolve<I, S, F, Fut>(
411 mut self,
412 name: impl Into<String>,
413 args: I,
414 ttl: Option<Duration>,
415 fetch: F,
416 ) -> Self
417 where
418 I: IntoIterator<Item = S>,
419 S: Into<String>,
420 F: Fn(Value) -> Fut + Send + Sync + 'static,
421 Fut: Future<Output = Result<Value, String>> + Send + 'static,
422 {
423 let name = name.into();
424 let fetch = Arc::new(fetch);
425 self.fields.push(Field {
426 state_key: name.clone(),
427 name,
428 source: Source::Resolve {
429 args: args.into_iter().map(Into::into).collect(),
430 ttl,
431 fetch: Arc::new(move |a| {
432 let fetch = fetch.clone();
433 Box::pin(async move { fetch(a).await })
434 }),
435 },
436 overwrite: false,
437 validate: None,
438 });
439 self
440 }
441 pub fn validate<F>(mut self, predicate: F) -> Self
445 where
446 F: Fn(&Value) -> bool + Send + Sync + 'static,
447 {
448 if let Some(field) = self.fields.last_mut() {
449 field.validate = Some(Arc::new(predicate));
450 }
451 self
452 }
453
454 pub fn window(mut self, n: usize) -> Self {
456 self.window = n;
457 self
458 }
459 pub fn trigger(mut self, trigger: ExtractionTrigger) -> Self {
461 self.trigger = trigger;
462 self
463 }
464 pub fn on_complete(mut self, agent: Arc<dyn TextAgent>, mode: AgentMode) -> Self {
467 self.on_complete = Some(OnComplete { agent, mode });
468 self
469 }
470 pub fn build(self) -> Extract {
472 Extract {
473 name: self.name,
474 fields: self.fields,
475 window: self.window,
476 trigger: self.trigger,
477 on_complete: self.on_complete,
478 }
479 }
480}
481
482pub struct RecordExtractor {
485 spec: Extract,
486 promotions: Vec<FieldPromotion>,
487 cache: Arc<DashMap<String, (Value, Instant)>>,
489}
490
491impl RecordExtractor {
492 pub fn new(spec: Extract) -> Self {
494 let promotions = spec
495 .fields
496 .iter()
497 .map(|f| {
498 let p = if f.overwrite {
499 FieldPromotion::overwrite(&f.name)
500 } else {
501 FieldPromotion::keep_known(&f.name)
502 };
503 p.to(&f.state_key)
504 })
505 .collect();
506 Self {
507 spec,
508 promotions,
509 cache: Arc::new(DashMap::new()),
510 }
511 }
512
513 async fn resolve_field(
519 &self,
520 field: &str,
521 args: &[String],
522 ttl: Option<Duration>,
523 fetch: &AsyncSourceFn<Value>,
524 fresh: &serde_json::Map<String, Value>,
525 state: &State,
526 ) -> Option<Value> {
527 let mut obj = serde_json::Map::new();
529 for key in args {
530 if let Some(v) = fresh.get(key).cloned().or_else(|| state.get::<Value>(key)) {
531 obj.insert(key.clone(), v);
532 }
533 }
534 let args_value = Value::Object(obj);
535 let cache_key = format!("{field}|{args_value}");
536 if let Some(ttl) = ttl
537 && let Some(entry) = self.cache.get(&cache_key)
538 && entry.1.elapsed() < ttl
539 {
540 return Some(entry.0.clone());
541 }
542 match fetch(args_value).await {
543 Ok(value) => {
544 if ttl.is_some() {
545 self.cache
546 .insert(cache_key, (value.clone(), Instant::now()));
547 }
548 Some(value)
549 }
550 Err(e) => {
551 tracing::warn!(field, "resolver failed: {e}");
552 None
553 }
554 }
555 }
556}
557
558#[async_trait]
559impl TurnExtractor for RecordExtractor {
560 fn name(&self) -> &str {
561 &self.spec.name
562 }
563
564 fn window_size(&self) -> usize {
565 self.spec.window
566 }
567
568 fn trigger(&self) -> ExtractionTrigger {
569 self.spec.trigger.clone()
570 }
571
572 fn promotion_rules(&self) -> &[FieldPromotion] {
573 &self.promotions
574 }
575
576 fn on_complete(&self) -> Option<OnComplete> {
577 self.spec.on_complete.clone()
578 }
579
580 async fn extract(&self, window: &[TranscriptTurn]) -> Result<Value, LlmError> {
581 let text = window
583 .iter()
584 .map(|t| t.user.as_str())
585 .collect::<Vec<_>>()
586 .join(" ");
587 let mut obj = serde_json::Map::new();
588 for field in &self.spec.fields {
589 if let Source::Recognize(rec) = &field.source
590 && let Some((value, _confidence)) = rec.recognize(&text)
591 {
592 if field.validate.as_ref().is_some_and(|v| !v(&value)) {
593 continue; }
595 obj.insert(field.name.clone(), value);
596 }
597 }
598 Ok(Value::Object(obj))
599 }
600
601 async fn extract_with_state(
602 &self,
603 window: &[TranscriptTurn],
604 state: &State,
605 ) -> Result<Value, LlmError> {
606 let text = window
607 .iter()
608 .map(|t| t.user.as_str())
609 .collect::<Vec<_>>()
610 .join(" ");
611 let mut obj = serde_json::Map::new();
612 let mut fresh = serde_json::Map::new();
616 for field in &self.spec.fields {
617 if let Source::Recognize(rec) = &field.source
618 && let Some((value, confidence)) = rec.recognize(&text)
619 {
620 if field.validate.as_ref().is_some_and(|v| !v(&value)) {
621 continue; }
623 let _ = state.set(
626 format!("state_meta:{}", field.state_key),
627 serde_json::json!({ "source": "extraction", "confidence": confidence }),
628 );
629 fresh.insert(field.state_key.clone(), value.clone());
630 obj.insert(field.name.clone(), value);
631 }
632 }
633 let resolves = self
635 .spec
636 .fields
637 .iter()
638 .filter_map(|field| match &field.source {
639 Source::Resolve { args, ttl, fetch } => Some(async {
640 self.resolve_field(&field.name, args, *ttl, fetch, &fresh, state)
641 .await
642 .map(|v| (field.name.clone(), v))
643 }),
644 Source::Recognize(_) => None,
645 });
646 for resolved in futures_util::future::join_all(resolves)
647 .await
648 .into_iter()
649 .flatten()
650 {
651 obj.insert(resolved.0, resolved.1);
652 }
653 Ok(Value::Object(obj))
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use serde_json::json;
661
662 fn turn(user: &str) -> TranscriptTurn {
663 TranscriptTurn {
664 turn_number: 0,
665 user: user.to_string(),
666 model: String::new(),
667 tool_calls: Vec::new(),
668 timestamp: std::time::Instant::now(),
669 }
670 }
671
672 #[test]
673 fn recognizers_basic() {
674 assert_eq!(
675 Recognizer::integer()
676 .recognize("I want 3 of them")
677 .unwrap()
678 .0,
679 json!(3)
680 );
681 assert_eq!(
682 Recognizer::money()
683 .recognize("that'll be $1,250.50")
684 .unwrap()
685 .0,
686 json!(1250.50)
687 );
688 assert_eq!(
689 Recognizer::one_of(["pizza", "salad"])
690 .recognize("a large PIZZA please")
691 .unwrap()
692 .0,
693 json!("pizza")
694 );
695 assert_eq!(
696 Recognizer::yes_no()
697 .recognize("yes that's right")
698 .unwrap()
699 .0,
700 json!(true)
701 );
702 assert_eq!(
703 Recognizer::yes_no().recognize("no thanks").unwrap().0,
704 json!(false)
705 );
706 assert!(Recognizer::yes_no().recognize("maybe later").is_none());
707 }
708
709 #[test]
710 fn yes_no_negation_wins_and_is_word_aware() {
711 let r = Recognizer::yes_no();
712 assert_eq!(r.recognize("not correct").unwrap().0, json!(false));
714 assert_eq!(r.recognize("don't confirm that").unwrap().0, json!(false));
715 assert_eq!(r.recognize("that's incorrect").unwrap().0, json!(false));
717 assert!(r.recognize("another option").is_none());
719 assert_eq!(r.recognize("yes please").unwrap().0, json!(true));
721 }
722
723 #[tokio::test]
724 async fn resolver_binds_args_recognized_this_turn() {
725 let spec = Extract::record("booking")
728 .field("slot", Recognizer::one_of(["morning", "afternoon"]))
729 .field_resolve("availability", ["slot"], None, |args: Value| async move {
730 Ok(json!({ "slot_seen": args.get("slot").cloned() }))
731 })
732 .build();
733 let ext = RecordExtractor::new(spec);
734 let state = State::new(); let out = ext
736 .extract_with_state(&[turn("afternoon works")], &state)
737 .await
738 .unwrap();
739 assert_eq!(out["slot"], json!("afternoon"));
740 assert_eq!(out["availability"], json!({ "slot_seen": "afternoon" }));
741 }
742
743 #[test]
744 fn datetime_normalizes_clock_and_calendar() {
745 let r = Recognizer::datetime();
746 assert_eq!(
748 r.recognize("can we meet at 3pm").unwrap().0,
749 json!({"time": "15:00"})
750 );
751 assert_eq!(
753 r.recognize("tomorrow at 9:30 am works").unwrap().0,
754 json!({"time": "09:30", "day": "tomorrow"})
755 );
756 assert_eq!(
758 r.recognize("12am sharp").unwrap().0,
759 json!({"time": "00:00"})
760 );
761 assert_eq!(
763 r.recognize("friday afternoon, 2026-06-05 at 15:00")
764 .unwrap()
765 .0,
766 json!({"date": "2026-06-05", "time": "15:00", "weekday": "friday", "part": "afternoon"})
767 );
768 assert!(r.recognize("a table for 4 people").is_none());
770 assert!(r.recognize("at 13pm").is_none());
772 }
773
774 #[test]
775 fn integer_near_anchors() {
776 let r = Recognizer::integer_near(["quantity", "want"]);
777 assert_eq!(r.recognize("I want 5").unwrap().0, json!(5));
778 assert!(r.recognize("call me at 5").is_none()); }
780
781 #[test]
782 fn fuzzy_matches_misheard_name() {
783 let r = Recognizer::fuzzy(["Johnson", "Jackson", "Jensen"]);
784 let (v, conf) = r.recognize("the name is jonson").unwrap();
785 assert_eq!(v, json!("Johnson"));
786 assert!(conf > 0.85);
787 }
788
789 #[tokio::test]
790 async fn record_extractor_captures_fields() {
791 let spec = Extract::record("order")
792 .field("quantity", Recognizer::integer_near(["want", "get"]))
793 .field("item", Recognizer::one_of(["pizza", "salad", "soda"]))
794 .window(2)
795 .build();
796 let extractor = RecordExtractor::new(spec);
797 assert_eq!(extractor.name(), "order");
798 assert_eq!(extractor.window_size(), 2);
799 assert_eq!(extractor.promotion_rules().len(), 2);
800
801 let window = vec![turn("I want 2 large pizza")];
802 let out = extractor.extract(&window).await.unwrap();
803 assert_eq!(out["quantity"], json!(2));
804 assert_eq!(out["item"], json!("pizza"));
805 }
806
807 #[tokio::test]
808 async fn record_resolves_async_field_from_state() {
809 let spec = Extract::record("booking")
810 .field("slot", Recognizer::one_of(["morning", "afternoon"]))
811 .field_resolve("availability", ["slot"], None, |args: Value| async move {
812 let slot = args.get("slot").and_then(|v| v.as_str()).unwrap_or("");
813 Ok(serde_json::json!({ "open": slot == "afternoon" }))
814 })
815 .build();
816 let ext = RecordExtractor::new(spec);
817 let state = State::new();
818 let _ = state.set("slot", "afternoon");
819 let out = ext
820 .extract_with_state(&[turn("afternoon please")], &state)
821 .await
822 .unwrap();
823 assert_eq!(out["slot"], json!("afternoon"));
824 assert_eq!(out["availability"], json!({ "open": true }));
825 }
826
827 #[tokio::test]
828 async fn resolver_field_caches_within_ttl() {
829 use std::sync::atomic::{AtomicUsize, Ordering};
830 let calls = Arc::new(AtomicUsize::new(0));
831 let counter = calls.clone();
832 let spec = Extract::record("b")
833 .field_resolve("v", ["k"], Some(Duration::from_secs(60)), move |_args| {
834 let counter = counter.clone();
835 async move {
836 counter.fetch_add(1, Ordering::SeqCst);
837 Ok(json!("x"))
838 }
839 })
840 .build();
841 let ext = RecordExtractor::new(spec);
842 let state = State::new();
843 let _ = state.set("k", 1);
844 let _ = ext.extract_with_state(&[turn("a")], &state).await.unwrap();
845 let _ = ext.extract_with_state(&[turn("a")], &state).await.unwrap();
846 assert_eq!(calls.load(Ordering::SeqCst), 1);
848 }
849
850 #[tokio::test]
851 async fn on_complete_is_exposed() {
852 use crate::error::AgentError;
853 struct A;
854 #[async_trait]
855 impl TextAgent for A {
856 fn name(&self) -> &str {
857 "a"
858 }
859 async fn run(&self, _s: &State) -> Result<String, AgentError> {
860 Ok("done".into())
861 }
862 }
863 let spec = Extract::record("x")
864 .field("q", Recognizer::integer())
865 .on_complete(Arc::new(A), AgentMode::Dispatch)
866 .build();
867 assert!(RecordExtractor::new(spec).on_complete().is_some());
868 }
869
870 #[tokio::test]
871 async fn record_extractor_omits_unrecognized() {
872 let spec = Extract::record("order")
873 .field("item", Recognizer::one_of(["pizza"]))
874 .build();
875 let out = RecordExtractor::new(spec)
876 .extract(&[turn("hello there")])
877 .await
878 .unwrap();
879 assert!(out.as_object().unwrap().is_empty());
880 }
881}