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 }
34 }
35
36 /// Whether a caller who reaches this node can have the call *end* here.
37 /// `ring` counts — its implicit `answered` hands the call to a human,
38 /// which ends the flow. Used by the "no caller is ever trapped" check.
39 pub fn is_terminal(&self) -> bool {
40 matches!(
41 self,
42 Node::Message { .. } | Node::Transfer { .. } | Node::Hangup { .. } | Node::Ring { .. }
43 )
44 }
45
46 /// The exit names this node **must** wire, given its config. Validation
47 /// checks the node's `exits` keys are exactly this set — no missing exit
48 /// (a dead choice) and no stray exit (a typo the engine would never
49 /// follow).
50 pub fn required_exits(&self) -> Vec<String> {
51 match self {
52 Node::Greeting { .. } => vec!["next".into()],
53 Node::Hours { .. } => vec!["open".into(), "closed".into()],
54 Node::Menu { options, .. } => {
55 let mut names: Vec<String> = options.keys().cloned().collect();
56 names.push("no_input".into());
57 names.push("invalid".into());
58 names
59 }
60 Node::Ring { .. } => vec!["no_answer".into()],
61 Node::Message { .. } | Node::Transfer { .. } | Node::Hangup { .. } => Vec::new(),
62 }
63 }
64
65 /// The node's wired exits (exit name → target node id), or `None` when
66 /// the document omits the block. Every generated variant carries an
67 /// `exits: Option<Exits>`, so this reads them uniformly.
68 pub fn exits(&self) -> Option<&HashMap<String, String>> {
69 let exits = match self {
70 Node::Greeting { exits, .. }
71 | Node::Hours { exits, .. }
72 | Node::Menu { exits, .. }
73 | Node::Ring { exits, .. }
74 | Node::Message { exits, .. }
75 | Node::Transfer { exits, .. }
76 | Node::Hangup { exits, .. } => exits,
77 };
78 exits.as_deref()
79 }
80}
81
82impl Prompt {
83 /// The *synthesizable* text: `Some` for a bare-string prompt the engine
84 /// speaks with TTS, `None` for an audio-asset prompt it plays as a clip.
85 /// This drives playback branching and the length cap — an audio prompt's
86 /// transcript, when present, is *not* returned here (it is display text,
87 /// not something to synthesize or bound). For the human-readable words
88 /// either kind speaks, use [`Prompt::transcript`].
89 pub fn as_text(&self) -> Option<&str> {
90 match self {
91 Prompt::Text(t) => Some(t.as_str()),
92 Prompt::Audio { .. } => None,
93 }
94 }
95
96 /// The human-readable words this prompt speaks, for display (a "what the
97 /// caller hears" transcript) and traces — regardless of how it is voiced.
98 /// A text prompt is its own transcript; an audio prompt carries the text
99 /// it was synthesized from in `transcript`, `None` when the document omits
100 /// it (older flows, or a ref not generated from text). Unlike [`as_text`],
101 /// this is never used to decide playback or enforce the length cap.
102 ///
103 /// [`as_text`]: Prompt::as_text
104 pub fn transcript(&self) -> Option<&str> {
105 match self {
106 Prompt::Text(t) => Some(t.as_str()),
107 Prompt::Audio { transcript, .. } => transcript.as_deref(),
108 }
109 }
110}
111
112impl Flow {
113 /// Parse a flow document from YAML text. This is the only surface-syntax
114 /// entry point; everything downstream works on the typed tree. Parsing
115 /// does **not** validate semantics (reachability, exit wiring, …) — call
116 /// [`crate::validate::validate`] on the result.
117 ///
118 /// Routes through `serde_yaml_ng::Value` first, on purpose: the
119 /// duplicate-key check lives in that crate's `Mapping` deserializer, not
120 /// on the direct-into-struct path — a `HashMap` field would otherwise
121 /// silently take last-wins. Routing through `Value` rejects a duplicate
122 /// key at *any* nesting level, the footgun doc 48 fences off.
123 pub fn from_yaml(src: &str) -> Result<Flow, serde_yaml_ng::Error> {
124 let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(src)?;
125 serde_yaml_ng::from_value(value)
126 }
127
128 /// Serialize back to YAML (lossless for the model; the opaque `ui` block
129 /// round-trips).
130 pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
131 serde_yaml_ng::to_string(self)
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use crate::model::Prompt;
138
139 #[test]
140 fn as_text_is_synthesizable_text_only() {
141 // A bare-string prompt is TTS the engine speaks.
142 let text = Prompt::Text("open eleven to ten".into());
143 assert_eq!(text.as_text(), Some("open eleven to ten"));
144
145 // An audio prompt is a clip — never synthesizable, even when it
146 // carries a transcript. `as_text` drives playback + the length cap,
147 // so it must stay `None` here.
148 let audio = Prompt::Audio {
149 audio: "vprompt_ab12cd34".into(),
150 transcript: Some("open eleven to ten".into()),
151 };
152 assert_eq!(audio.as_text(), None);
153 }
154
155 #[test]
156 fn transcript_is_the_spoken_words_regardless_of_voicing() {
157 // Text prompt is its own transcript.
158 let text = Prompt::Text("open eleven to ten".into());
159 assert_eq!(text.transcript(), Some("open eleven to ten"));
160
161 // Audio prompt surfaces the text it was synthesized from.
162 let with_text = Prompt::Audio {
163 audio: "vprompt_ab12cd34".into(),
164 transcript: Some("open eleven to ten".into()),
165 };
166 assert_eq!(with_text.transcript(), Some("open eleven to ten"));
167
168 // No transcript recorded (older flow / non-generated ref) → None.
169 let without_text = Prompt::Audio {
170 audio: "vprompt_ff00ee11".into(),
171 transcript: None,
172 };
173 assert_eq!(without_text.transcript(), None);
174 }
175
176 #[test]
177 fn audio_transcript_round_trips_through_yaml() {
178 let with = "{ audio: vprompt_ab12cd34, transcript: open eleven to ten }";
179 let parsed: Prompt = serde_yaml_ng::from_str(with).unwrap();
180 assert_eq!(parsed.transcript(), Some("open eleven to ten"));
181
182 // Re-serialize and re-parse: the transcript survives the trip.
183 let yaml = serde_yaml_ng::to_string(&parsed).unwrap();
184 let reparsed: Prompt = serde_yaml_ng::from_str(&yaml).unwrap();
185 assert_eq!(reparsed.transcript(), Some("open eleven to ten"));
186
187 // The field is optional: a bare ref still parses, with no transcript,
188 // and is skipped on the way back out (no `transcript: null` noise).
189 let bare: Prompt = serde_yaml_ng::from_str("{ audio: vprompt_ff00ee11 }").unwrap();
190 assert_eq!(bare.transcript(), None);
191 assert!(!serde_yaml_ng::to_string(&bare)
192 .unwrap()
193 .contains("transcript"));
194 }
195}