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    #[allow(
260        clippy::new_ret_no_self,
261        reason = "ContextBuilder::new() is the builder entry point; it opens the first SectionBuilder"
262    )]
263    pub fn new() -> SectionBuilder {
264        SectionBuilder {
265            label: String::new(),
266            fields: Vec::new(),
267            parent_sections: Vec::new(),
268        }
269    }
270
271    /// Render the context from the given state.
272    ///
273    /// Returns an empty string when no state has been gathered (no noise).
274    pub fn render(&self, state: &State) -> String {
275        let mut lines: Vec<String> = self
276            .sections
277            .iter()
278            .filter_map(|s| s.render(state))
279            .collect();
280
281        // Phase-aware "Gathering" context from phase needs metadata.
282        // The processor stores the current phase's needs in "session:phase_needs"
283        // as a JSON array of strings.
284        if let Some(needs) = state.get::<Vec<String>>("session:phase_needs") {
285            let missing: Vec<&str> = needs
286                .iter()
287                .filter(|key| !state.contains(key))
288                .map(|s| s.as_str())
289                .collect();
290            if !missing.is_empty() {
291                lines.push(format!("[Gathering] {}", missing.join(", ")));
292            }
293        }
294
295        lines.join("\n")
296    }
297
298    /// Convert into an [`super::InstructionModifier`] for use with phase modifiers.
299    pub fn into_modifier(self) -> super::InstructionModifier {
300        super::InstructionModifier::CustomAppend(Arc::new(move |state: &State| self.render(state)))
301    }
302}
303
304// ── Compose with + ─────────────────────────────────────────────────────────
305
306impl std::ops::Add for ContextBuilder {
307    type Output = ContextBuilder;
308
309    fn add(mut self, rhs: ContextBuilder) -> Self::Output {
310        self.sections.extend(rhs.sections);
311        self
312    }
313}
314
315// ── Tests ──────────────────────────────────────────────────────────────────
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::state::State;
321
322    #[test]
323    fn empty_state_returns_empty_string() {
324        let ctx = ContextBuilder::new()
325            .section("Caller")
326            .field("name", "Name")
327            .build();
328
329        let state = State::new();
330        assert_eq!(ctx.render(&state), "");
331    }
332
333    #[test]
334    fn renders_populated_fields() {
335        let ctx = ContextBuilder::new()
336            .section("Caller")
337            .field("name", "Name")
338            .field("org", "Organization")
339            .build();
340
341        let state = State::new();
342        let _ = state.set("name", "Bob");
343        let _ = state.set("org", "Google");
344
345        assert_eq!(
346            ctx.render(&state),
347            "[Caller] Name: Bob. Organization: Google."
348        );
349    }
350
351    #[test]
352    fn skips_missing_fields() {
353        let ctx = ContextBuilder::new()
354            .section("Caller")
355            .field("name", "Name")
356            .field("org", "Organization")
357            .build();
358
359        let state = State::new();
360        let _ = state.set("name", "Bob");
361
362        assert_eq!(ctx.render(&state), "[Caller] Name: Bob.");
363    }
364
365    #[test]
366    fn flag_renders_when_true() {
367        let ctx = ContextBuilder::new()
368            .section("Status")
369            .flag("verified", "Identity verified")
370            .build();
371
372        let state = State::new();
373        let _ = state.set("verified", true);
374
375        assert_eq!(ctx.render(&state), "[Status] Identity verified.");
376    }
377
378    #[test]
379    fn flag_omitted_when_false() {
380        let ctx = ContextBuilder::new()
381            .section("Status")
382            .flag("verified", "Identity verified")
383            .build();
384
385        let state = State::new();
386        let _ = state.set("verified", false);
387
388        assert_eq!(ctx.render(&state), "");
389    }
390
391    #[test]
392    fn sentiment_renders_non_neutral() {
393        let ctx = ContextBuilder::new()
394            .section("Mood")
395            .sentiment("sentiment")
396            .build();
397
398        let state = State::new();
399        let _ = state.set("sentiment", "impatient");
400
401        assert_eq!(ctx.render(&state), "[Mood] Caller seems impatient.");
402    }
403
404    #[test]
405    fn sentiment_skips_neutral() {
406        let ctx = ContextBuilder::new()
407            .section("Mood")
408            .sentiment("sentiment")
409            .build();
410
411        let state = State::new();
412        let _ = state.set("sentiment", "neutral");
413
414        assert_eq!(ctx.render(&state), "");
415    }
416
417    #[test]
418    fn custom_format() {
419        let ctx = ContextBuilder::new()
420            .section("Call")
421            .format("urgency", "Urgency", |v| {
422                let u = v.as_f64().unwrap_or(0.0);
423                if u > 0.7 {
424                    format!("high ({u:.1})")
425                } else {
426                    String::new()
427                }
428            })
429            .build();
430
431        let state = State::new();
432        let _ = state.set("urgency", 0.9_f64);
433
434        assert_eq!(ctx.render(&state), "[Call] high (0.9)");
435    }
436
437    #[test]
438    fn multiple_sections() {
439        let ctx = ContextBuilder::new()
440            .section("A")
441            .field("x", "X")
442            .section("B")
443            .field("y", "Y")
444            .build();
445
446        let state = State::new();
447        let _ = state.set("x", "1");
448        let _ = state.set("y", "2");
449
450        assert_eq!(ctx.render(&state), "[A] X: 1.\n[B] Y: 2.");
451    }
452
453    #[test]
454    fn empty_section_omitted() {
455        let ctx = ContextBuilder::new()
456            .section("Empty")
457            .field("missing", "Missing")
458            .section("Present")
459            .field("exists", "Exists")
460            .build();
461
462        let state = State::new();
463        let _ = state.set("exists", "yes");
464
465        assert_eq!(ctx.render(&state), "[Present] Exists: yes.");
466    }
467
468    #[test]
469    fn compose_with_add() {
470        let a = ContextBuilder::new().section("A").field("x", "X").build();
471
472        let b = ContextBuilder::new().section("B").field("y", "Y").build();
473
474        let combined = a + b;
475
476        let state = State::new();
477        let _ = state.set("x", "1");
478        let _ = state.set("y", "2");
479
480        assert_eq!(combined.render(&state), "[A] X: 1.\n[B] Y: 2.");
481    }
482
483    #[test]
484    fn phase_needs_shows_gathering() {
485        let ctx = ContextBuilder::new()
486            .section("Caller")
487            .field("name", "Name")
488            .build();
489
490        let state = State::new();
491        let _ = state.set("name", "Bob");
492        let _ = state.set(
493            "session:phase_needs",
494            vec!["name".to_string(), "org".to_string()],
495        );
496
497        let rendered = ctx.render(&state);
498        assert!(rendered.contains("[Caller] Name: Bob."));
499        assert!(rendered.contains("[Gathering] org"));
500    }
501
502    #[test]
503    fn phase_needs_disappears_when_all_gathered() {
504        let ctx = ContextBuilder::new()
505            .section("Caller")
506            .field("name", "Name")
507            .field("org", "Org")
508            .build();
509
510        let state = State::new();
511        let _ = state.set("name", "Bob");
512        let _ = state.set("org", "Google");
513        let _ = state.set(
514            "session:phase_needs",
515            vec!["name".to_string(), "org".to_string()],
516        );
517
518        let rendered = ctx.render(&state);
519        assert!(!rendered.contains("[Gathering]"));
520    }
521}