// voice/replier.go — reactive reply phrasing. // // A SEPARATE seam from internal/phraser.Phraser: // // - Phraser phrases DELIVERIES — the loop's nudges + reminders. Its output // (Body + Summary) is consumed by the dispatcher and shipped to ALL the // routed channels (voice gets Body, away channels get Summary). The // Phraser owns the multiple-of-many-channel output contract. // // - Replier phrases REPLIES — the one text a voice round-trip says back to // the user who just spoke. Reactive, single-channel (voice), no // dispatcher involvement. The reply text goes through TTS to a single // audio clip played on the originating client; nothing routes elsewhere. // // Both seams feed TTS in production but at different times: Phraser for // proactive nudges (loop tick → voicesink → tts → push), Replier for // reactive round-trips (client audio → stt → router → action → replier → // tts → response). Splitting the seam keeps the Phraser interface stable // (the project's tests mock it; adding a method would break them) and // keeps the LLM-backed impl cleanly focused: production phraser = // personality-prompted "nudge tone", production replier = "chat tone", // different prompts in the same model server. // // The Stub is the deterministic floor; production swaps in an LLM impl at // the daemon seam (config wiring, no CoreAPI or voice-package change). package voice import ( "context" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) // Replier — the reactive reply phrasing seam. The daemon's reactive handler // calls Reply with the router's Decision; the impl produces a terse reply // the handler passes to TTS and ships back to the originating client. // // The reply is per-Decision (one per reactive turn); the impl sees the // decision's Intent + Slots + Clarify. The Intent largely names the reply // shape (act/reminder/fact/note/query/clarify); the Slots carry the // specifics that personalise it ("got it: water at 14:00"). // The context is the turn's, and it is the only bound an LLM-backed impl has // besides the phraser timeout (V-638). A floor impl ignores it. type Replier interface { Reply(ctx context.Context, d router.Decision) string } // StubReplier — the deterministic, no-model floor. Canned per intent; // slots incorporated as plain strings (the LLM impl will natural-language // them; the Stub keeps it readable). The same instinct as the phraser // Stub: a SCAFFOLD GROUND TRUTH so tests + the daemon end-to-end have // deterministic replies; the LLM impl swaps in at the daemon wiring. type StubReplier struct{} // NewStubReplier builds the floor replier. func NewStubReplier() *StubReplier { return &StubReplier{} } // Reply dispatches on Intent + Clarify. Each branch is short; the LLM impl // will replace this with prompted text and the same dispatch shape. // It makes no model call, so the context is unused. func (s *StubReplier) Reply(_ context.Context, d router.Decision) string { if d.Clarify { return "не совсем поняла — можешь переформулировать?" } switch d.Intent { case router.IntentAct: if !d.Slots.HasFn { return "не могу это сделать — не разобрала действие." } return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn}) case router.IntentReminder: return phraser.Ack(phraser.AckReminder, nil) case router.IntentFact: // His words, not the key the parser filed them under (V-592). The key // is machine vocabulary — "water", "meal" — and reading it back was // never a confirmation he could check. return phraser.FactAck(d.Utterance) case router.IntentNote: return phraser.Ack(phraser.AckNote, nil) case router.IntentQuery: return "поискала в заметках — ничего не нашла." case router.IntentChat: return "поговорили." // stub — LLMReplier replaces this default: return phraser.Ack(phraser.AckGeneric, nil) } }