Files
Maven/internal/voice/replier.go
T
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00

85 lines
3.6 KiB
Go

// 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 "приняла."
}
}