Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions conformance/v1/valid/audio-prompt-transcript.expected.json
Original file line number Diff line number Diff line change
@@ -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": [] }
}
15 changes: 15 additions & 0 deletions conformance/v1/valid/audio-prompt-transcript.yaml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions crates/wavekat-flow/schema/flow.v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
85 changes: 83 additions & 2 deletions crates/wavekat-flow/src/model_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"));
}
}
4 changes: 4 additions & 0 deletions packages/flow-schema/src/generated/flow.v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/flow-schema/src/generated/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
/**
Expand Down
4 changes: 4 additions & 0 deletions packages/flow-schema/src/generated/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
23 changes: 22 additions & 1 deletion packages/flow-schema/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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_<hex>`) — a clip the
* platform produced and owns. Anything else in an `audio:` field is a ref the
Expand Down
47 changes: 47 additions & 0 deletions packages/flow-schema/test/prompt.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 2 additions & 1 deletion schema/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions schema/flow.v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
Expand Down
Loading