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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
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(
128    Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
129)]
130#[serde(rename_all = "snake_case")]
131pub enum ConfirmPolicy {
132    /// Never explicitly confirm.
133    #[default]
134    Never,
135    /// Confirm only when the slot's evidence confidence is low.
136    LowConfidence,
137    /// Always confirm before trusting the value.
138    Always,
139}
140
141impl ConfirmPolicy {
142    /// Parse from an attribute string (`never`/`low_confidence`/`always`).
143    pub fn parse(s: &str) -> Option<Self> {
144        match s {
145            "never" => Some(ConfirmPolicy::Never),
146            "low_confidence" => Some(ConfirmPolicy::LowConfidence),
147            "always" => Some(ConfirmPolicy::Always),
148            _ => None,
149        }
150    }
151}
152
153/// Metadata for a single slot within a [`FrameSpec`].
154#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
155pub struct SlotSpec {
156    /// The slot (field) name.
157    pub name: String,
158    /// The `State` key the slot is stored under (defaults to `name`).
159    pub state_key: String,
160    /// Prompt asked to elicit the slot.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub prompt: Option<String>,
163    /// Reprompt used after a failed/empty first attempt.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub reprompt: Option<String>,
166    /// When to confirm the slot's value.
167    #[serde(default, skip_serializing_if = "is_default_confirm")]
168    pub confirm: ConfirmPolicy,
169    /// Whether the slot holds PII (redact in logs/transcripts).
170    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
171    pub pii: bool,
172    /// The deterministic recognizer that fills this slot, if any.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub recognizer: Option<SlotRecognizer>,
175    /// A validator applied to recognized values; invalid values are rejected.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub validate: Option<SlotValidator>,
178}
179
180fn is_default_confirm(c: &ConfirmPolicy) -> bool {
181    *c == ConfirmPolicy::Never
182}
183
184impl SlotSpec {
185    /// A bare slot with name == state key and no metadata.
186    pub fn new(name: impl Into<String>) -> Self {
187        let name = name.into();
188        Self {
189            state_key: name.clone(),
190            name,
191            prompt: None,
192            reprompt: None,
193            confirm: ConfirmPolicy::Never,
194            pii: false,
195            recognizer: None,
196            validate: None,
197        }
198    }
199}
200
201/// The slot definition of a frame — the source of truth for what a stage that
202/// `collect`s this frame must gather, plus the metadata that drives confirmation
203/// and repair.
204#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
205pub struct FrameSpec {
206    /// Frame name (defaults to the struct name in snake_case).
207    pub name: String,
208    /// The slots, in declaration order.
209    pub slots: Vec<SlotSpec>,
210}
211
212impl FrameSpec {
213    /// The `State` keys of every slot, in order — what a `collect` completes on.
214    pub fn slot_keys(&self) -> Vec<String> {
215        self.slots.iter().map(|s| s.state_key.clone()).collect()
216    }
217
218    /// Look up a slot by name.
219    pub fn slot(&self, name: &str) -> Option<&SlotSpec> {
220        self.slots.iter().find(|s| s.name == name)
221    }
222
223    /// Lower the frame's recognizer-bearing slots into an [`Extract`] record that
224    /// fills them from the transcript. Returns `None` when no slot has a
225    /// recognizer (a frame whose slots are gathered some other way).
226    pub fn to_extract(&self) -> Option<Extract> {
227        let mut builder = Extract::record(self.name.clone());
228        let mut any = false;
229        for slot in &self.slots {
230            if let Some(rec) = &slot.recognizer {
231                builder = builder.field_to(
232                    slot.name.clone(),
233                    slot.state_key.clone(),
234                    rec.to_recognizer(),
235                );
236                if let Some(validator) = slot.validate.clone() {
237                    builder = builder.validate(move |v| validator.check(v));
238                }
239                any = true;
240            }
241        }
242        any.then(|| builder.build())
243    }
244}
245
246/// A typed conversation frame. Implement via `#[derive(Frame)]`.
247pub trait Frame {
248    /// The frame's slot definition.
249    fn frame() -> FrameSpec;
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn confirm_policy_parses() {
258        assert_eq!(ConfirmPolicy::parse("always"), Some(ConfirmPolicy::Always));
259        assert_eq!(
260            ConfirmPolicy::parse("low_confidence"),
261            Some(ConfirmPolicy::LowConfidence)
262        );
263        assert_eq!(ConfirmPolicy::parse("nope"), None);
264    }
265
266    #[test]
267    fn slot_validator_checks() {
268        let range = SlotValidator::Range {
269            min: Some(1.0),
270            max: Some(12.0),
271        };
272        assert!(range.check(&serde_json::json!(6)));
273        assert!(range.check(&serde_json::json!("4"))); // numeric string
274        assert!(!range.check(&serde_json::json!(0)));
275        assert!(!range.check(&serde_json::json!(13)));
276        assert!(!range.check(&serde_json::json!("x")));
277
278        assert!(SlotValidator::NonEmpty.check(&serde_json::json!("hi")));
279        assert!(!SlotValidator::NonEmpty.check(&serde_json::json!("  ")));
280
281        let one_of = SlotValidator::OneOf(vec!["pizza".into(), "salad".into()]);
282        assert!(one_of.check(&serde_json::json!("PIZZA")));
283        assert!(!one_of.check(&serde_json::json!("soda")));
284    }
285
286    #[test]
287    fn to_extract_lowers_recognizer_slots() {
288        let spec = FrameSpec {
289            name: "order".into(),
290            slots: vec![
291                SlotSpec {
292                    recognizer: Some(SlotRecognizer::OneOf(vec!["pizza".into(), "salad".into()])),
293                    ..SlotSpec::new("item")
294                },
295                // No recognizer — not part of the extract record.
296                SlotSpec::new("note"),
297            ],
298        };
299        let extract = spec.to_extract().expect("has a recognizer slot");
300        // Round-trips as part of the extract pipeline (built without panic).
301        let _ = extract;
302
303        // A frame with no recognizers lowers to no extractor.
304        let bare = FrameSpec {
305            name: "bare".into(),
306            slots: vec![SlotSpec::new("x")],
307        };
308        assert!(bare.to_extract().is_none());
309    }
310
311    #[test]
312    fn frame_spec_slot_keys_and_lookup() {
313        let spec = FrameSpec {
314            name: "booking".into(),
315            slots: vec![
316                SlotSpec::new("party_size"),
317                SlotSpec {
318                    pii: true,
319                    ..SlotSpec::new("name")
320                },
321            ],
322        };
323        assert_eq!(spec.slot_keys(), vec!["party_size", "name"]);
324        assert!(spec.slot("name").unwrap().pii);
325        assert!(spec.slot("missing").is_none());
326    }
327}