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        }
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}