gsm_runner/
model.rs

1use anyhow::{Context, bail};
2use serde::Deserialize;
3use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, Deserialize)]
6pub struct Flow {
7    pub id: String,
8    #[allow(dead_code)]
9    pub title: Option<String>,
10    #[allow(dead_code)]
11    pub description: Option<String>,
12    #[serde(rename = "type")]
13    #[allow(dead_code)]
14    pub kind: String,
15    #[serde(rename = "in")]
16    pub r#in: String,
17    pub nodes: BTreeMap<String, Node>,
18}
19
20#[derive(Debug, Clone, Deserialize)]
21pub struct Node {
22    #[serde(default)]
23    pub qa: Option<QaNode>,
24    #[serde(default)]
25    pub tool: Option<ToolNode>,
26    #[serde(default)]
27    pub template: Option<TemplateNode>,
28    #[serde(default)]
29    pub card: Option<CardNode>,
30    #[serde(default)]
31    pub routes: Vec<String>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35pub struct QaNode {
36    #[allow(dead_code)]
37    pub welcome: Option<String>,
38    pub questions: Vec<Question>,
39    #[serde(default)]
40    pub fallback_agent: Option<AgentCfg>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44pub struct Question {
45    pub id: String,
46    #[allow(dead_code)]
47    pub prompt: String,
48    #[serde(default)]
49    pub answer_type: Option<String>,
50    #[serde(default)]
51    pub max_words: Option<usize>,
52    #[serde(default)]
53    pub default: Option<serde_json::Value>,
54    #[serde(default)]
55    pub validate: Option<Validate>,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59pub struct Validate {
60    pub range: Option<[f64; 2]>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64pub struct AgentCfg {
65    #[serde(default)]
66    pub r#type: Option<String>,
67    #[serde(default)]
68    pub model: Option<String>,
69    pub task: Option<String>,
70    #[serde(default)]
71    pub endpoint: Option<String>,
72}
73
74#[derive(Debug, Clone, Deserialize)]
75pub struct ToolNode {
76    pub tool: String,
77    pub action: String,
78    #[serde(default)]
79    pub input: serde_json::Value,
80    #[serde(default)]
81    pub retry: Option<u32>,
82    #[serde(default)]
83    pub delay_secs: Option<u64>,
84    #[serde(default)]
85    pub timeout_secs: Option<u64>,
86}
87
88#[derive(Debug, Clone, Deserialize)]
89pub struct TemplateNode {
90    pub template: String,
91}
92
93#[derive(Debug, Clone, Deserialize)]
94pub struct CardNode {
95    pub title: Option<String>,
96    #[serde(default)]
97    pub body: Vec<CardBlock>,
98    #[serde(default)]
99    pub actions: Vec<CardAction>,
100}
101
102#[derive(Debug, Clone, Deserialize)]
103#[serde(tag = "type")]
104pub enum CardBlock {
105    #[serde(rename = "text")]
106    Text {
107        text: String,
108        #[serde(default)]
109        markdown: Option<bool>,
110    },
111    #[serde(rename = "fact")]
112    Fact { label: String, value: String },
113    #[serde(rename = "image")]
114    Image { url: String },
115}
116
117#[derive(Debug, Clone, Deserialize)]
118#[serde(tag = "type")]
119pub enum CardAction {
120    #[serde(rename = "openUrl")]
121    OpenUrl {
122        title: String,
123        url: String,
124        #[serde(default)]
125        jwt: Option<bool>,
126    },
127    #[serde(rename = "postback")]
128    Postback {
129        title: String,
130        data: serde_json::Value,
131    },
132}
133
134impl Flow {
135    pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
136        let txt = std::fs::read_to_string(path)
137            .with_context(|| format!("reading flow definition at {path}"))?;
138        let flow: Flow = serde_yaml_bw::from_str(&txt)
139            .with_context(|| format!("parsing flow yaml at {path}"))?;
140        flow.validate()?;
141        Ok(flow)
142    }
143
144    pub fn load_from_str(label: &str, raw: &str) -> anyhow::Result<Self> {
145        let flow: Flow = serde_yaml_bw::from_str(raw)
146            .with_context(|| format!("parsing flow yaml at {label}"))?;
147        flow.validate()?;
148        Ok(flow)
149    }
150
151    fn validate(&self) -> anyhow::Result<()> {
152        if self.id.trim().is_empty() {
153            bail!("flow missing id");
154        }
155        if self.kind.trim().is_empty() {
156            bail!("flow {} missing type", self.id);
157        }
158        if self.r#in.trim().is_empty() {
159            bail!("flow {} missing entry point `in`", self.id);
160        }
161        if self.nodes.is_empty() {
162            bail!("flow {} defines no nodes", self.id);
163        }
164        if !self.nodes.contains_key(&self.r#in) {
165            bail!(
166                "flow {} entry point `{}` not found in nodes",
167                self.id,
168                self.r#in
169            );
170        }
171        Ok(())
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use std::{fs, path::PathBuf};
179
180    fn write_temp_flow(contents: &str) -> PathBuf {
181        let suffix = uuid::Uuid::new_v4();
182        let path = std::env::temp_dir().join(format!("flow-{suffix}.yaml"));
183        fs::write(&path, contents).unwrap();
184        path
185    }
186
187    #[test]
188    fn load_from_file_parses_flow_structure() {
189        let yaml = r#"
190id: flow-1
191title: Sample Flow
192type: qa
193in: start
194nodes:
195  start:
196    qa:
197      welcome: "Hello"
198      questions:
199        - id: q1
200          prompt: "What is your name?"
201    routes: []
202"#;
203        let path = write_temp_flow(yaml);
204        let flow = Flow::load_from_file(path.to_str().unwrap()).expect("flow");
205        fs::remove_file(path).ok();
206
207        assert_eq!(flow.id, "flow-1");
208        assert_eq!(flow.kind, "qa");
209        let start = flow.nodes.get("start").expect("start node");
210        let qa = start.qa.as_ref().expect("qa node");
211        assert_eq!(qa.questions.len(), 1);
212        assert_eq!(qa.questions[0].prompt, "What is your name?");
213    }
214
215    #[test]
216    fn missing_optional_sections_default() {
217        let yaml = r#"
218id: flow-2
219type: tool
220in: worker
221nodes:
222  worker:
223    tool:
224      tool: weather
225      action: fetch
226    routes: []
227"#;
228        let path = write_temp_flow(yaml);
229        let flow = Flow::load_from_file(path.to_str().unwrap()).expect("flow");
230        fs::remove_file(path).ok();
231
232        let worker = flow.nodes.get("worker").expect("worker node");
233        assert!(worker.qa.is_none());
234        assert!(worker.card.is_none());
235        assert_eq!(worker.routes, Vec::<String>::new());
236    }
237
238    #[test]
239    fn load_from_file_errors_on_invalid_yaml() {
240        let yaml = r#"
241id: flow-3
242type: qa
243nodes:
244  start:
245    qa: {}
246        "#;
247        let path = write_temp_flow(yaml);
248        let result = Flow::load_from_file(path.to_str().unwrap());
249        fs::remove_file(path).ok();
250        assert!(result.is_err());
251    }
252}