Merge the chat QA: per-reach dialogue session, degrade test (V-45)

This commit is contained in:
2026-08-05 13:32:55 +04:00
10 changed files with 174 additions and 37 deletions
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
}
+50
View File
@@ -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)
}
}
+4 -4
View File
@@ -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,
+4 -2
View File
@@ -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: "во сколько у меня встреча"},
+82
View File
@@ -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)
}
}
+4 -4
View File
@@ -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.
+6 -6
View File
@@ -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
}
+14 -12
View File
@@ -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)
}
+8 -7
View File
@@ -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
}