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/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) + } +} 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 }