the dialogue session belongs to one reach, not to the box (V-45)

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.
This commit is contained in:
2026-08-05 13:32:24 +04:00
parent b5b599e287
commit 9d80a39a30
9 changed files with 124 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 { if h.phraser == nil {
return "поговорили." return "поговорили."
} }
history := h.chatHistory() history := h.chatHistory(ctx)
// The phraser hands back its own fallback text alongside the error, so the // 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. // turn survives a dead server and the failure still reaches the log.
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history) 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 { for _, r := range spoken {
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text}) 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 return tasks.FormatRU(ranked), true
} }
+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 { func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
if h.dialogueSessions != nil { if h.dialogueSessions != nil {
now := h.now() now := h.now()
prev := h.dialogueSessions.Get(voiceDialogueID, now) prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
dec = followUpMerge(prev, dec, now) dec = followUpMerge(prev, dec, now)
h.rememberTurn(prev, dec, now) h.rememberTurn(ctx, prev, dec, now)
} }
reply := h.applyAction(ctx, dec) reply := h.applyAction(ctx, dec)
if reply == "" { 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 // 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 // inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
// one long conversation can't grow the session unboundedly. // 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 var history []dialogue.Turn
if prev != nil { if prev != nil {
history = append(history, dialogue.Turn{ 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) { if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
slots.Text = dec.Utterance slots.Text = dec.Utterance
} }
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{
Intent: dialogue.Intent(dec.Intent), Intent: dialogue.Intent(dec.Intent),
Slots: slots, Slots: slots,
Timestamp: now, Timestamp: now,
+4 -2
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"testing" "testing"
"time" "time"
@@ -149,12 +150,13 @@ func TestRememberTurnRefreshesTheTopic(t *testing.T) {
now: func() time.Time { return contNow }, now: func() time.Time { return contNow },
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
} }
h.rememberTurn(nil, router.Decision{ ctx := context.Background()
h.rememberTurn(ctx, nil, router.Decision{
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча", Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
}, contNow) }, contNow)
// The second turn arrives with the first turn's Text already merged in. // The second turn arrives with the first turn's Text already merged in.
prev := h.dialogueSessions.Get(voiceDialogueID, contNow) prev := h.dialogueSessions.Get(voiceDialogueID, contNow)
h.rememberTurn(prev, router.Decision{ h.rememberTurn(ctx, prev, router.Decision{
Intent: router.IntentQuery, Intent: router.IntentQuery,
Utterance: "какие у меня планы", Utterance: "какие у меня планы",
Slots: router.Slots{Text: "во сколько у меня встреча"}, 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" "github.com/kami/maven/internal/router"
) )
// voiceDialogueID — the dialogue-session key for the microphone, and the // voiceDialogueID — the dialogue-session and clarify key for the microphone.
// clarify key for it too. This is a single-user box (ponytail), so one slot // This is a single-user box (ponytail), so one slot per reach suffices; a
// suffices; a second speaker would need per-speaker ids, which waits on // second speaker would need per-speaker ids, which waits on voice-print
// voice-print attribution (see PROGRESS multi-user deferral). // attribution (see PROGRESS multi-user deferral).
const voiceDialogueID = "voice" const voiceDialogueID = "voice"
// textDialogueID — the clarify key for a text turn that named no conversation. // 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 // 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 // from it. Best effort: no session store, or a session that expired between the
// question and the answer, means the words route normally. // 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 { if h.dialogueSessions == nil || len(cands) == 0 {
return return
} }
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands) h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), cands)
} }
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against // resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
// the list she just read. // 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 { if h.dialogueSessions == nil {
return "", false return "", false
} }
sess := h.dialogueSessions.Get(voiceDialogueID, h.now()) sess := h.dialogueSessions.Get(dialogueIDOf(ctx), h.now())
if sess == nil || len(sess.Candidates) == 0 { if sess == nil || len(sess.Candidates) == 0 {
return "", false 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. // a sentence, and the second half is the next turn.
return pick.Label, true 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) log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
return "не получилось изменить задачу.", true return "не получилось изменить задачу.", true
} }
// Spent: the list she read is no longer the list, and a second ordinal // Spent: the list she read is no longer the list, and a second ordinal
// against it would close the wrong task. // 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) log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
return say + ": " + pick.Label, true return say + ": " + pick.Label, true
} }
+14 -12
View File
@@ -38,7 +38,7 @@ func TestParseOrdinalReadsThePosition(t *testing.T) {
func TestOrdinalPassesWithNothingOffered(t *testing.T) { func TestOrdinalPassesWithNothingOffered(t *testing.T) {
h, _, _ := newClarifyHandler(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") t.Error("an ordinal with no list behind it was claimed")
} }
} }
@@ -47,14 +47,14 @@ func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
ctx := context.Background() ctx := context.Background()
ids := seedTasks(t, st, "купить хлеб", "позвонить маме") 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, "позвонить маме") { if !handled || !strings.Contains(reply, "позвонить маме") {
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled) 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. // 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") t.Error("the list was spent by a read-back")
} }
} }
@@ -63,9 +63,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
ctx := context.Background() ctx := context.Background()
ids := seedTasks(t, st, "купить хлеб", "позвонить маме") 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, "купить хлеб") { if !handled || !strings.Contains(reply, "купить хлеб") {
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled) 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 // Spent: a second ordinal against a list that no longer holds would close
// the wrong task. // 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") 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) { func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
h, st, _ := newClarifyHandler(t) h, st, _ := newClarifyHandler(t)
ids := seedTasks(t, st, "купить хлеб") 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") { if !handled || !strings.Contains(reply, "1") {
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled) 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 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)) cands := make([]dialogue.Candidate, 0, len(ids))
for i, id := range ids { for i, id := range ids {
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]}) cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
} }
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()}) h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{Timestamp: h.now()})
h.offerCandidates(cands) h.offerCandidates(ctx, cands)
} }
+8 -7
View File
@@ -132,8 +132,9 @@ type reactiveHandler struct {
// The production dateparser will replace StubDateTimeParser here too. // The production dateparser will replace StubDateTimeParser here too.
timeParser router.DateTimeParser timeParser router.DateTimeParser
// dialogueSessions carries slots across turns for follow-ups (single-user // dialogueSessions carries slots across turns for follow-ups. Keyed by the
// box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over. // 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 dialogueSessions *dialogue.SessionStore
// clarifyStore parks the request behind an open question she asked (see // 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 // just read (ordinal.go). Before routing, and only when a list is actually
// bound to the session: with nothing offered, "второй" is an ordinary word // bound to the session: with nothing offered, "второй" is an ordinary word
// and keeps routing. // and keeps routing.
if reply, handled := h.resolveCandidate(ctx, text); handled { if reply, handled := h.resolveCandidate(ctx, text, src); handled {
return withNotice(expiredNotice, reply) return withNotice(expiredNotice, reply)
} }
@@ -329,7 +330,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
) )
now := h.now() now := h.now()
if h.dialogueSessions != nil { if h.dialogueSessions != nil {
prev = h.dialogueSessions.Get(voiceDialogueID, now) prev = h.dialogueSessions.Get(dialogueIDOf(ctx), now)
} }
cont := false cont := false
if dec, cont = continuationDecision(prev, text, now); cont { 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) dec = followUpMerge(prev, dec, now)
} }
if !dec.Clarify { 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 // chatHistory collects dialogue turns from the session store for the current
// conversation. Returns prior user utterances (newest last) up to a depth of // conversation. Returns prior user utterances (newest last) up to a depth of
// 4 turns. Returns nil when there's no session or no history. // 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 { if h.dialogueSessions == nil {
return nil return nil
} }
now := h.now() now := h.now()
prev := h.dialogueSessions.Get(voiceDialogueID, now) prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
if prev == nil { if prev == nil {
return nil return nil
} }