5187f3bd14
"я выпил воды" came back as "Проверила, что ты выпел стакан воды". The verb is not a Russian word, the glass was never mentioned, and nothing had been checked. The store was right throughout: DefaultFactParser files this as key=water value="drank", and no row anywhere held "стакан". Every Russian word in that sentence was generated. replyContext hands the model "записала факт: water \"drank\"", so the model had nothing to phrase FROM and reached for the nearest plausible sentence — the example in ReplySystemPrompt, which was literally "Записала, что ты выпил стакан воды." So the fact path stops generating, the way the note payload did in V-576. The confirmation is a fixed deck frame with his own sentence in it, in both repliers, and the prompt example is contentless now. The stub also read the parser's KEY back at him, which is machine vocabulary he never said. The clarify half of this — a fact clarified out of "запиши" answers with "запиши" and nothing else — lands with V-593, which touches the same lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
84 lines
3.7 KiB
Go
84 lines
3.7 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/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").
|
|
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 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)
|
|
}
|
|
}
|