Skip to main content

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}