1use serde::{Deserialize, Serialize};
29use serde_json::Value;
30
31use crate::extract::{Extract, Recognizer};
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
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, schemars::JsonSchema)]
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(
128 Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
129)]
130#[serde(rename_all = "snake_case")]
131pub enum ConfirmPolicy {
132 #[default]
134 Never,
135 LowConfidence,
137 Always,
139}
140
141impl ConfirmPolicy {
142 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#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
155pub struct SlotSpec {
156 pub name: String,
158 pub state_key: String,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub prompt: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub reprompt: Option<String>,
166 #[serde(default, skip_serializing_if = "is_default_confirm")]
168 pub confirm: ConfirmPolicy,
169 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
171 pub pii: bool,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub recognizer: Option<SlotRecognizer>,
175 #[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 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#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
205pub struct FrameSpec {
206 pub name: String,
208 pub slots: Vec<SlotSpec>,
210}
211
212impl FrameSpec {
213 pub fn slot_keys(&self) -> Vec<String> {
215 self.slots.iter().map(|s| s.state_key.clone()).collect()
216 }
217
218 pub fn slot(&self, name: &str) -> Option<&SlotSpec> {
220 self.slots.iter().find(|s| s.name == name)
221 }
222
223 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
246pub trait Frame {
248 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"))); 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 SlotSpec::new("note"),
297 ],
298 };
299 let extract = spec.to_extract().expect("has a recognizer slot");
300 let _ = extract;
302
303 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}