Skip to main content

gemini_adk_rs/live/
extractor.rs

1//! Turn-windowed extraction — OOB LLM structured data extraction between turns.
2//!
3//! A `TurnExtractor` runs after each turn completes, taking a window of recent
4//! transcript turns and producing a structured JSON value via an out-of-band
5//! LLM call.
6
7use 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/// Controls WHEN an extractor runs.
19///
20/// The default is `EveryTurn`. Use `AfterToolCall` when tool calls are the primary state source,
21/// `Interval(n)` to reduce extraction frequency, or `OnPhaseChange`
22/// to extract only when entering a new conversation phase.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum ExtractionTrigger {
25    /// Run on every TurnComplete event (current default).
26    EveryTurn,
27    /// Run every N TurnComplete events.
28    Interval(u32),
29    /// Run after tool calls complete.
30    AfterToolCall,
31    /// Run when a phase transition occurs.
32    OnPhaseChange,
33    /// Run on GenerationComplete, as soon as the model has generated its
34    /// response and before the turn completes. Current Live models send no
35    /// GenerationComplete for an interrupted turn.
36    OnGenerationComplete,
37}
38
39/// How an extracted field should be merged into authoritative session state.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum MergePolicy {
42    /// Keep an existing state value; write only when the target key is absent.
43    KeepKnown,
44    /// Always overwrite the target state key with the extracted field value.
45    Overwrite,
46}
47
48/// Predicate used to decide whether an extracted field may be promoted.
49pub type PromotionPredicate = Arc<dyn Fn(&State, &Value) -> bool + Send + Sync>;
50
51/// Rule for promoting one raw extraction field into authoritative state.
52#[derive(Clone)]
53pub struct FieldPromotion {
54    /// Field name inside the extractor's JSON object.
55    pub field: String,
56    /// State key to write when the field is accepted.
57    pub state_key: String,
58    /// Merge behavior for the target state key.
59    pub merge: MergePolicy,
60    /// Optional acceptance predicate.
61    pub accept: Option<PromotionPredicate>,
62}
63
64impl FieldPromotion {
65    /// Promote `field` into the same state key using [`MergePolicy::KeepKnown`].
66    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    /// Promote `field` into the same state key using [`MergePolicy::Overwrite`].
77    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    /// Promote a boolean field only when its extracted value is `true`.
88    pub fn true_only(field: impl Into<String>) -> Self {
89        Self::overwrite(field).accept_when(|_, value| value.as_bool() == Some(true))
90    }
91
92    /// Promote a string field only when its extracted value is non-empty.
93    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    /// Promote into a custom target state key.
99    pub fn to(mut self, state_key: impl Into<String>) -> Self {
100        self.state_key = state_key.into();
101        self
102    }
103
104    /// Only accept this promotion when `predicate` returns true.
105    ///
106    /// This is the escape hatch for application-specific logic:
107    /// `FieldPromotion::overwrite("intent").accept_when(|state, value| ...)`.
108    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    /// Add an additional acceptance predicate, preserving any existing predicate.
117    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    /// Only promote after the named concept has been presented by a phase.
129    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
135/// Whether the newest turn of `window` has no caller words while an earlier
136/// one does: a turn the caller said nothing in, with input transcription on.
137/// Extracting it cannot find anything new from the caller.
138pub(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
143/// Strip markdown code fences from LLM output.
144///
145/// Handles `` ```json\n...\n``` ``, `` ```\n...\n``` ``, and bare JSON.
146fn strip_code_fences(text: &str) -> &str {
147    let trimmed = text.trim();
148    if let Some(rest) = trimmed.strip_prefix("```") {
149        // Skip optional language tag (e.g., "json") on the first line
150        let rest = rest.trim_start_matches(|c: char| c != '\n');
151        let rest = rest.strip_prefix('\n').unwrap_or(rest);
152        // Strip trailing ```
153        let rest = rest.trim_end();
154        rest.strip_suffix("```").unwrap_or(rest).trim()
155    } else {
156        trimmed
157    }
158}
159
160/// Trait for between-turn extraction from transcript windows.
161///
162/// Implementations receive a window of recent transcript turns and produce
163/// a structured JSON value. The processor stores the result in `State`
164/// under the extractor's name.
165#[async_trait]
166pub trait TurnExtractor: Send + Sync {
167    /// Name of this extractor (used as the State key).
168    fn name(&self) -> &str;
169
170    /// How many recent turns this extractor needs.
171    fn window_size(&self) -> usize;
172
173    /// Whether this extractor should run for the current turn.
174    ///
175    /// Override to skip extraction on trivial turns (e.g., short utterances,
176    /// turns without user speech). Default returns `true` (always extract).
177    ///
178    /// This is checked before launching the async extraction, so returning
179    /// `false` avoids an LLM round-trip entirely.
180    fn should_extract(&self, window: &[TranscriptTurn]) -> bool {
181        let _ = window;
182        true
183    }
184
185    /// The trigger mode for this extractor.
186    ///
187    /// Controls when the extractor runs. Default is `EveryTurn`.
188    fn trigger(&self) -> ExtractionTrigger {
189        ExtractionTrigger::EveryTurn
190    }
191
192    /// Field promotion rules for this extractor.
193    ///
194    /// When empty, the runtime auto-flattens every top-level non-null field of
195    /// the extraction into state under the field's own name. When non-empty,
196    /// only these rules can promote raw extraction fields into authoritative
197    /// state.
198    fn promotion_rules(&self) -> &[FieldPromotion] {
199        &[]
200    }
201
202    /// Whether this extractor may write the state key `key`.
203    ///
204    /// The tool lane asks this to pick the extractors to run early when a
205    /// guard that reads `key` refuses a tool. The default: a promotion rule
206    /// targets `key`, or there are no rules, in which case every field is
207    /// promoted under its own name and any key may be written.
208    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    /// Extract structured data from the transcript window.
214    async fn extract(&self, window: &[TranscriptTurn]) -> Result<Value, LlmError>;
215
216    /// Extract with access to session `State` — for extractors whose sources
217    /// bind arguments from `State` (e.g. async fetch/agent resolvers).
218    ///
219    /// The default delegates to [`extract`](Self::extract), so transcript-only
220    /// extractors need not implement it. The pipeline always calls this method.
221    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    /// An optional agent to run when this extractor's results land in state —
231    /// the `on_complete(dispatch(agent))` effect. Fired by the pipeline after
232    /// promotion, only when the extractor produced a non-empty object.
233    fn on_complete(&self) -> Option<OnComplete> {
234        None
235    }
236}
237
238/// A state mutation or promotion decision produced by the shared extraction reducer.
239#[derive(Clone, Debug)]
240pub enum PromotionEvent {
241    /// A field was promoted and can be observed under this extraction event name.
242    Promoted {
243        /// Event name.
244        name: String,
245        /// Promoted value.
246        value: Value,
247    },
248    /// An explicit promotion rule accepted or rejected its field.
249    Decision {
250        /// Source field.
251        field: String,
252        /// Destination key.
253        state_key: String,
254        /// Whether the value was accepted.
255        accepted: bool,
256        /// Stable explanatory reason.
257        reason: String,
258        /// Observed value.
259        value: Value,
260    },
261}
262/// Apply the same null, merge and acceptance rules for Live and owned task extraction.
263pub 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            // Re-stating a known value in other words is not news; writing it
298            // would only churn the journal and could read as a correction.
299            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/// A downstream agent fired when an extractor's results land in state.
334#[derive(Clone)]
335pub struct OnComplete {
336    /// The agent to run; it reads its inputs from `State`.
337    pub agent: Arc<dyn crate::text::TextAgent>,
338    /// How to run it (`Call` awaits inline; `Dispatch`/`Background` detached).
339    pub mode: crate::orchestration::AgentMode,
340}
341
342/// Appended to every extraction prompt: a field the transcript does not
343/// state is null, never a guess.
344const 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
347/// Appended when some fields are already in state: re-reading the window
348/// every turn, the model would otherwise re-state known values in new words,
349/// and each re-statement would read as a correction. The latest turn is also
350/// quoted after the transcript: told only to compare with "the latest turn",
351/// `gemini-3.5-flash-lite` without thinking re-stated a known slot in 17 of
352/// 20 turns that changed nothing (7 in other words, read as corrections);
353/// with the turn quoted, in none of 30, and it still caught a real change in
354/// 30 of 30.
355const 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
360/// The schema with every top-level property allowed to be null, so the
361/// model can say "not stated" instead of inventing a value of the right type.
362fn 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
394/// LLM-backed turn extractor that sends transcript windows to an OOB LLM
395/// with a structured extraction prompt.
396pub struct LlmExtractor {
397    name: String,
398    llm: Arc<dyn BaseLlm>,
399    prompt: String,
400    window_size: usize,
401    schema: Option<Value>,
402    /// Pre-rendered schema string (computed once at construction)
403    schema_str: Option<String>,
404    /// Minimum word count in the last user utterance to trigger extraction.
405    min_words: usize,
406    /// When this extractor should fire.
407    trigger: ExtractionTrigger,
408    /// Field promotion rules. Empty means every top-level non-null field is
409    /// auto-flattened into state under its own name.
410    promotion_rules: Vec<FieldPromotion>,
411    /// Thinking budget sent with each request; `None` leaves the model's
412    /// default.
413    thinking_budget: Option<u32>,
414    /// How far down [`FALLBACK_THINKING_BUDGET`] the model has pushed the
415    /// budget: 0 sends `thinking_budget`, 1 the fallback, 2 none. Kept so a
416    /// rejected budget is not sent again.
417    budget_step: std::sync::atomic::AtomicU8,
418}
419
420/// The budget sent when a model rejects a thinking budget of 0, such as
421/// `gemini-3.5-flash-lite` ("Request contains an invalid argument"). That
422/// model accepts budgets from 1, and below about 1024 it does not think at
423/// all, so this keeps thinking off at the smallest budget it accepts.
424pub const FALLBACK_THINKING_BUDGET: u32 = 64;
425
426impl LlmExtractor {
427    /// Create a new LLM-backed extractor.
428    ///
429    /// - `name`: key for storing results in State
430    /// - `llm`: the out-of-band LLM to use for extraction
431    /// - `prompt`: system instruction describing what to extract
432    /// - `window_size`: how many recent turns to include
433    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    /// Set the thinking budget sent with each extraction request.
455    ///
456    /// The default is `Some(0)`: extraction reads a few turns, and the turn
457    /// pipeline waits for it, so thinking costs seconds of latency on every
458    /// turn. Measured on `gemini-flash-latest`, a budget of 0 cut a short
459    /// extraction from 7.7 s to 2.3 s with the same results. `None` leaves
460    /// the model's default. When a model rejects a budget of 0 (some only
461    /// work in thinking mode, and `gemini-3.5-flash-lite` needs at least 1),
462    /// the extractor retries with [`FALLBACK_THINKING_BUDGET`], then without
463    /// a budget, and keeps sending the first one the model accepted.
464    pub fn with_thinking_budget(mut self, budget: Option<u32>) -> Self {
465        self.thinking_budget = budget;
466        self
467    }
468
469    /// Set the minimum word count in the last user utterance to trigger extraction.
470    ///
471    /// Turns where the user said fewer than `n` words will skip the LLM call.
472    /// Useful for filtering out "uh huh", "ok", "yes" style responses.
473    pub fn with_min_words(mut self, n: usize) -> Self {
474        self.min_words = n;
475        self
476    }
477
478    /// Set a JSON Schema for structured output.
479    ///
480    /// When set, the schema is included in the prompt to guide the LLM
481    /// toward producing valid JSON matching the schema.
482    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    /// Set the trigger mode for this extractor.
489    pub fn with_trigger(mut self, trigger: ExtractionTrigger) -> Self {
490        self.trigger = trigger;
491        self
492    }
493
494    /// Set explicit field promotion rules.
495    ///
496    /// Once promotion rules are present, top-level fields are no longer
497    /// automatically flattened into state; only accepted rules promote.
498    pub fn with_promotions(mut self, rules: Vec<FieldPromotion>) -> Self {
499        self.promotion_rules = rules;
500        self
501    }
502
503    /// The values already in state for this extractor's fields, keyed by
504    /// field name: the promotion targets when there are rules, otherwise the
505    /// schema's properties.
506    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        // Use native JSON mode when a schema is available — the API constrains
561        // the model to produce valid JSON matching the schema, eliminating
562        // markdown fences and malformed output. Every field may be null:
563        // constrained to a bare type, the model fills fields the transcript
564        // never mentions with placeholders ("unknown", "", 0).
565        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        // Fallback: strip markdown code fences if the model still wraps output
576        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    /// Send `request` with the thinking budget, falling back without it if
586    /// the model rejects it, and retrying once on a transient error: a lost
587    /// extraction loses what the caller said in that turn.
588    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    /// Format transcript turns for the LLM prompt.
624    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        // A turn the caller said nothing in, such as the model speaking a
655        // tool's result, holds no new caller information, and the turn
656        // pipeline waits for every extraction: in live runs each one held up
657        // the next tool call by seconds. Only when the window shows the
658        // caller's words are transcribed at all, so a session without input
659        // transcription still extracts.
660        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        // Check the last user utterance
667        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        // Without rules every top-level field is promoted under its own
684        // name; the schema says which fields there are.
685        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    /// Extract, telling the model which fields are already known so it
702    /// returns only what the latest turn adds or changes.
703    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        // A schema without properties is left alone.
868        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        // Nothing known yet: no preamble and no known-field guidance.
944        extractor.extract_with_state(&window, &state).await.unwrap();
945        // Known values are listed by field name, read from their state keys.
946        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        // A different value is still promoted.
1012        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    /// Answers with each scripted result in turn and records every request.
1051    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        // A configured budget the model rejects goes straight to none when it
1154        // is the fallback already.
1155        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        // The model speaks a tool's result: nothing new from the caller.
1219        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        // No caller words anywhere: transcription may be off, so extract.
1228        assert!(extractor.should_extract(&make_turns(&[("", "Hello.")])));
1229        // Extractors that read the model's own words still run.
1230        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}