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:
@@ -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