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