feat(voice): complete Spike 2 voice pipeline with quality-tested prompt engine
Spike 2 delivers the full voice pipeline: queue → worker pool → sr-voice
child process (stdio JSONL) → cache → disk. Three rounds of quality testing
with Paula, Mellanie, and Gestalt produced iterative prompt improvements.
Prompt engine (prompt_builder.rs):
- Example-based epistemic marker integration (not keyword lists)
- Length-aware Angry tell variant (preserves facts on long content)
- Double-prompt technique: REMEMBER block repeats constraints near OUTPUT:
- Imperative injection framing (composition engine controls frequency)
- Anti-invention constraint ("do not add information not in the input")
- Universal RULES cleaned: worldbuilding moved to culture personas
Worker pool (worker.rs):
- Output post-processor strips after first newline (prevents prompt leakage)
- Watchdog poll loop (1s ticks) replaces blocking sleep for cancel
- Child health check before writing (try_wait)
Test infrastructure:
- voice_pipeline.rs: end-to-end test, auto-detects real sr-voice or mock
- voice_quality_batch.rs: 39 edge-case prompts for quality review
- mock-stdio.sh: Python JSONL mock for CI (no model needed)
- Makefile targets: test-voice-mock, test-voice-real
Quality results (Gemma 2B Q4_K_M, CPU ~13 t/s):
- Epistemic markers: naturally integrated (round 1 comma-lists fixed)
- Tell differentiation: 3/5 working (Nervous, Guarded, Angry)
- Information preservation: ~90% (up from ~70%)
- Prompt leakage: eliminated
- Open: Friendly/RoutineDeviation tells inert (#651), Factual bypass (#650)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,50 +33,73 @@ pub struct BuiltPrompt {
|
||||
pub injections_fired: Vec<usize>,
|
||||
}
|
||||
|
||||
/// Universal rules prefix — format constraints and negative injectors.
|
||||
/// Universal rules prefix — format and output constraints only.
|
||||
/// Worldbuilding and style constraints belong in the culture persona or
|
||||
/// the TASK section, not here.
|
||||
const RULES: &str = "\
|
||||
RULES: Output exactly one line of voiced text. \
|
||||
No explanation. No options. No markdown. No labels. Stop after one line.\n\n\
|
||||
CONSTRAINTS:\n\
|
||||
- Use occupational titles (shift lead, supervisor, foreman), not military ranks.\n\
|
||||
- Technology: insert (neural implant), span gate (FTL transit), \
|
||||
horizon gate (alien gate), the Reach (settled systems).\n\
|
||||
- No wit, quips, or wordplay. Humor is dry and rare.\n\
|
||||
- Do not reference Earth as a current place. Cultural heritage markers are natural.";
|
||||
OUTPUT CONSTRAINTS:\n\
|
||||
- Speak in complete sentences. Short ones. Cut words that don't pull weight \
|
||||
— but keep the sentence structure.\n\
|
||||
- Do not summarize. Keep all facts from the input. Shorten phrasing, not content.\n\
|
||||
- Do not add information that is not in the input. No invented details.\n\
|
||||
- NOT: \"Parts late. Behind.\" YES: \"Parts never came. We're a shift behind.\"";
|
||||
|
||||
/// Tell-state tone injectors (D-024 tell taxonomy).
|
||||
///
|
||||
/// Each tell category has a carefully worded tone modifier that influences
|
||||
/// the LLM output without naming the emotion. The model shows, not tells.
|
||||
/// Each tell category has a concrete syntactic instruction with an example,
|
||||
/// tuned for 2B model capacity. Behavioral descriptions alone ("a beat late")
|
||||
/// don't produce differentiated output at this model size — concrete surface
|
||||
/// patterns are needed.
|
||||
///
|
||||
/// Angry has a length-aware variant: on long content (16+ words), the default
|
||||
/// "make sentences shorter" instruction causes destructive compression that
|
||||
/// strips facts. Long-Angry instead preserves the full claim and focuses
|
||||
/// intensity on one sentence.
|
||||
fn tell_injector(category: TellCategory) -> &'static str {
|
||||
match category {
|
||||
TellCategory::Nervous => {
|
||||
"TELL-STATE: This character's words come slightly faster than usual, briefer. \
|
||||
They don't elaborate. A phrase drops off before it's finished. \
|
||||
Do not say they seem nervous or afraid."
|
||||
"TONE: Cut one clause from the sentence. Let a phrase trail off with a dash or ellipsis. \
|
||||
Example: \"Yeah, it's just — doesn't matter.\" \
|
||||
Do not say they seem nervous."
|
||||
}
|
||||
TellCategory::Angry => {
|
||||
"TELL-STATE: This character's words are measured and deliberate — not shouting, containing. \
|
||||
A word hits harder than the context requires. Do not say they seem angry."
|
||||
"TONE: Make sentences shorter and more deliberate. One word should hit harder than expected. \
|
||||
Example: \"Supervisor wants me in early.\" where \"wants\" carries weight. \
|
||||
Do not say they seem angry."
|
||||
}
|
||||
TellCategory::Friendly => {
|
||||
"TELL-STATE: This character offers slightly more than asked. \
|
||||
A word of genuine warmth lands casually. They don't perform friendliness — it just shows. \
|
||||
Do not add compliments or over-warmth."
|
||||
"TONE: Add one small extra detail or aside that wasn't strictly necessary. \
|
||||
Example: \"Inspection's tomorrow — should be fine, though.\" \
|
||||
Do not add compliments or forced warmth."
|
||||
}
|
||||
TellCategory::Guarded => {
|
||||
"TELL-STATE: This character chooses each word with a half-second more care than normal. \
|
||||
They answer what was asked, no more. There is nothing wrong here. \
|
||||
Do not say they seem guarded or evasive."
|
||||
"TONE: Use formal, precise words. Answer exactly what was asked, nothing extra. \
|
||||
Example: \"That's correct.\" instead of \"Yeah, exactly.\" \
|
||||
Do not say they seem guarded."
|
||||
}
|
||||
TellCategory::RoutineDeviation => {
|
||||
"TELL-STATE: This character is elsewhere in their mind. \
|
||||
They are present but preoccupied — answers are on track but land a beat late. \
|
||||
Do not explain why or name what they're thinking about."
|
||||
"TONE: Start the sentence on topic, then add a brief unfinished thought about something else. \
|
||||
Example: \"Pressure's fine. I was going to — anyway, it's logged.\" \
|
||||
Do not explain what they were thinking about."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Long-content variant for Angry tell. Used when base_text is 16+ words
|
||||
/// to prevent destructive compression that strips facts.
|
||||
const ANGRY_LONG: &str = "\
|
||||
TONE: Keep the full claim intact — do not cut facts. \
|
||||
Make one sentence land harder than the rest. \
|
||||
Example: \"Three reports filed. No budget. But they found half a million for the lounge.\" \
|
||||
Do not say they seem angry.";
|
||||
|
||||
/// Whether to use the long-content Angry variant.
|
||||
fn is_long_content(base_text: &str) -> bool {
|
||||
word_count(base_text) >= 16
|
||||
}
|
||||
|
||||
/// Known epistemic markers that must be preserved through re-voicing.
|
||||
///
|
||||
/// When the base text contains these phrases, the LLM is instructed to
|
||||
@@ -131,10 +154,15 @@ fn extract_epistemic_markers(base_text: &str) -> Vec<&'static str> {
|
||||
/// 1. Universal RULES prefix (format constraints, negative injectors)
|
||||
/// 2. Culture-specific PERSONA block (from `culture.voice_persona`)
|
||||
/// 3. Culture-specific examples
|
||||
/// 4. Occasional injections (rolled per-prompt via seeded RNG)
|
||||
/// 5. Tell-state tone modifier (only for medium/long content)
|
||||
/// 6. Epistemic marker protection
|
||||
/// 7. TASK + INPUT + OUTPUT: stop token
|
||||
/// 4. Tell-state tone modifier (only for medium/long content)
|
||||
/// 5. Epistemic marker protection (example-based, not keyword-list)
|
||||
/// 6. Occasional injections (imperative, positioned near TASK for 2B attention)
|
||||
/// 7. TASK + INPUT + repeated RULES reminder + OUTPUT: stop token
|
||||
///
|
||||
/// The prompt is structured so that the most important instructions appear
|
||||
/// both at the start and immediately before OUTPUT: (double-prompt technique).
|
||||
/// 2B models de-weight early prompt sections; repeating near the end anchors
|
||||
/// the instructions in the attention window.
|
||||
///
|
||||
/// `seed` should be deterministic per (npc_id, content_index, world_seed)
|
||||
/// so that the same prompt produces the same injection pattern on re-run.
|
||||
@@ -145,7 +173,7 @@ pub fn build_prompt(
|
||||
tell_state: Option<TellCategory>,
|
||||
seed: u64,
|
||||
) -> BuiltPrompt {
|
||||
let mut parts: Vec<String> = Vec::with_capacity(10);
|
||||
let mut parts: Vec<String> = Vec::with_capacity(16);
|
||||
let mut injections_fired: Vec<usize> = Vec::new();
|
||||
|
||||
// 1. Universal rules
|
||||
@@ -167,7 +195,47 @@ pub fn build_prompt(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Occasional injections — rolled by composition engine, not model
|
||||
// 4. Tell-state tone modifier (skip for short content — 2B model can't differentiate)
|
||||
if !is_short_content(base_text) {
|
||||
if let Some(tell) = tell_state {
|
||||
parts.push(String::new());
|
||||
// Angry on long content uses a special variant that preserves facts
|
||||
if tell == TellCategory::Angry && is_long_content(base_text) {
|
||||
parts.push(ANGRY_LONG.to_string());
|
||||
} else {
|
||||
parts.push(tell_injector(tell).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Epistemic marker protection — example-based, not keyword-list.
|
||||
// The old keyword-list approach ("must appear in the output: i heard, might have")
|
||||
// caused 2B models to emit markers as comma-separated lists. Example-based
|
||||
// integration teaches the model how to weave them into natural speech.
|
||||
let markers = extract_epistemic_markers(base_text);
|
||||
if !markers.is_empty() {
|
||||
parts.push(String::new());
|
||||
if markers.len() == 1 {
|
||||
parts.push(format!(
|
||||
"PRESERVE: The phrase \"{}\" carries specific meaning. \
|
||||
Use it naturally in the output as part of a sentence, not as a label. \
|
||||
Example: \"I heard they stopped the line — twice, apparently.\"",
|
||||
markers[0]
|
||||
));
|
||||
} else {
|
||||
let marker_list = markers.join("\", \"");
|
||||
parts.push(format!(
|
||||
"PRESERVE: The phrases \"{}\" carry specific meaning. \
|
||||
Weave them naturally into the output sentence. Do not list them. \
|
||||
Example: \"I heard they rerouted it. Might have been last cycle.\"",
|
||||
marker_list
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Occasional injections — imperative, positioned near TASK for 2B attention.
|
||||
// The composition engine already controls frequency — once an injection fires,
|
||||
// the model must execute it without discretion.
|
||||
let mut rng = ChaCha8Rng::seed_from_u64(seed);
|
||||
for (i, injection) in culture.occasional_injections.iter().enumerate() {
|
||||
// Gate off for suppressive tells
|
||||
@@ -179,42 +247,59 @@ pub fn build_prompt(
|
||||
|
||||
if rng.random::<f32>() < injection.frequency {
|
||||
parts.push(String::new());
|
||||
parts.push(injection.clause.clone());
|
||||
// Imperative framing — no "when" conditional, just "include this"
|
||||
parts.push(format!("INJECT: Include the phrase from this example in your output."));
|
||||
if let Some(ref example) = injection.example {
|
||||
parts.push(format!("INPUT: {}", example.input));
|
||||
parts.push(format!("OUTPUT: {}", example.output));
|
||||
} else {
|
||||
parts.push(injection.clause.clone());
|
||||
}
|
||||
injections_fired.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Tell-state tone modifier (skip for short content — 2B model can't differentiate)
|
||||
if !is_short_content(base_text) {
|
||||
if let Some(tell) = tell_state {
|
||||
parts.push(String::new());
|
||||
parts.push(tell_injector(tell).to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Epistemic marker protection
|
||||
let markers = extract_epistemic_markers(base_text);
|
||||
if !markers.is_empty() {
|
||||
parts.push(String::new());
|
||||
let marker_list = markers.join(", ");
|
||||
parts.push(format!(
|
||||
"PRESERVE: The following phrases must appear in the output: {}",
|
||||
marker_list
|
||||
));
|
||||
}
|
||||
|
||||
// 7. Task + input + output stop token
|
||||
// 7. Task + input + repeated rules reminder + output stop token
|
||||
let task_verb = match content_type {
|
||||
ContentType::Dialogue => "Re-voice",
|
||||
ContentType::Behavior => "Describe",
|
||||
ContentType::Dialogue => {
|
||||
"Re-voice the following in this character's voice. \
|
||||
Keep all facts. Use complete sentences"
|
||||
}
|
||||
ContentType::Behavior => {
|
||||
"Describe the following action as a third-person observer. \
|
||||
Preserve all individual actions in sequence. Do not extract conclusions"
|
||||
}
|
||||
};
|
||||
parts.push(String::new());
|
||||
parts.push(format!("TASK: {} the following in this character's voice.", task_verb));
|
||||
parts.push(format!("TASK: {}.", task_verb));
|
||||
parts.push(format!("INPUT: {}", base_text));
|
||||
|
||||
// Double-prompt: repeat the critical constraints immediately before OUTPUT:
|
||||
// to anchor them in the 2B model's attention window.
|
||||
let mut reminder = String::from("REMEMBER:");
|
||||
reminder.push_str(" Output exactly one line.");
|
||||
reminder.push_str(" Keep all facts from the input. Do not invent new details.");
|
||||
reminder.push_str(" Complete sentences, not fragments.");
|
||||
if !markers.is_empty() {
|
||||
reminder.push_str(&format!(
|
||||
" Use \"{}\" naturally in the sentence.",
|
||||
markers[0]
|
||||
));
|
||||
}
|
||||
if !injections_fired.is_empty() {
|
||||
// Remind about the first fired injection
|
||||
if let Some(inj) = culture
|
||||
.occasional_injections
|
||||
.get(*injections_fired.first().unwrap())
|
||||
{
|
||||
if let Some(ref ex) = inj.example {
|
||||
// Extract the key phrase from the example output
|
||||
let phrase = ex.output.split('.').next().unwrap_or(&ex.output);
|
||||
reminder.push_str(&format!(" Include a phrase like \"{}\".", phrase));
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.push(reminder);
|
||||
parts.push("OUTPUT:".to_string());
|
||||
|
||||
BuiltPrompt {
|
||||
@@ -379,7 +464,7 @@ mod tests {
|
||||
42,
|
||||
);
|
||||
// 3 words — should skip tell injector
|
||||
assert!(!result.prompt.contains("TELL-STATE:"));
|
||||
assert!(!result.prompt.contains("TONE:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -392,8 +477,8 @@ mod tests {
|
||||
Some(TellCategory::Nervous),
|
||||
42,
|
||||
);
|
||||
assert!(result.prompt.contains("TELL-STATE:"));
|
||||
assert!(result.prompt.contains("slightly faster than usual"));
|
||||
assert!(result.prompt.contains("TONE:"));
|
||||
assert!(result.prompt.contains("trail off"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -205,18 +205,23 @@ impl VoicePipe {
|
||||
|
||||
// Watchdog: kill the child if it doesn't respond within the timeout.
|
||||
// This unblocks the read_line below (stdout closes → read returns empty).
|
||||
// Uses a poll loop (1s ticks) so the watchdog exits promptly on cancel.
|
||||
let child_id = self.child.id();
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let cancel_clone = Arc::clone(&cancel);
|
||||
let timeout_secs = INFERENCE_TIMEOUT.as_secs();
|
||||
let watchdog = std::thread::spawn(move || {
|
||||
std::thread::sleep(INFERENCE_TIMEOUT);
|
||||
if !cancel_clone.load(Ordering::Relaxed) {
|
||||
tracing::warn!(pid = child_id, "sr-voice inference timeout — killing child");
|
||||
let _ = Command::new("kill")
|
||||
.arg("-9")
|
||||
.arg(child_id.to_string())
|
||||
.output();
|
||||
for _ in 0..timeout_secs {
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
if cancel_clone.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
tracing::warn!(pid = child_id, "sr-voice inference timeout — killing child");
|
||||
let _ = Command::new("kill")
|
||||
.arg("-9")
|
||||
.arg(child_id.to_string())
|
||||
.output();
|
||||
});
|
||||
|
||||
let mut response_line = String::new();
|
||||
@@ -241,7 +246,16 @@ impl VoicePipe {
|
||||
|
||||
body["text"]
|
||||
.as_str()
|
||||
.map(|s| s.trim().to_string())
|
||||
.map(|s| {
|
||||
// Post-processor: strip everything after the first newline.
|
||||
// Prevents prompt leakage (survey bleed, example continuation)
|
||||
// that 2B models sometimes produce after the first valid line.
|
||||
let trimmed = s.trim();
|
||||
match trimmed.find('\n') {
|
||||
Some(pos) => trimmed[..pos].trim().to_string(),
|
||||
None => trimmed.to_string(),
|
||||
}
|
||||
})
|
||||
.ok_or_else(|| "response missing 'text' field".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user