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:
2026-03-07 19:20:06 +01:00
co-authored by Claude Opus 4.6
parent e93a9e8b70
commit 9f34d030d7
6 changed files with 1201 additions and 65 deletions
+22 -8
View File
@@ -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())
}
}