gemini_adk_rs/live/
context_builder.rs

1//! Declarative state-to-narrative context builder.
2//!
3//! A [`ContextBuilder`] renders session [`State`] into a natural-language summary
4//! that gets appended to the phase instruction via [`super::InstructionModifier::CustomAppend`].
5//! It replaces hand-written `fn app_context(s: &State) -> String` closures with a
6//! declarative, composable API.
7//!
8//! # How it works
9//!
10//! 1. Declare **sections** — named groups of related state keys
11//! 2. Each section has **fields** — state keys with display labels and render modes
12//! 3. The builder checks each key in state, skips missing values, formats present ones
13//! 4. If the current phase has [`needs`](super::Phase::needs) metadata, appends a
14//!    "Gathering:" line for missing keys so the model knows what to focus on
15//!
16//! # Output format
17//!
18//! ```text
19//! [Caller] Name: Bob. Organization: Google. Known contact.
20//! [Call] Purpose: schedule meeting. Urgency: high (0.8).
21//! [Gathering] caller_organization
22//! ```
23//!
24//! Empty sections are omitted. When no state has been gathered, returns an empty
25//! string (no noise in the instruction).
26//!
27//! # Example
28//!
29//! ```ignore
30//! use gemini_adk_rs::live::context_builder::ContextBuilder;
31//!
32//! let ctx = ContextBuilder::new()
33//!     .section("Caller")
34//!         .field("caller_name", "Name")
35//!         .field("caller_organization", "Organization")
36//!         .flag("is_known_contact", "Known contact")
37//!     .section("Call")
38//!         .field("call_purpose", "Purpose")
39//!         .sentiment("caller_sentiment")
40//!     .build();
41//!
42//! // Use with phase_defaults:
43//! // .phase_defaults(|d| d.with_context(ctx))
44//! ```
45
46use std::sync::Arc;
47
48use serde_json::Value;
49
50use crate::state::State;
51
52// ── Field rendering modes ──────────────────────────────────────────────────
53
54/// How a field renders its value from state.
55#[derive(Clone)]
56enum FieldKind {
57    /// Render as "Label: {value}."
58    Value,
59    /// Bool flag — show label when true, omit when false.
60    Flag,
61    /// Sentiment — renders as "Caller seems {value}." when non-neutral.
62    Sentiment,
63    /// Custom formatter — receives the raw JSON value.
64    Format(Arc<dyn Fn(&Value) -> String + Send + Sync>),
65}
66
67/// A single state key with its display label and render mode.
68#[derive(Clone)]
69struct Field {
70    key: String,
71    label: String,
72    kind: FieldKind,
73}
74
75impl Field {
76    fn render(&self, state: &State) -> Option<String> {
77        let val: Option<Value> = state.get(&self.key);
78        match &self.kind {
79            FieldKind::Value => {
80                let val = val?;
81                match &val {
82                    Value::String(s) if s.is_empty() => None,
83                    Value::String(s) => Some(format!("{}: {s}.", self.label)),
84                    Value::Number(n) => Some(format!("{}: {n}.", self.label)),
85                    Value::Bool(b) => Some(format!("{}: {b}.", self.label)),
86                    Value::Null => None,
87                    other => Some(format!("{}: {other}.", self.label)),
88                }
89            }
90            FieldKind::Flag => {
91                let val = val?;
92                if val.as_bool().unwrap_or(false) {
93                    Some(format!("{}.", self.label))
94                } else {
95                    None
96                }
97            }
98            FieldKind::Sentiment => {
99                let val = val?;
100                let s = val.as_str()?;
101                if s.is_empty() || s == "neutral" || s == "unknown" {
102                    None
103                } else {
104                    Some(format!("Caller seems {s}."))
105                }
106            }
107            FieldKind::Format(f) => {
108                let val = val?;
109                let rendered = f(&val);
110                if rendered.is_empty() {
111                    None
112                } else {
113                    Some(rendered)
114                }
115            }
116        }
117    }
118}
119
120// ── Section ────────────────────────────────────────────────────────────────
121
122/// A named group of related state fields.
123#[derive(Clone)]
124struct Section {
125    label: String,
126    fields: Vec<Field>,
127}
128
129impl Section {
130    fn render(&self, state: &State) -> Option<String> {
131        let parts: Vec<String> = self.fields.iter().filter_map(|f| f.render(state)).collect();
132        if parts.is_empty() {
133            None
134        } else {
135            Some(format!("[{}] {}", self.label, parts.join(" ")))
136        }
137    }
138}
139
140// ── SectionBuilder ─────────────────────────────────────────────────────────
141
142/// Fluent builder for a single section.
143pub struct SectionBuilder {
144    label: String,
145    fields: Vec<Field>,
146    parent_sections: Vec<Section>,
147}
148
149impl SectionBuilder {
150    /// Add a value field — renders as "Label: {value}." when the key exists.
151    pub fn field(mut self, key: &str, label: &str) -> Self {
152        self.fields.push(Field {
153            key: key.into(),
154            label: label.into(),
155            kind: FieldKind::Value,
156        });
157        self
158    }
159
160    /// Add a bool flag — renders "Label." when true, omitted when false/missing.
161    pub fn flag(mut self, key: &str, label: &str) -> Self {
162        self.fields.push(Field {
163            key: key.into(),
164            label: label.into(),
165            kind: FieldKind::Flag,
166        });
167        self
168    }
169
170    /// Add a sentiment field — renders "Caller seems {value}." when non-neutral.
171    pub fn sentiment(mut self, key: &str) -> Self {
172        self.fields.push(Field {
173            key: key.into(),
174            label: "sentiment".into(),
175            kind: FieldKind::Sentiment,
176        });
177        self
178    }
179
180    /// Add a field with a custom formatter.
181    ///
182    /// The formatter receives the raw JSON value and returns a string.
183    /// Return an empty string to skip the field.
184    pub fn format(
185        mut self,
186        key: &str,
187        label: &str,
188        f: impl Fn(&Value) -> String + Send + Sync + 'static,
189    ) -> Self {
190        self.fields.push(Field {
191            key: key.into(),
192            label: label.into(),
193            kind: FieldKind::Format(Arc::new(f)),
194        });
195        self
196    }
197
198    /// Start a new section (finalizes the current one).
199    pub fn section(mut self, label: &str) -> SectionBuilder {
200        // Finalize current section
201        if !self.fields.is_empty() {
202            self.parent_sections.push(Section {
203                label: self.label,
204                fields: self.fields,
205            });
206        }
207        SectionBuilder {
208            label: label.into(),
209            fields: Vec::new(),
210            parent_sections: self.parent_sections,
211        }
212    }
213
214    /// Build the final [`ContextBuilder`].
215    pub fn build(mut self) -> ContextBuilder {
216        // Finalize last section
217        if !self.fields.is_empty() {
218            self.parent_sections.push(Section {
219                label: self.label,
220                fields: self.fields,
221            });
222        }
223        ContextBuilder {
224            sections: self.parent_sections,
225        }
226    }
227}
228
229// ── ContextBuilder ─────────────────────────────────────────────────────────
230
231/// Declarative state-to-narrative renderer.
232///
233/// Renders session state into a natural-language summary for the model's
234/// instruction. Sections group related keys; missing values are auto-skipped.
235///
236/// When the current phase has `needs` metadata (set via `.needs()` on the
237/// phase builder), appends a "Gathering:" line for keys that are still missing,
238/// so the model knows what to focus on in the current phase.
239///
240/// Use directly with `with_context()` on phase defaults or individual phases.
241///
242/// # Example
243///
244/// ```ignore
245/// .phase_defaults(|d| d.with_context(
246///     ContextBuilder::new()
247///         .section("Caller")
248///         .field("name", "Name")
249///         .build()
250/// ))
251/// ```
252#[derive(Clone, Default)]
253pub struct ContextBuilder {
254    sections: Vec<Section>,
255}
256
257impl ContextBuilder {
258    /// Start building a new context with the first section.
259    pub fn new() -> SectionBuilder {
260        SectionBuilder {
261            label: String::new(),
262            fields: Vec::new(),
263            parent_sections: Vec::new(),
264        }
265    }
266
267    /// Render the context from the given state.
268    ///
269    /// Returns an empty string when no state has been gathered (no noise).
270    pub fn render(&self, state: &State) -> String {
271        let mut lines: Vec<String> = self
272            .sections
273            .iter()
274            .filter_map(|s| s.render(state))
275            .collect();
276
277        // Phase-aware "Gathering" context from phase needs metadata.
278        // The processor stores the current phase's needs in "session:phase_needs"
279        // as a JSON array of strings.
280        if let Some(needs) = state.get::<Vec<String>>("session:phase_needs") {
281            let missing: Vec<&str> = needs
282                .iter()
283                .filter(|key| !state.contains(key))
284                .map(|s| s.as_str())
285                .collect();
286            if !missing.is_empty() {
287                lines.push(format!("[Gathering] {}", missing.join(", ")));
288            }
289        }
290
291        lines.join("\n")
292    }
293
294    /// Convert into an [`super::InstructionModifier`] for use with phase modifiers.
295    pub fn into_modifier(self) -> super::InstructionModifier {
296        super::InstructionModifier::CustomAppend(Arc::new(move |state: &State| self.render(state)))
297    }
298}
299
300// ── Compose with + ─────────────────────────────────────────────────────────
301
302impl std::ops::Add for ContextBuilder {
303    type Output = ContextBuilder;
304
305    fn add(mut self, rhs: ContextBuilder) -> Self::Output {
306        self.sections.extend(rhs.sections);
307        self
308    }
309}
310
311// ── Tests ──────────────────────────────────────────────────────────────────
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316    use crate::state::State;
317
318    #[test]
319    fn empty_state_returns_empty_string() {
320        let ctx = ContextBuilder::new()
321            .section("Caller")
322            .field("name", "Name")
323            .build();
324
325        let state = State::new();
326        assert_eq!(ctx.render(&state), "");
327    }
328
329    #[test]
330    fn renders_populated_fields() {
331        let ctx = ContextBuilder::new()
332            .section("Caller")
333            .field("name", "Name")
334            .field("org", "Organization")
335            .build();
336
337        let state = State::new();
338        state.set("name", "Bob");
339        state.set("org", "Google");
340
341        assert_eq!(
342            ctx.render(&state),
343            "[Caller] Name: Bob. Organization: Google."
344        );
345    }
346
347    #[test]
348    fn skips_missing_fields() {
349        let ctx = ContextBuilder::new()
350            .section("Caller")
351            .field("name", "Name")
352            .field("org", "Organization")
353            .build();
354
355        let state = State::new();
356        state.set("name", "Bob");
357
358        assert_eq!(ctx.render(&state), "[Caller] Name: Bob.");
359    }
360
361    #[test]
362    fn flag_renders_when_true() {
363        let ctx = ContextBuilder::new()
364            .section("Status")
365            .flag("verified", "Identity verified")
366            .build();
367
368        let state = State::new();
369        state.set("verified", true);
370
371        assert_eq!(ctx.render(&state), "[Status] Identity verified.");
372    }
373
374    #[test]
375    fn flag_omitted_when_false() {
376        let ctx = ContextBuilder::new()
377            .section("Status")
378            .flag("verified", "Identity verified")
379            .build();
380
381        let state = State::new();
382        state.set("verified", false);
383
384        assert_eq!(ctx.render(&state), "");
385    }
386
387    #[test]
388    fn sentiment_renders_non_neutral() {
389        let ctx = ContextBuilder::new()
390            .section("Mood")
391            .sentiment("sentiment")
392            .build();
393
394        let state = State::new();
395        state.set("sentiment", "impatient");
396
397        assert_eq!(ctx.render(&state), "[Mood] Caller seems impatient.");
398    }
399
400    #[test]
401    fn sentiment_skips_neutral() {
402        let ctx = ContextBuilder::new()
403            .section("Mood")
404            .sentiment("sentiment")
405            .build();
406
407        let state = State::new();
408        state.set("sentiment", "neutral");
409
410        assert_eq!(ctx.render(&state), "");
411    }
412
413    #[test]
414    fn custom_format() {
415        let ctx = ContextBuilder::new()
416            .section("Call")
417            .format("urgency", "Urgency", |v| {
418                let u = v.as_f64().unwrap_or(0.0);
419                if u > 0.7 {
420                    format!("high ({u:.1})")
421                } else {
422                    String::new()
423                }
424            })
425            .build();
426
427        let state = State::new();
428        state.set("urgency", 0.9_f64);
429
430        assert_eq!(ctx.render(&state), "[Call] high (0.9)");
431    }
432
433    #[test]
434    fn multiple_sections() {
435        let ctx = ContextBuilder::new()
436            .section("A")
437            .field("x", "X")
438            .section("B")
439            .field("y", "Y")
440            .build();
441
442        let state = State::new();
443        state.set("x", "1");
444        state.set("y", "2");
445
446        assert_eq!(ctx.render(&state), "[A] X: 1.\n[B] Y: 2.");
447    }
448
449    #[test]
450    fn empty_section_omitted() {
451        let ctx = ContextBuilder::new()
452            .section("Empty")
453            .field("missing", "Missing")
454            .section("Present")
455            .field("exists", "Exists")
456            .build();
457
458        let state = State::new();
459        state.set("exists", "yes");
460
461        assert_eq!(ctx.render(&state), "[Present] Exists: yes.");
462    }
463
464    #[test]
465    fn compose_with_add() {
466        let a = ContextBuilder::new().section("A").field("x", "X").build();
467
468        let b = ContextBuilder::new().section("B").field("y", "Y").build();
469
470        let combined = a + b;
471
472        let state = State::new();
473        state.set("x", "1");
474        state.set("y", "2");
475
476        assert_eq!(combined.render(&state), "[A] X: 1.\n[B] Y: 2.");
477    }
478
479    #[test]
480    fn phase_needs_shows_gathering() {
481        let ctx = ContextBuilder::new()
482            .section("Caller")
483            .field("name", "Name")
484            .build();
485
486        let state = State::new();
487        state.set("name", "Bob");
488        state.set(
489            "session:phase_needs",
490            vec!["name".to_string(), "org".to_string()],
491        );
492
493        let rendered = ctx.render(&state);
494        assert!(rendered.contains("[Caller] Name: Bob."));
495        assert!(rendered.contains("[Gathering] org"));
496    }
497
498    #[test]
499    fn phase_needs_disappears_when_all_gathered() {
500        let ctx = ContextBuilder::new()
501            .section("Caller")
502            .field("name", "Name")
503            .field("org", "Org")
504            .build();
505
506        let state = State::new();
507        state.set("name", "Bob");
508        state.set("org", "Google");
509        state.set(
510            "session:phase_needs",
511            vec!["name".to_string(), "org".to_string()],
512        );
513
514        let rendered = ctx.render(&state);
515        assert!(!rendered.contains("[Gathering]"));
516    }
517}