// 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 "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"). type Replier interface { Reply(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. func (s *StubReplier) Reply(d router.Decision) string { if d.Clarify { return "не совсем поняла — можешь переформулировать?" } switch d.Intent { case router.IntentAct: if !d.Slots.HasFn { return "не могу это сделать — не разобрала действие." } return "ок, записала действие: " + d.Slots.Fn case router.IntentReminder: if d.Slots.HasTime { return "напомню." } return "напомню." case router.IntentFact: if d.Slots.HasKey { if d.Slots.Value != "" { return "отметила: " + d.Slots.Key + " = " + d.Slots.Value } return "отметила: " + d.Slots.Key } return "записала факт." case router.IntentNote: return "сохранила заметку." case router.IntentQuery: return "это пока не подключено — чтение из памяти появится позже." default: return "приняла." } }