From 9d80a39a30a5feabc3237380b9173e87878c0f9b Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 13:32:24 +0400 Subject: [PATCH 1/2] the dialogue session belongs to one reach, not to the box (V-45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clarify store was keyed per reach in V-466. The dialogue session was not: five call sites read and wrote the constant voiceDialogueID, so anaphora, history and the ordinal candidate list were one slot for the whole daemon. The candidate list is the half that cost something. She recites tasks at the mic, he types "первую сделал" on /chat, and it closes the second task he heard out loud on a surface that never showed him a list. Now every one of those sites reads dialogueIDOf(ctx), which handleText and the voice path already set. resolveCandidate also wrote resolved_by "tap:voice" for every pick, including a typed one. It takes the turn's source now. A row that lies about where it came from is worse than no row. Anaphora across surfaces was the other reading — one continuous conversation with her, any surface. Rejected: a phone open while he talks is the case this box hits, and two clients sharing one slot trample each other. --- cmd/mavend/actions.go | 2 +- cmd/mavend/actions_task.go | 2 +- cmd/mavend/clarify.go | 8 +-- cmd/mavend/continuation_test.go | 6 ++- cmd/mavend/dialogue_surface_test.go | 82 +++++++++++++++++++++++++++++ cmd/mavend/followup.go | 8 +-- cmd/mavend/ordinal.go | 12 ++--- cmd/mavend/ordinal_test.go | 26 ++++----- cmd/mavend/voice.go | 15 +++--- 9 files changed, 124 insertions(+), 37 deletions(-) create mode 100644 cmd/mavend/dialogue_surface_test.go diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index c8d0b95..9ef1f20 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -61,7 +61,7 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s if h.phraser == nil { return "поговорили." } - history := h.chatHistory() + history := h.chatHistory(ctx) // The phraser hands back its own fallback text alongside the error, so the // turn survives a dead server and the failure still reaches the log. reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history) diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go index 1da0148..69462ce 100644 --- a/cmd/mavend/actions_task.go +++ b/cmd/mavend/actions_task.go @@ -80,7 +80,7 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, for _, r := range spoken { cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text}) } - h.offerCandidates(cands) + h.offerCandidates(ctx, cands) return tasks.FormatRU(ranked), true } diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index d6efb09..c9188d2 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -305,9 +305,9 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string { if h.dialogueSessions != nil { now := h.now() - prev := h.dialogueSessions.Get(voiceDialogueID, now) + prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now) dec = followUpMerge(prev, dec, now) - h.rememberTurn(prev, dec, now) + h.rememberTurn(ctx, prev, dec, now) } reply := h.applyAction(ctx, dec) if reply == "" { @@ -323,7 +323,7 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi // rememberTurn stores this turn as the dialogue session the next follow-up // inherits from, carrying up to 4 prior turns of history for anaphora. Capped so // one long conversation can't grow the session unboundedly. -func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decision, now time.Time) { +func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) { var history []dialogue.Turn if prev != nil { history = append(history, dialogue.Turn{ @@ -359,7 +359,7 @@ func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decisi if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) { slots.Text = dec.Utterance } - h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ + h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{ Intent: dialogue.Intent(dec.Intent), Slots: slots, Timestamp: now, diff --git a/cmd/mavend/continuation_test.go b/cmd/mavend/continuation_test.go index a087eca..e8cab3c 100644 --- a/cmd/mavend/continuation_test.go +++ b/cmd/mavend/continuation_test.go @@ -1,6 +1,7 @@ package main import ( + "context" "testing" "time" @@ -149,12 +150,13 @@ func TestRememberTurnRefreshesTheTopic(t *testing.T) { now: func() time.Time { return contNow }, dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), } - h.rememberTurn(nil, router.Decision{ + ctx := context.Background() + h.rememberTurn(ctx, nil, router.Decision{ Intent: router.IntentQuery, Utterance: "во сколько у меня встреча", }, contNow) // The second turn arrives with the first turn's Text already merged in. prev := h.dialogueSessions.Get(voiceDialogueID, contNow) - h.rememberTurn(prev, router.Decision{ + h.rememberTurn(ctx, prev, router.Decision{ Intent: router.IntentQuery, Utterance: "какие у меня планы", Slots: router.Slots{Text: "во сколько у меня встреча"}, diff --git a/cmd/mavend/dialogue_surface_test.go b/cmd/mavend/dialogue_surface_test.go new file mode 100644 index 0000000..68c9b42 --- /dev/null +++ b/cmd/mavend/dialogue_surface_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/kami/maven/internal/router" +) + +// The list she read at the mic is not the list a browser is looking at +// (Vikunja #45 step 3). The clarify store was keyed per reach in #466; the +// dialogue session was still one slot for the box, so "второй" typed on the web +// closed the second task she had recited out loud. +func TestCandidatesDoNotCrossReaches(t *testing.T) { + h, st, _ := newClarifyHandler(t) + voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, "")) + webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web")) + + ids := seedTasks(t, st, "купить хлеб", "позвонить маме") + putCandidates(h, voiceCtx, ids, "купить хлеб", "позвонить маме") + + if reply, handled := h.resolveCandidate(webCtx, "первую сделал", sourceText); handled { + t.Fatalf("a web turn picked from the list she read aloud: %q", reply) + } + live, err := st.ListTasks(context.Background(), "live") + if err != nil { + t.Fatalf("list tasks: %v", err) + } + if len(live) != 2 { + t.Fatalf("%d tasks live, want 2 — the web turn moved one", len(live)) + } + // The reach that was offered the list still owns it. + if _, handled := h.resolveCandidate(voiceCtx, "первую сделал", sourceVoice); !handled { + t.Fatal("the mic lost its own list") + } +} + +// A selection writes a fact, so the fact must name the reach the words arrived +// on. It said "tap:voice" for a typed turn. +func TestCandidateProvenanceFollowsTheReach(t *testing.T) { + h, st, _ := newClarifyHandler(t) + ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web")) + ids := seedTasks(t, st, "купить хлеб") + putCandidates(h, ctx, ids, "купить хлеб") + + if _, handled := h.resolveCandidate(ctx, "первую сделал", sourceText); !handled { + t.Fatal("the pick was not acted on") + } + done, err := st.ListTasks(context.Background(), "done") + if err != nil { + t.Fatalf("list tasks: %v", err) + } + if len(done) != 1 { + t.Fatalf("%d tasks done, want 1", len(done)) + } + if by := done[0].ResolvedBy; by != string(sourceText) { + t.Errorf("resolved_by = %q, want %q", by, sourceText) + } +} + +// Anaphora is per reach too: an ellipsis typed on the web must not continue the +// question he asked at the mic. Both surfaces stay usable at once, which is the +// case a single-owner box actually hits — a phone open while he talks. +func TestAnaphoraDoesNotCrossReaches(t *testing.T) { + h, _, _ := newClarifyHandler(t) + voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, "")) + webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web")) + now := h.now() + + h.rememberTurn(voiceCtx, nil, router.Decision{ + Intent: router.IntentQuery, Utterance: "во сколько у меня встреча", + }, now) + + if sess := h.dialogueSessions.Get(dialogueIDOf(webCtx), now); sess != nil { + t.Fatalf("the web reach inherited the mic's turn: %+v", sess) + } + sess := h.dialogueSessions.Get(dialogueIDOf(voiceCtx), now) + if sess == nil || !strings.Contains(sess.Slots.Text, "встреча") { + t.Fatalf("the mic lost its own turn: %+v", sess) + } +} diff --git a/cmd/mavend/followup.go b/cmd/mavend/followup.go index 2ec0f49..7da65fe 100644 --- a/cmd/mavend/followup.go +++ b/cmd/mavend/followup.go @@ -8,10 +8,10 @@ import ( "github.com/kami/maven/internal/router" ) -// voiceDialogueID — the dialogue-session key for the microphone, and the -// clarify key for it too. This is a single-user box (ponytail), so one slot -// suffices; a second speaker would need per-speaker ids, which waits on -// voice-print attribution (see PROGRESS multi-user deferral). +// voiceDialogueID — the dialogue-session and clarify key for the microphone. +// This is a single-user box (ponytail), so one slot per reach suffices; a +// second speaker would need per-speaker ids, which waits on voice-print +// attribution (see PROGRESS multi-user deferral). const voiceDialogueID = "voice" // textDialogueID — the clarify key for a text turn that named no conversation. diff --git a/cmd/mavend/ordinal.go b/cmd/mavend/ordinal.go index 6b2cf61..7b28f77 100644 --- a/cmd/mavend/ordinal.go +++ b/cmd/mavend/ordinal.go @@ -97,20 +97,20 @@ func parseCandidateVerb(text string) (status, say string, ok bool) { // offerCandidates records the list she just read, so his next words can pick // from it. Best effort: no session store, or a session that expired between the // question and the answer, means the words route normally. -func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) { +func (h *reactiveHandler) offerCandidates(ctx context.Context, cands []dialogue.Candidate) { if h.dialogueSessions == nil || len(cands) == 0 { return } - h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands) + h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), cands) } // resolveCandidate handles "второй", "первую сделал", "последнюю убери" against // the list she just read. -func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) { +func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src turnSource) (string, bool) { if h.dialogueSessions == nil { return "", false } - sess := h.dialogueSessions.Get(voiceDialogueID, h.now()) + sess := h.dialogueSessions.Get(dialogueIDOf(ctx), h.now()) if sess == nil || len(sess.Candidates) == 0 { return "", false } @@ -133,13 +133,13 @@ func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (st // a sentence, and the second half is the next turn. return pick.Label, true } - if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil { + if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), string(src)); err != nil { log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err) return "не получилось изменить задачу.", true } // Spent: the list she read is no longer the list, and a second ordinal // against it would close the wrong task. - h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil) + h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil) log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status) return say + ": " + pick.Label, true } diff --git a/cmd/mavend/ordinal_test.go b/cmd/mavend/ordinal_test.go index cf63bd0..564319d 100644 --- a/cmd/mavend/ordinal_test.go +++ b/cmd/mavend/ordinal_test.go @@ -38,7 +38,7 @@ func TestParseOrdinalReadsThePosition(t *testing.T) { func TestOrdinalPassesWithNothingOffered(t *testing.T) { h, _, _ := newClarifyHandler(t) - if _, handled := h.resolveCandidate(context.Background(), "второй"); handled { + if _, handled := h.resolveCandidate(context.Background(), "второй", sourceVoice); handled { t.Error("an ordinal with no list behind it was claimed") } } @@ -47,14 +47,14 @@ func TestOrdinalReadsBackWithoutAVerb(t *testing.T) { h, st, _ := newClarifyHandler(t) ctx := context.Background() ids := seedTasks(t, st, "купить хлеб", "позвонить маме") - putCandidates(h, ids, "купить хлеб", "позвонить маме") + putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме") - reply, handled := h.resolveCandidate(ctx, "второй") + reply, handled := h.resolveCandidate(ctx, "второй", sourceVoice) if !handled || !strings.Contains(reply, "позвонить маме") { t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled) } // Still live: naming one is often the first half of a sentence. - if _, handled := h.resolveCandidate(ctx, "первый"); !handled { + if _, handled := h.resolveCandidate(ctx, "первый", sourceVoice); !handled { t.Error("the list was spent by a read-back") } } @@ -63,9 +63,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) { h, st, _ := newClarifyHandler(t) ctx := context.Background() ids := seedTasks(t, st, "купить хлеб", "позвонить маме") - putCandidates(h, ids, "купить хлеб", "позвонить маме") + putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме") - reply, handled := h.resolveCandidate(ctx, "первую сделал") + reply, handled := h.resolveCandidate(ctx, "первую сделал", sourceVoice) if !handled || !strings.Contains(reply, "купить хлеб") { t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled) } @@ -80,7 +80,7 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) { } // Spent: a second ordinal against a list that no longer holds would close // the wrong task. - if _, handled := h.resolveCandidate(ctx, "второй"); handled { + if _, handled := h.resolveCandidate(ctx, "второй", sourceVoice); handled { t.Error("the list survived the pick it was spent on") } } @@ -88,9 +88,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) { func TestOrdinalPastTheEndSaysHowMany(t *testing.T) { h, st, _ := newClarifyHandler(t) ids := seedTasks(t, st, "купить хлеб") - putCandidates(h, ids, "купить хлеб") + putCandidates(h, context.Background(), ids, "купить хлеб") - reply, handled := h.resolveCandidate(context.Background(), "третий") + reply, handled := h.resolveCandidate(context.Background(), "третий", sourceVoice) if !handled || !strings.Contains(reply, "1") { t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled) } @@ -118,11 +118,13 @@ func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 { return ids } -func putCandidates(h *reactiveHandler, ids []int64, labels ...string) { +// putCandidates binds a list to the reach the ctx names, the way queryTasks +// does when she recites one. +func putCandidates(h *reactiveHandler, ctx context.Context, ids []int64, labels ...string) { cands := make([]dialogue.Candidate, 0, len(ids)) for i, id := range ids { cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]}) } - h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()}) - h.offerCandidates(cands) + h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{Timestamp: h.now()}) + h.offerCandidates(ctx, cands) } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 7cf1410..fe12f19 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -132,8 +132,9 @@ type reactiveHandler struct { // The production dateparser will replace StubDateTimeParser here too. timeParser router.DateTimeParser - // dialogueSessions carries slots across turns for follow-ups (single-user - // box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over. + // dialogueSessions carries slots across turns for follow-ups. Keyed by the + // reach the turn arrived on (dialogueIDOf), like the clarify store: one + // slot per reach, not one for the box. nil ⇒ no carry-over. dialogueSessions *dialogue.SessionStore // clarifyStore parks the request behind an open question she asked (see @@ -313,7 +314,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour // just read (ordinal.go). Before routing, and only when a list is actually // bound to the session: with nothing offered, "второй" is an ordinary word // and keeps routing. - if reply, handled := h.resolveCandidate(ctx, text); handled { + if reply, handled := h.resolveCandidate(ctx, text, src); handled { return withNotice(expiredNotice, reply) } @@ -329,7 +330,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour ) now := h.now() if h.dialogueSessions != nil { - prev = h.dialogueSessions.Get(voiceDialogueID, now) + prev = h.dialogueSessions.Get(dialogueIDOf(ctx), now) } cont := false if dec, cont = continuationDecision(prev, text, now); cont { @@ -361,7 +362,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour dec = followUpMerge(prev, dec, now) } if !dec.Clarify { - h.rememberTurn(prev, dec, now) + h.rememberTurn(ctx, prev, dec, now) } } @@ -492,12 +493,12 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) // chatHistory collects dialogue turns from the session store for the current // conversation. Returns prior user utterances (newest last) up to a depth of // 4 turns. Returns nil when there's no session or no history. -func (h *reactiveHandler) chatHistory() []dialogue.Turn { +func (h *reactiveHandler) chatHistory(ctx context.Context) []dialogue.Turn { if h.dialogueSessions == nil { return nil } now := h.now() - prev := h.dialogueSessions.Get(voiceDialogueID, now) + prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now) if prev == nil { return nil } From dd63180e44b2be4e6b4ffaa9053386b9c6e289ed Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 13:32:35 +0400 Subject: [PATCH 2/2] the chat path answers with no llama-server (V-45) Step 4 of the QA list, pinned as a test rather than checked by hand: the deploy has llama-server up and stopping it to look is not available here. Both halves of a turn call the model. The cascade falls to the classifier and the replier falls to the stub, and each was covered separately by a stubbed error value. This wires a real client at a closed port so a dial error walks the whole path, and asserts three utterances still come back with words. Also pins that daemonAPI.Chat errors only when the voice path was never wired, which is what keeps mavweb's /api/chat off its error branch when the model is down. mavweb never returns 500 there in any case: it redirects to /chat. --- cmd/mavend/chat_degrade_test.go | 50 +++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 cmd/mavend/chat_degrade_test.go diff --git a/cmd/mavend/chat_degrade_test.go b/cmd/mavend/chat_degrade_test.go new file mode 100644 index 0000000..c376aa0 --- /dev/null +++ b/cmd/mavend/chat_degrade_test.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/llm" + "github.com/kami/maven/internal/router" +) + +// The chat path must answer when llama-server is down (Vikunja #45 step 4). +// Both halves of a turn call the model — the router and the replier — and each +// has its own floor: the cascade falls to the classifier, the replier falls to +// the stub. This wires a client at a closed port so both floors are exercised +// by a dial error rather than by a stubbed error value. +func TestChatAnswersWithNoLlamaServer(t *testing.T) { + h, _, _ := newClarifyHandler(t) + dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond) + emb := router.NewHashEmbedder(1024) + h.recall.embedder = emb + h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead)) + h.replier = newLLMReplier(dead, nil) + + ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web")) + for _, utt := range []string{ + "привет", + "запиши что кофе закончился", + "что у меня сегодня", + } { + reply := h.handleText(ctx, "web", utt) + if reply == "" { + t.Errorf("%q answered with nothing; a dead model must degrade to the stub", utt) + } + } +} + +// daemonAPI.Chat reports an error only when the voice path was never wired. +// A turn that reaches handleText always carries text, which is what keeps +// mavweb's /api/chat off its error branch when the model is down. +func TestChatAPIErrsOnlyWhenUnwired(t *testing.T) { + d := &daemonAPI{} + if _, err := d.Chat(context.Background(), "web", "привет"); err == nil { + t.Fatal("an unwired daemon must say so") + } + d.chatFn = func(context.Context, string, string) string { return "" } + if _, err := d.Chat(context.Background(), "web", "привет"); err != nil { + t.Fatalf("a wired daemon must not error: %v", err) + } +}