gemini_adk_rs/
frame.rs

1//! Frames & slots — typed, first-class fields for conversation authoring.
2//!
3//! Voice authors think in *frames* (a `Booking`, a `PaymentFrame`), not bare
4//! state keys. A slot carries a prompt, reprompt, confirmation policy, and
5//! PII/redaction policy alongside its `State` key. `#[derive(Frame)]` generates
6//! the [`Frame`] impl from a struct's `#[slot(..)]` attributes; the conversation
7//! compiler consumes a frame's slots for `collect` completion, and the metadata
8//! drives confirmations and repair.
9//!
10//! ```ignore
11//! use gemini_adk_rs::Frame; // the derive
12//!
13//! #[derive(Frame)]
14//! #[frame(name = "booking")]
15//! struct Booking {
16//!     #[slot(prompt = "For how many people?", confirm = "low_confidence")]
17//!     party_size: u8,
18//!     #[slot(prompt = "What day and time?")]
19//!     slot: String,
20//!     #[slot(prompt = "Name for the reservation?", pii)]
21//!     name: String,
22//! }
23//!
24//! let spec = Booking::frame();
25//! assert_eq!(spec.slot_keys(), vec!["party_size", "slot", "name"]);
26//! ```
27
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use crate::extract::{Extract, Recognizer};
32
33/// A serializable validator applied to a recognized slot value; a value failing
34/// it is rejected (the slot stays unfilled until a valid value is recognized).
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum SlotValidator {
38    /// Numeric range with optional inclusive bounds (accepts numbers, or numeric
39    /// strings).
40    Range {
41        /// Inclusive lower bound.
42        #[serde(default, skip_serializing_if = "Option::is_none")]
43        min: Option<f64>,
44        /// Inclusive upper bound.
45        #[serde(default, skip_serializing_if = "Option::is_none")]
46        max: Option<f64>,
47    },
48    /// A non-empty (after trim) string.
49    NonEmpty,
50    /// A string matching this regex pattern.
51    Regex(String),
52    /// One of a fixed set (case-insensitive for strings).
53    OneOf(Vec<String>),
54}
55
56impl SlotValidator {
57    /// Whether `value` passes this validator.
58    pub fn check(&self, value: &Value) -> bool {
59        match self {
60            SlotValidator::Range { min, max } => {
61                let n = match value {
62                    Value::Number(n) => n.as_f64(),
63                    Value::String(s) => s.trim().parse::<f64>().ok(),
64                    _ => None,
65                };
66                match n {
67                    Some(n) => min.is_none_or(|lo| n >= lo) && max.is_none_or(|hi| n <= hi),
68                    None => false,
69                }
70            }
71            SlotValidator::NonEmpty => value.as_str().is_some_and(|s| !s.trim().is_empty()),
72            SlotValidator::Regex(pat) => regex::Regex::new(pat)
73                .ok()
74                .zip(value.as_str())
75                .is_some_and(|(re, s)| re.is_match(s)),
76            SlotValidator::OneOf(opts) => value
77                .as_str()
78                .is_some_and(|s| opts.iter().any(|o| o.eq_ignore_ascii_case(s))),
79        }
80    }
81}
82
83/// A serializable description of the deterministic recognizer that fills a slot.
84///
85/// Mirrors [`Recognizer`] but is serde-friendly (it holds patterns/options as
86/// data, not a compiled `Regex`), so a [`FrameSpec`] — and the conversation spec
87/// that embeds it — round-trips through JSON/YAML. Lower to a runtime recognizer
88/// with [`SlotRecognizer::to_recognizer`].
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum SlotRecognizer {
92    /// First integer in the text.
93    Integer,
94    /// First integer, but only when one of these anchor words is present.
95    IntegerNear(Vec<String>),
96    /// A monetary amount.
97    Money,
98    /// First capture (or whole match) of this regex pattern.
99    Regex(String),
100    /// The first of these options to appear (case-insensitive substring).
101    OneOf(Vec<String>),
102    /// The best Jaro-Winkler match among these options.
103    Fuzzy(Vec<String>),
104    /// Affirmative/negative → boolean.
105    YesNo,
106    /// A calendar/clock expression → a JSON object.
107    DateTime,
108}
109
110impl SlotRecognizer {
111    /// Lower to a runtime [`Recognizer`].
112    pub fn to_recognizer(&self) -> Recognizer {
113        match self {
114            SlotRecognizer::Integer => Recognizer::integer(),
115            SlotRecognizer::IntegerNear(anchors) => Recognizer::integer_near(anchors.clone()),
116            SlotRecognizer::Money => Recognizer::money(),
117            SlotRecognizer::Regex(pat) => Recognizer::regex(pat),
118            SlotRecognizer::OneOf(opts) => Recognizer::one_of(opts.clone()),
119            SlotRecognizer::Fuzzy(opts) => Recognizer::fuzzy(opts.clone()),
120            SlotRecognizer::YesNo => Recognizer::yes_no(),
121            SlotRecognizer::DateTime => Recognizer::datetime(),
122        }
123    }
124}
125
126/// When a slot's value should be confirmed back to the user before it is trusted.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum ConfirmPolicy {
130    /// Never explicitly confirm.
131    #[default]
132    Never,
133    /// Confirm only when the slot's evidence confidence is low.
134    LowConfidence,
135    /// Always confirm before trusting the value.
136    Always,
137}
138
139impl ConfirmPolicy {
140    /// Parse from an attribute string (`never`/`low_confidence`/`always`).
141    pub fn parse(s: &str) -> Option<Self> {
142        match s {
143            "never" => Some(ConfirmPolicy::Never),
144            "low_confidence" => Some(ConfirmPolicy::LowConfidence),
145            "always" => Some(ConfirmPolicy::Always),
146            _ => None,
147        }
148    }
149}
150
151/// Metadata for a single slot within a [`FrameSpec`].
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct SlotSpec {
154    /// The slot (field) name.
155    pub name: String,
156    /// The `State` key the slot is stored under (defaults to `name`).
157    pub state_key: String,
158    /// Prompt asked to elicit the slot.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub prompt: Option<String>,
161    /// Reprompt used after a failed/empty first attempt.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub reprompt: Option<String>,
164    /// When to confirm the slot's value.
165    #[serde(default, skip_serializing_if = "is_default_confirm")]
166    pub confirm: ConfirmPolicy,
167    /// Whether the slot holds PII (redact in logs/transcripts).
168    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
169    pub pii: bool,
170    /// The deterministic recognizer that fills this slot, if any.
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub recognizer: Option<SlotRecognizer>,
173    /// A validator applied to recognized values; invalid values are rejected.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub validate: Option<SlotValidator>,
176}
177
178fn is_default_confirm(c: &ConfirmPolicy) -> bool {
179    *c == ConfirmPolicy::Never
180}
181
182impl SlotSpec {
183    /// A bare slot with name == state key and no metadata.
184    pub fn new(name: impl Into<String>) -> Self {
185        let name = name.into();
186        Self {
187            state_key: name.clone(),
188            name,
189            prompt: None,
190            reprompt: None,
191            confirm: ConfirmPolicy::Never,
192            pii: false,
193            recognizer: None,
194            validate: None,
195        }
196    }
197}
198
199/// The slot definition of a frame — the source of truth for what a stage that
200/// `collect`s this frame must gather, plus the metadata that drives confirmation
201/// and repair.
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct FrameSpec {
204    /// Frame name (defaults to the struct name in snake_case).
205    pub name: String,
206    /// The slots, in declaration order.
207    pub slots: Vec<SlotSpec>,
208}
209
210impl FrameSpec {
211    /// The `State` keys of every slot, in order — what a `collect` completes on.
212    pub fn slot_keys(&self) -> Vec<String> {
213        self.slots.iter().map(|s| s.state_key.clone()).collect()
214    }
215
216    /// Look up a slot by name.
217    pub fn slot(&self, name: &str) -> Option<&SlotSpec> {
218        self.slots.iter().find(|s| s.name == name)
219    }
220
221    /// Lower the frame's recognizer-bearing slots into an [`Extract`] record that
222    /// fills them from the transcript. Returns `None` when no slot has a
223    /// recognizer (a frame whose slots are gathered some other way).
224    pub fn to_extract(&self) -> Option<Extract> {
225        let mut builder = Extract::record(self.name.clone());
226        let mut any = false;
227        for slot in &self.slots {
228            if let Some(rec) = &slot.recognizer {
229                builder = builder.field_to(
230                    slot.name.clone(),
231                    slot.state_key.clone(),
232                    rec.to_recognizer(),
233                );
234                if let Some(validator) = slot.validate.clone() {
235                    builder = builder.validate(move |v| validator.check(v));
236                }
237                any = true;
238            }
239        }
240        any.then(|| builder.build())
241    }
242}
243
244/// A typed conversation frame. Implement via `#[derive(Frame)]`.
245pub trait Frame {
246    /// The frame's slot definition.
247    fn frame() -> FrameSpec;
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn confirm_policy_parses() {
256        assert_eq!(ConfirmPolicy::parse("always"), Some(ConfirmPolicy::Always));
257        assert_eq!(
258            ConfirmPolicy::parse("low_confidence"),
259            Some(ConfirmPolicy::LowConfidence)
260        );
261        assert_eq!(ConfirmPolicy::parse("nope"), None);
262    }
263
264    #[test]
265    fn slot_validator_checks() {
266        let range = SlotValidator::Range {
267            min: Some(1.0),
268            max: Some(12.0),
269        };
270        assert!(range.check(&serde_json::json!(6)));
271        assert!(range.check(&serde_json::json!("4"))); // numeric string
272        assert!(!range.check(&serde_json::json!(0)));
273        assert!(!range.check(&serde_json::json!(13)));
274        assert!(!range.check(&serde_json::json!("x")));
275
276        assert!(SlotValidator::NonEmpty.check(&serde_json::json!("hi")));
277        assert!(!SlotValidator::NonEmpty.check(&serde_json::json!("  ")));
278
279        let one_of = SlotValidator::OneOf(vec!["pizza".into(), "salad".into()]);
280        assert!(one_of.check(&serde_json::json!("PIZZA")));
281        assert!(!one_of.check(&serde_json::json!("soda")));
282    }
283
284    #[test]
285    fn to_extract_lowers_recognizer_slots() {
286        let spec = FrameSpec {
287            name: "order".into(),
288            slots: vec![
289                SlotSpec {
290                    recognizer: Some(SlotRecognizer::OneOf(vec!["pizza".into(), "salad".into()])),
291                    ..SlotSpec::new("item")
292                },
293                // No recognizer — not part of the extract record.
294                SlotSpec::new("note"),
295            ],
296        };
297        let extract = spec.to_extract().expect("has a recognizer slot");
298        // Round-trips as part of the extract pipeline (built without panic).
299        let _ = extract;
300
301        // A frame with no recognizers lowers to no extractor.
302        let bare = FrameSpec {
303            name: "bare".into(),
304            slots: vec![SlotSpec::new("x")],
305        };
306        assert!(bare.to_extract().is_none());
307    }
308
309    #[test]
310    fn frame_spec_slot_keys_and_lookup() {
311        let spec = FrameSpec {
312            name: "booking".into(),
313            slots: vec![
314                SlotSpec::new("party_size"),
315                SlotSpec {
316                    pii: true,
317                    ..SlotSpec::new("name")
318                },
319            ],
320        };
321        assert_eq!(spec.slot_keys(), vec!["party_size", "name"]);
322        assert!(spec.slot("name").unwrap().pii);
323        assert!(spec.slot("missing").is_none());
324    }
325}