diff --git a/conformance/v1/valid/audio-prompt-transcript.expected.json b/conformance/v1/valid/audio-prompt-transcript.expected.json new file mode 100644 index 0000000..3f31ddc --- /dev/null +++ b/conformance/v1/valid/audio-prompt-transcript.expected.json @@ -0,0 +1,5 @@ +{ + "description": "Exercises the optional `text` transcript on an audio prompt (present on one node, absent on another) — additive and always structurally valid.", + "structurallyValid": true, + "semantic": { "ok": true, "errors": [] } +} diff --git a/conformance/v1/valid/audio-prompt-transcript.yaml b/conformance/v1/valid/audio-prompt-transcript.yaml new file mode 100644 index 0000000..8d2489f --- /dev/null +++ b/conformance/v1/valid/audio-prompt-transcript.yaml @@ -0,0 +1,15 @@ +schema_version: 1 +id: flow_audio_transcript +name: Generated-clip prompts with transcripts +entry: welcome +nodes: + welcome: + kind: greeting + prompt: { audio: vprompt_ab12cd34, transcript: Thanks for calling Luigi's! } + exits: { next: vm } + vm: + kind: message + # An audio prompt without a transcript stays valid — the field is optional. + prompt: { audio: vprompt_ff00ee11 } + max_secs: 90 + tone: none diff --git a/crates/wavekat-flow/schema/flow.v1.schema.json b/crates/wavekat-flow/schema/flow.v1.schema.json index 0f8e812..2fe2e66 100644 --- a/crates/wavekat-flow/schema/flow.v1.schema.json +++ b/crates/wavekat-flow/schema/flow.v1.schema.json @@ -51,6 +51,10 @@ "audio": { "description": "Audio asset ref. A generated-clip ref is a voice_prompts id matching ^vprompt_[a-z0-9]+$.", "type": "string" + }, + "transcript": { + "description": "The words this pre-rendered clip speaks — the text it was synthesized from — carried so a viewer can show what a caller hears, and for traces. Advisory only: playback always uses `audio`, never this. Optional and forward-added: absent on older documents and on refs the platform did not generate from text.", + "type": "string" } } } diff --git a/crates/wavekat-flow/src/model_ext.rs b/crates/wavekat-flow/src/model_ext.rs index 34e9cc0..6cb2700 100644 --- a/crates/wavekat-flow/src/model_ext.rs +++ b/crates/wavekat-flow/src/model_ext.rs @@ -80,14 +80,33 @@ impl Node { } impl Prompt { - /// The spoken text, or `None` for an audio-asset prompt. Handy for - /// traces and length validation. + /// The *synthesizable* text: `Some` for a bare-string prompt the engine + /// speaks with TTS, `None` for an audio-asset prompt it plays as a clip. + /// This drives playback branching and the length cap — an audio prompt's + /// transcript, when present, is *not* returned here (it is display text, + /// not something to synthesize or bound). For the human-readable words + /// either kind speaks, use [`Prompt::transcript`]. pub fn as_text(&self) -> Option<&str> { match self { Prompt::Text(t) => Some(t.as_str()), Prompt::Audio { .. } => None, } } + + /// The human-readable words this prompt speaks, for display (a "what the + /// caller hears" transcript) and traces — regardless of how it is voiced. + /// A text prompt is its own transcript; an audio prompt carries the text + /// it was synthesized from in `transcript`, `None` when the document omits + /// it (older flows, or a ref not generated from text). Unlike [`as_text`], + /// this is never used to decide playback or enforce the length cap. + /// + /// [`as_text`]: Prompt::as_text + pub fn transcript(&self) -> Option<&str> { + match self { + Prompt::Text(t) => Some(t.as_str()), + Prompt::Audio { transcript, .. } => transcript.as_deref(), + } + } } impl Flow { @@ -112,3 +131,65 @@ impl Flow { serde_yaml_ng::to_string(self) } } + +#[cfg(test)] +mod tests { + use crate::model::Prompt; + + #[test] + fn as_text_is_synthesizable_text_only() { + // A bare-string prompt is TTS the engine speaks. + let text = Prompt::Text("open eleven to ten".into()); + assert_eq!(text.as_text(), Some("open eleven to ten")); + + // An audio prompt is a clip — never synthesizable, even when it + // carries a transcript. `as_text` drives playback + the length cap, + // so it must stay `None` here. + let audio = Prompt::Audio { + audio: "vprompt_ab12cd34".into(), + transcript: Some("open eleven to ten".into()), + }; + assert_eq!(audio.as_text(), None); + } + + #[test] + fn transcript_is_the_spoken_words_regardless_of_voicing() { + // Text prompt is its own transcript. + let text = Prompt::Text("open eleven to ten".into()); + assert_eq!(text.transcript(), Some("open eleven to ten")); + + // Audio prompt surfaces the text it was synthesized from. + let with_text = Prompt::Audio { + audio: "vprompt_ab12cd34".into(), + transcript: Some("open eleven to ten".into()), + }; + assert_eq!(with_text.transcript(), Some("open eleven to ten")); + + // No transcript recorded (older flow / non-generated ref) → None. + let without_text = Prompt::Audio { + audio: "vprompt_ff00ee11".into(), + transcript: None, + }; + assert_eq!(without_text.transcript(), None); + } + + #[test] + fn audio_transcript_round_trips_through_yaml() { + let with = "{ audio: vprompt_ab12cd34, transcript: open eleven to ten }"; + let parsed: Prompt = serde_yaml_ng::from_str(with).unwrap(); + assert_eq!(parsed.transcript(), Some("open eleven to ten")); + + // Re-serialize and re-parse: the transcript survives the trip. + let yaml = serde_yaml_ng::to_string(&parsed).unwrap(); + let reparsed: Prompt = serde_yaml_ng::from_str(&yaml).unwrap(); + assert_eq!(reparsed.transcript(), Some("open eleven to ten")); + + // The field is optional: a bare ref still parses, with no transcript, + // and is skipped on the way back out (no `transcript: null` noise). + let bare: Prompt = serde_yaml_ng::from_str("{ audio: vprompt_ff00ee11 }").unwrap(); + assert_eq!(bare.transcript(), None); + assert!(!serde_yaml_ng::to_string(&bare) + .unwrap() + .contains("transcript")); + } +} diff --git a/packages/flow-schema/src/generated/flow.v1.schema.json b/packages/flow-schema/src/generated/flow.v1.schema.json index 0f8e812..2fe2e66 100644 --- a/packages/flow-schema/src/generated/flow.v1.schema.json +++ b/packages/flow-schema/src/generated/flow.v1.schema.json @@ -51,6 +51,10 @@ "audio": { "description": "Audio asset ref. A generated-clip ref is a voice_prompts id matching ^vprompt_[a-z0-9]+$.", "type": "string" + }, + "transcript": { + "description": "The words this pre-rendered clip speaks — the text it was synthesized from — carried so a viewer can show what a caller hears, and for traces. Advisory only: playback always uses `audio`, never this. Optional and forward-added: absent on older documents and on refs the platform did not generate from text.", + "type": "string" } } } diff --git a/packages/flow-schema/src/generated/model.ts b/packages/flow-schema/src/generated/model.ts index fc4b350..d85b763 100644 --- a/packages/flow-schema/src/generated/model.ts +++ b/packages/flow-schema/src/generated/model.ts @@ -67,6 +67,10 @@ export interface Audio { * Audio asset ref. A generated-clip ref is a voice_prompts id matching ^vprompt_[a-z0-9]+$. */ audio: string; + /** + * The words this pre-rendered clip speaks — the text it was synthesized from — carried so a viewer can show what a caller hears, and for traces. Advisory only: playback always uses `audio`, never this. Optional and forward-added: absent on older documents and on refs the platform did not generate from text. + */ + transcript?: string; [k: string]: unknown; } /** diff --git a/packages/flow-schema/src/generated/schema.ts b/packages/flow-schema/src/generated/schema.ts index bedf61a..613f2e2 100644 --- a/packages/flow-schema/src/generated/schema.ts +++ b/packages/flow-schema/src/generated/schema.ts @@ -67,6 +67,10 @@ const schema = { "audio": { "description": "Audio asset ref. A generated-clip ref is a voice_prompts id matching ^vprompt_[a-z0-9]+$.", "type": "string" + }, + "transcript": { + "description": "The words this pre-rendered clip speaks — the text it was synthesized from — carried so a viewer can show what a caller hears, and for traces. Advisory only: playback always uses `audio`, never this. Optional and forward-added: absent on older documents and on refs the platform did not generate from text.", + "type": "string" } } } diff --git a/packages/flow-schema/src/model.ts b/packages/flow-schema/src/model.ts index 6dc6169..aa00890 100644 --- a/packages/flow-schema/src/model.ts +++ b/packages/flow-schema/src/model.ts @@ -134,7 +134,14 @@ export function requiredExits(node: Node): string[] { } } -/** The spoken text, or `null` for an audio-asset prompt. */ +/** + * The *synthesizable* text: the string for a bare-text prompt the engine + * speaks with TTS, or `null` for an audio-asset prompt it plays as a clip. + * Drives playback branching and the length cap. An audio prompt's transcript + * is display text, not something to synthesize — for the human-readable words + * either kind speaks, use {@link promptTranscript}. Twin: `model_ext.rs` + * `Prompt::as_text`. + */ export function promptText(prompt: Prompt): string | null { return typeof prompt === 'string' ? prompt : null; } @@ -144,6 +151,20 @@ export function promptAudio(prompt: Prompt): string | null { return typeof prompt === 'string' ? null : prompt.audio; } +/** + * The human-readable words this prompt speaks, for display (a "what the + * caller hears" transcript) and traces, regardless of how it is voiced. A + * text prompt is its own transcript; an audio prompt carries the text it was + * synthesized from in `transcript`, `null` when the document omits it (older + * flows, or a ref not generated from text). Unlike {@link promptText}, never + * used to decide playback or enforce the length cap. Twin: `model_ext.rs` + * `Prompt::transcript`. + */ +export function promptTranscript(prompt: Prompt): string | null { + if (typeof prompt === 'string') return prompt; + return typeof prompt.transcript === 'string' ? prompt.transcript : null; +} + /** * A generated-clip ref is a `voice_prompts` id (`vprompt_`) — a clip the * platform produced and owns. Anything else in an `audio:` field is a ref the diff --git a/packages/flow-schema/test/prompt.test.ts b/packages/flow-schema/test/prompt.test.ts new file mode 100644 index 0000000..c2b0417 --- /dev/null +++ b/packages/flow-schema/test/prompt.test.ts @@ -0,0 +1,47 @@ +// Prompt accessors — the split between "text to synthesize" and "words the +// caller hears". `promptText`/`promptAudio` drive playback (synthesize vs. +// play a clip); `promptTranscript` is display/trace text that both kinds +// carry. Twin of the Rust `Prompt::as_text` / `Prompt::transcript` tests in +// `crates/wavekat-flow/src/model_ext.rs`. + +import { describe, expect, it } from 'vitest'; + +import { promptAudio, promptText, promptTranscript } from '../src/model.js'; +import type { Prompt } from '../src/model.js'; + +const textPrompt: Prompt = 'We are open Tuesday to Sunday, eleven to ten.'; +const audioWithText: Prompt = { + audio: 'vprompt_ab12cd34', + transcript: 'We are open Tuesday to Sunday, eleven to ten.', +}; +const audioWithoutText: Prompt = { audio: 'vprompt_ff00ee11' }; + +describe('promptText / promptAudio (playback branching)', () => { + it('a text prompt is synthesizable, has no audio ref', () => { + expect(promptText(textPrompt)).toBe(textPrompt); + expect(promptAudio(textPrompt)).toBeNull(); + }); + + it('an audio prompt is a clip, never synthesizable — even with a transcript', () => { + expect(promptText(audioWithText)).toBeNull(); + expect(promptAudio(audioWithText)).toBe('vprompt_ab12cd34'); + expect(promptText(audioWithoutText)).toBeNull(); + expect(promptAudio(audioWithoutText)).toBe('vprompt_ff00ee11'); + }); +}); + +describe('promptTranscript (display / trace text)', () => { + it('a text prompt is its own transcript', () => { + expect(promptTranscript(textPrompt)).toBe(textPrompt); + }); + + it('an audio prompt surfaces the text it was synthesized from', () => { + expect(promptTranscript(audioWithText)).toBe( + 'We are open Tuesday to Sunday, eleven to ten.', + ); + }); + + it('is null when an audio prompt carries no transcript', () => { + expect(promptTranscript(audioWithoutText)).toBeNull(); + }); +}); diff --git a/schema/README.md b/schema/README.md index bd72d4e..a6c1301 100644 --- a/schema/README.md +++ b/schema/README.md @@ -19,7 +19,8 @@ The schema owns **structure only**: - the field set and types of `Flow` and every node kind, - the `kind` discriminated union (`greeting`, `hours`, `menu`, `ring`, `message`, `transfer`, `hangup`), -- enums (`MessageTone`), the `Prompt` = `string | { audio }` shape, +- enums (`MessageTone`), the `Prompt` = `string | { audio, transcript? }` shape + (`transcript` is the optional text an audio clip was synthesized from), - required vs. optional fields and their defaults, - `schema_version` pinned to `1` (a document declaring another version does not validate against *this* file — it validates against that version's file). diff --git a/schema/flow.v1.schema.json b/schema/flow.v1.schema.json index 0f8e812..2fe2e66 100644 --- a/schema/flow.v1.schema.json +++ b/schema/flow.v1.schema.json @@ -51,6 +51,10 @@ "audio": { "description": "Audio asset ref. A generated-clip ref is a voice_prompts id matching ^vprompt_[a-z0-9]+$.", "type": "string" + }, + "transcript": { + "description": "The words this pre-rendered clip speaks — the text it was synthesized from — carried so a viewer can show what a caller hears, and for traces. Advisory only: playback always uses `audio`, never this. Optional and forward-added: absent on older documents and on refs the platform did not generate from text.", + "type": "string" } } }