gemini_adk_rs/extract/
mod.rs

1//! The extraction kit — `Extract` records with deterministic recognizers.
2//!
3//! An [`Extract`] record declares typed fields, each filled by a [`Recognizer`]
4//! that reads the conversation transcript on the CPU — no model, no network, no
5//! accelerator. It compiles to a [`TurnExtractor`] (so it plugs into the
6//! existing extraction pipeline) and promotes recognized fields into governed
7//! `State`, where `Flow` guards (`done(captured([...]))`) and repair read them.
8//!
9//! This is the deterministic, transcript-sourced slice of the kit. LLM / fetch
10//! / MCP / agent *resolvers* and the `#[derive(Extract)]` macro layer on top of
11//! this same record model.
12//!
13//! ```
14//! use gemini_adk_rs::extract::{Extract, Recognizer};
15//!
16//! let order = Extract::record("order")
17//!     .field("quantity", Recognizer::integer())
18//!     .field("item", Recognizer::one_of(["pizza", "salad", "soda"]))
19//!     .field("confirmed", Recognizer::yes_no())
20//!     .window(3)
21//!     .build();
22//! ```
23
24use 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());
46// 12-hour clock with an am/pm marker: "3pm", "3:30 pm".
47static TIME12_RE: LazyLock<Regex> =
48    LazyLock::new(|| Regex::new(r"(?i)\b(\d{1,2})(?::(\d{2}))?\s*([ap]m)\b").unwrap());
49// 24-hour clock: "15:00", "09:30".
50static TIME24_RE: LazyLock<Regex> =
51    LazyLock::new(|| Regex::new(r"\b([01]?\d|2[0-3]):([0-5]\d)\b").unwrap());
52
53/// Normalize a clock time found in `text` to a 24-hour `"HH:MM"` string.
54fn 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; // "13pm" is not a real time
61        }
62        if pm && hour < 12 {
63            hour += 12;
64        } else if !pm && hour == 12 {
65            hour = 0; // 12am -> 00:00
66        }
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/// A deterministic transcript recognizer: `text -> (value, confidence)`.
78///
79/// Recognizers run on the CPU over the user transcript. Confidence is in
80/// `0.0..=1.0`; deterministic matches are high-confidence, fuzzy matches carry
81/// their similarity score.
82#[derive(Clone)]
83pub enum Recognizer {
84    /// First integer in the text. If `near` is non-empty, at least one anchor
85    /// word must be present for the match to count.
86    Integer {
87        /// Anchor words that must appear for the integer to be recognized.
88        near: Vec<String>,
89    },
90    /// A monetary amount (`$1,250.00`, `200`) → JSON number.
91    Money,
92    /// First capture group (or whole match) of a regex → string.
93    Regex(Regex),
94    /// The first option that appears (case-insensitive substring) → string.
95    OneOf(Vec<String>),
96    /// The option with the best Jaro-Winkler similarity ≥ `min` → string.
97    /// Useful for ASR-mangled names matched against a roster.
98    Fuzzy {
99        /// Candidate values to match against.
100        options: Vec<String>,
101        /// Minimum similarity in `0.0..=1.0`.
102        min: f64,
103    },
104    /// Affirmative/negative detection → boolean.
105    YesNo,
106    /// A calendar/clock expression → a JSON object with any of the keys
107    /// `date` (`YYYY-MM-DD`), `time` (24h `HH:MM`), `day` (`today`/`tomorrow`/
108    /// `tonight`/`yesterday`), `weekday`, and `part` (`morning`/`afternoon`/
109    /// `evening`/`noon`/`midnight`). Deterministic, on-device — a small
110    /// Duckling-style normalizer, not a full grammar.
111    DateTime,
112}
113
114impl Recognizer {
115    /// First integer, optionally anchored to nearby words.
116    pub fn integer() -> Self {
117        Recognizer::Integer { near: Vec::new() }
118    }
119    /// Integer recognized only when one of `anchors` is present.
120    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    /// A monetary amount.
130    pub fn money() -> Self {
131        Recognizer::Money
132    }
133    /// A regex (first capture group, or the whole match).
134    pub fn regex(pattern: &str) -> Self {
135        Recognizer::Regex(Regex::new(pattern).expect("invalid recognizer regex"))
136    }
137    /// Match against a fixed set of options (case-insensitive substring).
138    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    /// Fuzzy-match against options with a default 0.85 threshold.
146    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    /// Affirmative/negative.
157    pub fn yes_no() -> Self {
158        Recognizer::YesNo
159    }
160    /// A calendar/clock expression normalized to a JSON object.
161    pub fn datetime() -> Self {
162        Recognizer::DateTime
163    }
164
165    /// Recognize a value in `text`, with a confidence in `0.0..=1.0`.
166    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                    // Best similarity of the option against the whole text and each word.
200                    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                // Whole-word tokens (keeping apostrophes so "don't" stays intact),
212                // so "incorrect" doesn't match "correct" and "another" doesn't match
213                // "no". Negation is checked first: "not correct"/"don't confirm" → false.
214                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/// How a field is filled.
298#[derive(Clone)]
299enum Source {
300    /// Deterministic transcript recognizer (sync, no state).
301    Recognize(Recognizer),
302    /// Async resolver: bind `args` from `State`, fetch, optionally cache.
303    Resolve {
304        /// State keys bound into the args object passed to the fetcher.
305        args: Vec<String>,
306        /// Optional cache time-to-live keyed by `(field, canonical args)`.
307        ttl: Option<Duration>,
308        /// The async fetcher.
309        /// (An [`AsyncSourceFn`] bound from the args object, not the whole `State`.)
310        fetch: AsyncSourceFn<Value>,
311    },
312}
313
314/// Post-recognition validator: a recognized value is only promoted when this
315/// returns `true`.
316type FieldValidator = Arc<dyn Fn(&Value) -> bool + Send + Sync>;
317
318/// A field in an [`Extract`] record.
319#[derive(Clone)]
320pub struct Field {
321    name: String,
322    source: Source,
323    state_key: String,
324    overwrite: bool,
325    /// Optional predicate; a recognized value failing it is rejected (not promoted).
326    validate: Option<FieldValidator>,
327}
328
329/// A declarative extraction record: typed fields filled by recognizers and/or
330/// async resolvers.
331#[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    /// Start building a record with the given extractor name.
342    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    /// Compile into a [`TurnExtractor`] for registration.
353    pub fn into_extractor(self) -> Arc<dyn TurnExtractor> {
354        Arc::new(RecordExtractor::new(self))
355    }
356
357    /// The `(field name, state key)` pairs this record promotes. Callers that run
358    /// the extractor and promote the returned record into `State` themselves (e.g.
359    /// the simulation harness) use this to map each field to its state key.
360    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
368/// Builder for an [`Extract`] record.
369pub 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    /// Add a field filled by `recognizer`, promoted to a state key of the same name.
379    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    /// Add a field promoted to a custom state key.
391    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    /// Add a field filled by an **async resolver** — a tool call, HTTP fetch, or
407    /// MCP request. `args` names the `State` keys bound into the JSON object
408    /// passed to `fetch`; the returned value becomes the field. With a `ttl`,
409    /// results are memoized by `(field, canonical args)` for that duration.
410    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    /// Attach a validator to the **most recently added** field: a recognized
442    /// value is promoted only when `predicate` returns `true` (else it is
443    /// rejected, as if no value was recognized this turn).
444    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    /// Number of recent turns the recognizers read (default 3).
455    pub fn window(mut self, n: usize) -> Self {
456        self.window = n;
457        self
458    }
459    /// When the record runs (default `EveryTurn`).
460    pub fn trigger(mut self, trigger: ExtractionTrigger) -> Self {
461        self.trigger = trigger;
462        self
463    }
464    /// Run `agent` (in `mode`) when this record lands fields in state — the
465    /// `on_complete(dispatch(agent))` effect. Its result lands in `{name}:result`.
466    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    /// Finalize the record.
471    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
482/// A [`TurnExtractor`] that runs an [`Extract`] record's recognizers and
483/// resolvers, and promotes the recognized fields into state.
484pub struct RecordExtractor {
485    spec: Extract,
486    promotions: Vec<FieldPromotion>,
487    /// Per-field resolver cache keyed by `(field, canonical args)`.
488    cache: Arc<DashMap<String, (Value, Instant)>>,
489}
490
491impl RecordExtractor {
492    /// Build from a record spec.
493    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    /// Resolve one async field, honoring the per-field TTL cache.
514    ///
515    /// Args bind from `fresh` (values recognized in *this* turn, keyed by their
516    /// state key) first, then from session `State` — so a resolver sees a slot
517    /// recognized in the same utterance, not a stale value.
518    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        // Bind args, preferring this turn's recognitions (skip absent keys).
528        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        // Recognizer-only path (no State): used by callers that don't bind args.
582        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; // recognized but rejected by the slot validator
594                }
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        // Sync recognizers over the transcript. `fresh` maps each recognized
613        // field's *state key* to its value, so resolvers can bind args from
614        // values recognized in this same turn (before promotion runs).
615        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; // recognized but rejected by the slot validator
622                }
623                // Record provenance + confidence under the `state_meta:` convention
624                // so `State::evidence()` can surface how a slot was filled.
625                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        // Async resolvers, bound from this turn's recognitions + State.
634        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        // Negation beats an affirmative substring.
713        assert_eq!(r.recognize("not correct").unwrap().0, json!(false));
714        assert_eq!(r.recognize("don't confirm that").unwrap().0, json!(false));
715        // "incorrect" must not match "correct"; it's a negation.
716        assert_eq!(r.recognize("that's incorrect").unwrap().0, json!(false));
717        // Word-aware: "another" does not contain a standalone "no".
718        assert!(r.recognize("another option").is_none());
719        // Plain affirmation still works.
720        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        // `slot` is recognized this turn (not yet in State); the resolver must
726        // bind it from the same utterance, not see it missing.
727        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(); // slot is NOT in State yet
735        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        // 12-hour clock with pm.
747        assert_eq!(
748            r.recognize("can we meet at 3pm").unwrap().0,
749            json!({"time": "15:00"})
750        );
751        // Minutes + am, plus a relative day.
752        assert_eq!(
753            r.recognize("tomorrow at 9:30 am works").unwrap().0,
754            json!({"time": "09:30", "day": "tomorrow"})
755        );
756        // 12am normalizes to midnight.
757        assert_eq!(
758            r.recognize("12am sharp").unwrap().0,
759            json!({"time": "00:00"})
760        );
761        // 24-hour clock + weekday + part of day + ISO date.
762        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        // A bare integer is not a time.
769        assert!(r.recognize("a table for 4 people").is_none());
770        // "13pm" is not a real clock time.
771        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()); // no anchor word
779    }
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        // Identical args within the TTL → fetched once.
847        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}