Skip to content
Closed
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
31 changes: 24 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/wavekat-flow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ anyhow = "1"
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# `source` edits a document's text in place, so it needs each value's
# position, which serde_yaml_ng doesn't expose. Only the event parser is
# used (pure Rust, two small deps). Pinned: 0.2.2's markers count chars,
# not the bytes its docs say, and `source` relies on that (its non-ASCII
# tests catch a change).
saphyr-parser = "=0.2.2"
serde_yaml_ng = "0.10"
thiserror = "2"
time = { version = "0.3", features = ["macros", "parsing"] }
Expand Down
5 changes: 3 additions & 2 deletions crates/wavekat-flow/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ this crate.
|--------|--------------|
| `model` (re-exported at root) | Generated document types (`Flow`, `Node`, `Prompt`, …) plus hand-written helpers (`Flow::from_yaml`, `Node::exits`, …) |
| `validate` | Semantic validation: reachability, caller traps, exit-set exactness, DTMF and prompt rules |
| `source` | Find and rewrite prompts in a document's YAML text without disturbing comments or layout: voice text prompts as clips, swap local recordings for uploaded clips |
| `hours` | Business-hours / timezone math (IANA zones, holiday exceptions) |
| `engine` | The flow interpreter + the `FlowEffects` async trait your runtime implements |
| `trace` | Serializable run traces emitted by the engine |
Expand Down Expand Up @@ -97,8 +98,8 @@ meantime, which is the safe direction.

## One format, two languages

This crate is the Rust half of the format. Authoring, comment-preserving
edits, and the TypeScript twin of the validator live in
This crate is the Rust half of the format. General-purpose comment-preserving
edits (rename, add or remove a node, rewire an exit) and the TypeScript twin of the validator live in
[`@wavekat/flow-schema`](https://www.npmjs.com/package/@wavekat/flow-schema).
The schema, the shared conformance corpus, and the full design rationale live
in the [`wavekat-flow` repository](https://github.com/wavekat/wavekat-flow).
Expand Down
10 changes: 8 additions & 2 deletions crates/wavekat-flow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
//! [`trace`] output) alongside the generated model, adapted to the generated
//! types and pinned by the shared conformance corpus. The engine owns the
//! [`engine::FlowEffects`] trait *definition*; the daemon keeps its live impl
//! in its own codebase. Comment-preserving mutation stays TS-only.
//! in its own codebase. [`source`] finds and rewrites prompts in a document's text without
//! disturbing comments or layout, for Rust authoring tools; the TS side's
//! general-purpose comment-preserving edits stay in `mutate.ts`.

/// The generated document model (`Flow`, `Node`, `Prompt`, …), emitted
/// from the schema by `typify` into `OUT_DIR/flow_types.rs`.
Expand All @@ -32,11 +34,15 @@ mod model_ext;
pub mod book;
pub mod engine;
pub mod hours;
pub mod source;
pub mod trace;
pub mod validate;

pub use model::*;
pub use model_ext::{required_assets, NodeId};
pub use model_ext::{
is_generated_clip_ref, is_local_audio_ref, prompt_fields, required_assets, NodeId,
PROMPT_FIELDS,
};

/// The normative JSON Schema (draft 2020-12) for format version 1, as a
/// string, bundled so consumers can run structural validation without
Expand Down
80 changes: 80 additions & 0 deletions crates/wavekat-flow/src/model_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,60 @@ pub fn required_assets(flow: &Flow) -> Vec<String> {
refs.into_iter().collect()
}

/// Every node field that can hold a prompt, across all kinds.
pub const PROMPT_FIELDS: &[&str] = &["prompt", "confirm_prompt"];

/// The fields a node of `kind` speaks a prompt from, in the order
/// [`Node::prompts`] returns them, or `None` for a kind this build doesn't
/// know. For callers working on the source text, where the typed tree isn't
/// available (a draft that doesn't parse as a [`Flow`] yet). Keep in step
/// with [`Node::prompts`]; the corpus test `source_prompts_match_model`
/// fails when they differ.
pub fn prompt_fields(kind: &str) -> Option<&'static [&'static str]> {
match kind {
"greeting" | "menu" | "message" | "hangup" => Some(&PROMPT_FIELDS[..1]),
"book" => Some(PROMPT_FIELDS),
"hours" | "ring" | "transfer" => Some(&[]),
_ => None,
}
}

/// A generated-clip ref is a `voice_prompts` id (`vprompt_<alnum>`), a clip
/// the platform produced and owns. Anything else in an `audio:` field is a
/// ref the platform does *not* own. Twin: `model.ts` `isGeneratedClipRef`.
pub fn is_generated_clip_ref(reference: &str) -> bool {
let Some(rest) = reference
.get(..8)
.filter(|p| p.eq_ignore_ascii_case("vprompt_"))
.map(|_| &reference[8..])
else {
return false;
};
!rest.is_empty() && rest.chars().all(|c| c.is_ascii_alphanumeric())
}

/// Audio file extensions an author may point an `audio:` at.
const LOCAL_AUDIO_EXTENSIONS: &[&str] = &[
".wav", ".wave", ".mp3", ".ogg", ".oga", ".opus", ".flac", ".m4a", ".aac", ".mp4", ".webm",
];

/// Does an `audio:` value name a file on the author's machine, to be
/// uploaded before the flow is saved? A path separator or an audio file
/// extension makes it a file; a generated clip ref never is. Any other ref
/// (a clip shipped with the device, say) is neither, and is left alone.
pub fn is_local_audio_ref(reference: &str) -> bool {
let r = reference.trim();
if r.is_empty() || is_generated_clip_ref(r) {
return false;
}
let lower = r.to_ascii_lowercase();
r.contains('/')
|| r.contains('\\')
|| LOCAL_AUDIO_EXTENSIONS
.iter()
.any(|ext| lower.ends_with(ext))
}

impl Prompt {
/// 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.
Expand Down Expand Up @@ -188,6 +242,32 @@ impl Flow {
mod tests {
use crate::model::Prompt;

#[test]
fn generated_clip_refs() {
use super::is_generated_clip_ref as clip;
assert!(clip("vprompt_ab12cd34"));
assert!(clip("VPROMPT_AB12"));
assert!(!clip("vprompt_"));
assert!(!clip("vprompt_ab-12"));
assert!(!clip("vprompt_ab12.wav"));
assert!(!clip("shipped_ref"));
assert!(!clip("vprompt"));
assert!(!clip("vpromp€_x"));
}

#[test]
fn local_audio_refs() {
use super::is_local_audio_ref as local;
assert!(local("./a.wav"));
assert!(local("a.WAV"));
assert!(local("dir/a"));
assert!(local("C:\\clips\\a"));
assert!(local("song.mp3"));
assert!(!local("vprompt_12ab"));
assert!(!local("shipped_ref"));
assert!(!local(" "));
}

#[test]
fn as_text_is_synthesizable_text_only() {
// A bare-string prompt is TTS the engine speaks.
Expand Down
Loading
Loading