wavekat_flow/model_ext.rs
1//! Hand-written model helpers layered on the SCHEMA-GENERATED types in
2//! [`crate::model`]. The schema owns the document *shape*; the accessors
3//! here are *logic*, not shape (the exit set a kind must wire, terminal-ness,
4//! the `kind` tag, prompt text extraction) — so they live in code, one twin
5//! per language. The TypeScript twin is `packages/flow-schema/src/model.ts`;
6//! keep the two in lockstep.
7//!
8//! typify emits `Node` as an internally-tagged enum (`#[serde(tag = "kind")]`,
9//! wired by the `build.rs` schema normalization) whose variants carry their
10//! config fields *and* `exits` together. The pre-consolidation crate used a
11//! `Node { #[flatten] config: Component, exits }` split with a `Component`
12//! enum; these methods present the same query surface over the generated
13//! shape so the validator/engine read the same.
14
15use std::collections::HashMap;
16
17use crate::model::{Flow, Node, Prompt};
18
19/// A node's key within a flow. Human-meaningful (`night_menu`, not `n7`).
20pub type NodeId = String;
21
22impl Node {
23 /// The `kind` tag, for traces and diagnostics.
24 pub fn kind(&self) -> &'static str {
25 match self {
26 Node::Greeting { .. } => "greeting",
27 Node::Hours { .. } => "hours",
28 Node::Menu { .. } => "menu",
29 Node::Ring { .. } => "ring",
30 Node::Message { .. } => "message",
31 Node::Transfer { .. } => "transfer",
32 Node::Hangup { .. } => "hangup",
33 Node::Book { .. } => "book",
34 }
35 }
36
37 /// Whether a caller who reaches this node can have the call *end* here.
38 /// `ring` counts — its implicit `answered` hands the call to a human,
39 /// which ends the flow. Used by the "no caller is ever trapped" check.
40 pub fn is_terminal(&self) -> bool {
41 matches!(
42 self,
43 Node::Message { .. } | Node::Transfer { .. } | Node::Hangup { .. } | Node::Ring { .. }
44 )
45 }
46
47 /// The exit names this node **must** wire, given its config. Validation
48 /// checks the node's `exits` keys are exactly this set — no missing exit
49 /// (a dead choice) and no stray exit (a typo the engine would never
50 /// follow).
51 pub fn required_exits(&self) -> Vec<String> {
52 match self {
53 Node::Greeting { .. } => vec!["next".into()],
54 Node::Hours { .. } => vec!["open".into(), "closed".into()],
55 Node::Menu { options, .. } => {
56 let mut names: Vec<String> = options.keys().cloned().collect();
57 names.push("no_input".into());
58 names.push("invalid".into());
59 names
60 }
61 Node::Ring { .. } => vec!["no_answer".into()],
62 // Every way out of `book` is wired, including the two nobody
63 // wants to think about: a calendar with nothing free, and a
64 // calendar we couldn't reach. An unwired `unavailable` would
65 // be a dead line on the day the provider has an outage.
66 Node::Book { .. } => vec![
67 "booked".into(),
68 "no_slots".into(),
69 "no_input".into(),
70 "unavailable".into(),
71 ],
72 Node::Message { .. } | Node::Transfer { .. } | Node::Hangup { .. } => Vec::new(),
73 }
74 }
75
76 /// The node's wired exits (exit name → target node id), or `None` when
77 /// the document omits the block. Every generated variant carries an
78 /// `exits: Option<Exits>`, so this reads them uniformly.
79 pub fn exits(&self) -> Option<&HashMap<String, String>> {
80 let exits = match self {
81 Node::Greeting { exits, .. }
82 | Node::Hours { exits, .. }
83 | Node::Menu { exits, .. }
84 | Node::Ring { exits, .. }
85 | Node::Message { exits, .. }
86 | Node::Transfer { exits, .. }
87 | Node::Hangup { exits, .. }
88 | Node::Book { exits, .. } => exits,
89 };
90 exits.as_deref()
91 }
92
93 /// The prompts this node speaks. Most kinds have one; `book` has two
94 /// (its intro and its confirmation) and `hangup`'s is optional. Twin:
95 /// `model.ts` `nodePrompts`.
96 pub fn prompts(&self) -> Vec<&Prompt> {
97 match self {
98 Node::Greeting { prompt, .. }
99 | Node::Menu { prompt, .. }
100 | Node::Message { prompt, .. } => vec![prompt],
101 Node::Hangup { prompt, .. } => prompt.iter().collect(),
102 Node::Book {
103 prompt,
104 confirm_prompt,
105 ..
106 } => vec![prompt, confirm_prompt],
107 Node::Hours { .. } | Node::Ring { .. } | Node::Transfer { .. } => Vec::new(),
108 }
109 }
110}
111
112/// Every audio asset the flow needs on the device, sorted and unique:
113/// the refs its prompts point at, plus — for a `book` node — the
114/// vocabulary it speaks times with (see [`crate::book`], which explains
115/// why that is an asset and not a sentence).
116///
117/// "Needs on the device", not "is written in the document": the daemon
118/// asks this to decide whether a flow can be armed yet, and a `book`
119/// flow whose vocabulary hasn't synced can no more run than one whose
120/// greeting hasn't. Twin: `model.ts` `requiredAssets`.
121pub fn required_assets(flow: &Flow) -> Vec<String> {
122 let mut refs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
123 for node in flow.nodes.values() {
124 for prompt in node.prompts() {
125 if let Prompt::Audio { audio, .. } = prompt {
126 refs.insert(audio.clone());
127 }
128 }
129 refs.extend(crate::book::vocabulary_refs(node));
130 }
131 refs.into_iter().collect()
132}
133
134impl Prompt {
135 /// The *synthesizable* text: `Some` for a bare-string prompt the engine
136 /// speaks with TTS, `None` for an audio-asset prompt it plays as a clip.
137 /// This drives playback branching and the length cap — an audio prompt's
138 /// transcript, when present, is *not* returned here (it is display text,
139 /// not something to synthesize or bound). For the human-readable words
140 /// either kind speaks, use [`Prompt::transcript`].
141 pub fn as_text(&self) -> Option<&str> {
142 match self {
143 Prompt::Text(t) => Some(t.as_str()),
144 Prompt::Audio { .. } => None,
145 }
146 }
147
148 /// The human-readable words this prompt speaks, for display (a "what the
149 /// caller hears" transcript) and traces — regardless of how it is voiced.
150 /// A text prompt is its own transcript; an audio prompt carries the text
151 /// it was synthesized from in `transcript`, `None` when the document omits
152 /// it (older flows, or a ref not generated from text). Unlike [`as_text`],
153 /// this is never used to decide playback or enforce the length cap.
154 ///
155 /// [`as_text`]: Prompt::as_text
156 pub fn transcript(&self) -> Option<&str> {
157 match self {
158 Prompt::Text(t) => Some(t.as_str()),
159 Prompt::Audio { transcript, .. } => transcript.as_deref(),
160 }
161 }
162}
163
164impl Flow {
165 /// Parse a flow document from YAML text. This is the only surface-syntax
166 /// entry point; everything downstream works on the typed tree. Parsing
167 /// does **not** validate semantics (reachability, exit wiring, …) — call
168 /// [`crate::validate::validate`] on the result.
169 ///
170 /// Routes through `serde_yaml_ng::Value` first, on purpose: the
171 /// duplicate-key check lives in that crate's `Mapping` deserializer, not
172 /// on the direct-into-struct path — a `HashMap` field would otherwise
173 /// silently take last-wins. Routing through `Value` rejects a duplicate
174 /// key at *any* nesting level, the footgun doc 48 fences off.
175 pub fn from_yaml(src: &str) -> Result<Flow, serde_yaml_ng::Error> {
176 let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(src)?;
177 serde_yaml_ng::from_value(value)
178 }
179
180 /// Serialize back to YAML (lossless for the model; the opaque `ui` block
181 /// round-trips).
182 pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
183 serde_yaml_ng::to_string(self)
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use crate::model::Prompt;
190
191 #[test]
192 fn as_text_is_synthesizable_text_only() {
193 // A bare-string prompt is TTS the engine speaks.
194 let text = Prompt::Text("open eleven to ten".into());
195 assert_eq!(text.as_text(), Some("open eleven to ten"));
196
197 // An audio prompt is a clip — never synthesizable, even when it
198 // carries a transcript. `as_text` drives playback + the length cap,
199 // so it must stay `None` here.
200 let audio = Prompt::Audio {
201 audio: "vprompt_ab12cd34".into(),
202 transcript: Some("open eleven to ten".into()),
203 };
204 assert_eq!(audio.as_text(), None);
205 }
206
207 #[test]
208 fn transcript_is_the_spoken_words_regardless_of_voicing() {
209 // Text prompt is its own transcript.
210 let text = Prompt::Text("open eleven to ten".into());
211 assert_eq!(text.transcript(), Some("open eleven to ten"));
212
213 // Audio prompt surfaces the text it was synthesized from.
214 let with_text = Prompt::Audio {
215 audio: "vprompt_ab12cd34".into(),
216 transcript: Some("open eleven to ten".into()),
217 };
218 assert_eq!(with_text.transcript(), Some("open eleven to ten"));
219
220 // No transcript recorded (older flow / non-generated ref) → None.
221 let without_text = Prompt::Audio {
222 audio: "vprompt_ff00ee11".into(),
223 transcript: None,
224 };
225 assert_eq!(without_text.transcript(), None);
226 }
227
228 #[test]
229 fn audio_transcript_round_trips_through_yaml() {
230 let with = "{ audio: vprompt_ab12cd34, transcript: open eleven to ten }";
231 let parsed: Prompt = serde_yaml_ng::from_str(with).unwrap();
232 assert_eq!(parsed.transcript(), Some("open eleven to ten"));
233
234 // Re-serialize and re-parse: the transcript survives the trip.
235 let yaml = serde_yaml_ng::to_string(&parsed).unwrap();
236 let reparsed: Prompt = serde_yaml_ng::from_str(&yaml).unwrap();
237 assert_eq!(reparsed.transcript(), Some("open eleven to ten"));
238
239 // The field is optional: a bare ref still parses, with no transcript,
240 // and is skipped on the way back out (no `transcript: null` noise).
241 let bare: Prompt = serde_yaml_ng::from_str("{ audio: vprompt_ff00ee11 }").unwrap();
242 assert_eq!(bare.transcript(), None);
243 assert!(!serde_yaml_ng::to_string(&bare)
244 .unwrap()
245 .contains("transcript"));
246 }
247}