1use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use crate::extract::{Extract, Recognizer};
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum SlotValidator {
38 Range {
41 #[serde(default, skip_serializing_if = "Option::is_none")]
43 min: Option<f64>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 max: Option<f64>,
47 },
48 NonEmpty,
50 Regex(String),
52 OneOf(Vec<String>),
54}
55
56impl SlotValidator {
57 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum SlotRecognizer {
92 Integer,
94 IntegerNear(Vec<String>),
96 Money,
98 Regex(String),
100 OneOf(Vec<String>),
102 Fuzzy(Vec<String>),
104 YesNo,
106 DateTime,
108}
109
110impl SlotRecognizer {
111 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum ConfirmPolicy {
130 #[default]
132 Never,
133 LowConfidence,
135 Always,
137}
138
139impl ConfirmPolicy {
140 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#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct SlotSpec {
154 pub name: String,
156 pub state_key: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub prompt: Option<String>,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub reprompt: Option<String>,
164 #[serde(default, skip_serializing_if = "is_default_confirm")]
166 pub confirm: ConfirmPolicy,
167 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
169 pub pii: bool,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub recognizer: Option<SlotRecognizer>,
173 #[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 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#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct FrameSpec {
204 pub name: String,
206 pub slots: Vec<SlotSpec>,
208}
209
210impl FrameSpec {
211 pub fn slot_keys(&self) -> Vec<String> {
213 self.slots.iter().map(|s| s.state_key.clone()).collect()
214 }
215
216 pub fn slot(&self, name: &str) -> Option<&SlotSpec> {
218 self.slots.iter().find(|s| s.name == name)
219 }
220
221 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
244pub trait Frame {
246 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"))); 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 SlotSpec::new("note"),
295 ],
296 };
297 let extract = spec.to_extract().expect("has a recognizer slot");
298 let _ = extract;
300
301 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}