From 160700921599df7e6138924b9fc4e12013a2b898 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 22:50:38 +0400 Subject: [PATCH] the reply seam takes the turn's context (V-638) voice.Replier.Reply had no context, so llmReplier phrased under context.Background() and the only bound on a reply was phraser.timeout, 60s in deploy. Both call sites already held a context. The stub ignores it: it makes no model call. --- cmd/mavend/clarify.go | 2 +- cmd/mavend/replier_llm.go | 8 ++++---- cmd/mavend/replier_llm_test.go | 10 +++++----- cmd/mavend/voice.go | 2 +- internal/voice/replier.go | 9 +++++++-- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index bb87835..1271b8d 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -571,7 +571,7 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi } reply := h.applyAction(ctx, dec) if reply == "" { - reply = h.replier.Reply(dec) + reply = h.replier.Reply(ctx, dec) } if reply == "" { // Belt: an empty reply here would be a silent drop. diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index cde52e2..36f0d7f 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -22,7 +22,7 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier { // Reply never fails: a clarify, a model error and an unusable generation all // answer from the stub, which is what keeps a turn from breaking on the model. -func (r *llmReplier) Reply(d router.Decision) string { +func (r *llmReplier) Reply(ctx context.Context, d router.Decision) string { if d.Clarify { // The deck, not the stub's single sentence: a clarify she cannot turn // into a question is the line he hears most often when she misses him, @@ -39,14 +39,14 @@ func (r *llmReplier) Reply(d router.Decision) string { // что ты выпел стакан воды" for "я выпил воды". return phraser.FactAck(d.Utterance) } - out, err := r.p.PhraseReply(context.Background(), d) + out, err := r.p.PhraseReply(ctx, d) if err != nil || out == "" { - return r.stub.Reply(d) + return r.stub.Reply(ctx, d) } // The persona checks, on the live path (personaguard.go). A reply that // leaks reasoning or calls him "вы" is worse than a flat one. if _, ok := guardSpoken("reply", out); !ok { - return r.stub.Reply(d) + return r.stub.Reply(ctx, d) } return out } diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go index 027670c..3b3a32c 100644 --- a/cmd/mavend/replier_llm_test.go +++ b/cmd/mavend/replier_llm_test.go @@ -22,7 +22,7 @@ func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) { r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil) - got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) + got := r.Reply(context.Background(), router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) if got != "записала, кофе закончился" { t.Errorf("got %q, want %q", got, "записала, кофе закончился") } @@ -42,7 +42,7 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) { // the clarify deck rather than the stub's single sentence. func TestLLMReplierClarifyReadsTheDeck(t *testing.T) { r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil) - got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"}) + got := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "мгм"}) if got == "я всё поняла" { t.Fatal("a clarify must not be phrased by the model") } @@ -50,7 +50,7 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) { t.Errorf("on clarify: got %q, want %q", got, want) } // Two different misses do not sound identical. - if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got { + if same := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "а"}); same == got { t.Log("two utterances hashed to the same line, which is allowed but should be rare") } } @@ -60,14 +60,14 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) { // produce, which is the same claim without pinning one wording. func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) { t.Helper() - if got := r.Reply(d); !phraser.IsAck(key, nil, got) { + if got := r.Reply(context.Background(), d); !phraser.IsAck(key, nil, got) { t.Errorf("on %s: got %q, want a %q line", what, got, key) } } func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) { t.Helper() - got, want := r.Reply(d), voice.NewStubReplier().Reply(d) + got, want := r.Reply(context.Background(), d), voice.NewStubReplier().Reply(context.Background(), d) if got != want { t.Errorf("on %s: got %q, want stub %q", what, got, want) } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 4e12bce..d148ac9 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -458,7 +458,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // 9. replier — phrase the reply across the router decision. if replyText == "" { - replyText = h.replier.Reply(dec) + replyText = h.replier.Reply(ctx, dec) } return withNotice(expiredNotice, replyText) } diff --git a/internal/voice/replier.go b/internal/voice/replier.go index ef05de0..0d52515 100644 --- a/internal/voice/replier.go +++ b/internal/voice/replier.go @@ -26,6 +26,8 @@ package voice import ( + "context" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -38,8 +40,10 @@ import ( // 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(d router.Decision) string + Reply(ctx context.Context, d router.Decision) string } // StubReplier — the deterministic, no-model floor. Canned per intent; @@ -54,7 +58,8 @@ 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 { +// It makes no model call, so the context is unused. +func (s *StubReplier) Reply(_ context.Context, d router.Decision) string { if d.Clarify { return "не совсем поняла — можешь переформулировать?" }