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.
This commit is contained in:
2026-08-06 22:50:38 +04:00
parent 661b5c1099
commit 1607009215
5 changed files with 18 additions and 13 deletions
+1 -1
View File
@@ -571,7 +571,7 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
} }
reply := h.applyAction(ctx, dec) reply := h.applyAction(ctx, dec)
if reply == "" { if reply == "" {
reply = h.replier.Reply(dec) reply = h.replier.Reply(ctx, dec)
} }
if reply == "" { if reply == "" {
// Belt: an empty reply here would be a silent drop. // Belt: an empty reply here would be a silent drop.
+4 -4
View File
@@ -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 // 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. // 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 { if d.Clarify {
// The deck, not the stub's single sentence: a clarify she cannot turn // 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, // 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 "я выпил воды". // что ты выпел стакан воды" for "я выпил воды".
return phraser.FactAck(d.Utterance) return phraser.FactAck(d.Utterance)
} }
out, err := r.p.PhraseReply(context.Background(), d) out, err := r.p.PhraseReply(ctx, d)
if err != nil || out == "" { 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 // The persona checks, on the live path (personaguard.go). A reply that
// leaks reasoning or calls him "вы" is worse than a flat one. // leaks reasoning or calls him "вы" is worse than a flat one.
if _, ok := guardSpoken("reply", out); !ok { if _, ok := guardSpoken("reply", out); !ok {
return r.stub.Reply(d) return r.stub.Reply(ctx, d)
} }
return out return out
} }
+5 -5
View File
@@ -22,7 +22,7 @@ func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) {
func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) { func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil) 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 != "записала, кофе закончился" { if got != "записала, кофе закончился" {
t.Errorf("got %q, want %q", 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. // the clarify deck rather than the stub's single sentence.
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) { func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil) 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 == "я всё поняла" { if got == "я всё поняла" {
t.Fatal("a clarify must not be phrased by the model") 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) t.Errorf("on clarify: got %q, want %q", got, want)
} }
// Two different misses do not sound identical. // 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") 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. // produce, which is the same claim without pinning one wording.
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) { func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
t.Helper() 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) 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) { func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
t.Helper() 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 { if got != want {
t.Errorf("on %s: got %q, want stub %q", what, got, want) t.Errorf("on %s: got %q, want stub %q", what, got, want)
} }
+1 -1
View File
@@ -458,7 +458,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// 9. replier — phrase the reply across the router decision. // 9. replier — phrase the reply across the router decision.
if replyText == "" { if replyText == "" {
replyText = h.replier.Reply(dec) replyText = h.replier.Reply(ctx, dec)
} }
return withNotice(expiredNotice, replyText) return withNotice(expiredNotice, replyText)
} }
+7 -2
View File
@@ -26,6 +26,8 @@
package voice package voice
import ( import (
"context"
"github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
) )
@@ -38,8 +40,10 @@ import (
// decision's Intent + Slots + Clarify. The Intent largely names the reply // decision's Intent + Slots + Clarify. The Intent largely names the reply
// shape (act/reminder/fact/note/query/clarify); the Slots carry the // shape (act/reminder/fact/note/query/clarify); the Slots carry the
// specifics that personalise it ("got it: water at 14:00"). // 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 { 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; // 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 // Reply dispatches on Intent + Clarify. Each branch is short; the LLM impl
// will replace this with prompted text and the same dispatch shape. // 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 { if d.Clarify {
return "не совсем поняла — можешь переформулировать?" return "не совсем поняла — можешь переформулировать?"
} }