Files
Maven/cmd/mavend/followup.go
T
claude 9d80a39a30 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.
2026-08-05 13:32:24 +04:00

149 lines
5.6 KiB
Go

package main
import (
"context"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// 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.
// Separate from the mic: an old client that sends no id still must not answer
// a question she asked out loud.
const textDialogueID = "text"
// dialogueKey — the context key carrying the id of the conversation this turn
// belongs to. It rides the context rather than a parameter for the same reason
// the correlation id does: every step of the turn needs it, most of them only
// to hand to the next one, and threading it by hand would put it in six
// clarify signatures that have nothing else to say about it.
type dialogueKey struct{}
// dialogueIDFor builds the id a turn is held under: the conversation the reach
// named, qualified by the tap it arrived on, or the tap's own fallback when it
// named none.
//
// A parked clarifying question used to be held under voiceDialogueID no matter
// where the turn came from, so one unanswerable question captured the next
// three utterances from anywhere. Three independent curl sessions fed a
// capture attempt that had already failed, and a reminder among them was lost
// (Vikunja #466).
func dialogueIDFor(src turnSource, conversation string) string {
if conversation != "" {
return string(src) + ":" + conversation
}
if src == sourceVoice {
return voiceDialogueID
}
return textDialogueID
}
// withDialogueID tags a turn with that id.
func withDialogueID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, dialogueKey{}, id)
}
// dialogueIDOf reads it back. Falls back to the microphone's slot, which is
// what an unthreaded caller — a test, an internal replay — gets.
func dialogueIDOf(ctx context.Context) string {
if id, ok := ctx.Value(dialogueKey{}).(string); ok && id != "" {
return id
}
return voiceDialogueID
}
// toDialogueSlots and applyDialogueSlots are the only bridge between
// router.Slots and dialogue.Slots. dialogue must not import router (import
// cycle), so the two structs are hand-kept copies and every field has to be
// carried by hand here. Adding a field to either struct without adding it to
// BOTH functions loses a slot silently — nothing fails to build. The tests in
// slotsparity_test.go fail when the field sets or the converters stop matching;
// when they do, fix these two functions, not the tests.
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
func toDialogueSlots(s router.Slots) dialogue.Slots {
return dialogue.Slots{
Time: s.Time,
HasTime: s.HasTime,
Key: s.Key,
Value: s.Value,
HasKey: s.HasKey,
Text: s.Text,
Fn: s.Fn,
Args: s.Args,
HasFn: s.HasFn,
}
}
// applyDialogueSlots writes dialogue slots back onto router slots.
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
base.Time, base.HasTime = d.Time, d.HasTime
base.Key, base.Value, base.HasKey = d.Key, d.Value, d.HasKey
base.Text = d.Text
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
return base
}
// anaphoraResolver is a shared instance for pronoun detection.
var anaphoraResolver router.AnaphoraResolver
// followUpMerge fills the current turn's missing slots from a prior
// non-expired session — the multi-turn seam. It handles three cases:
//
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior).
// 2. Cross-intent anaphora: if the current utterance contains a pronoun
// ("это" / "он" / "она" etc.) AND the prior session has a key, inherit
// the key for fact-lookup queries and reminder creation.
// 3. Query after Fact: a query that references the prior fact's subject
// inherits the key so the handler can do a fact-by-key lookup.
//
// A clarify turn resolves nothing, so it never inherits. InheritSlots only
// fills gaps, so a fully-slotted current turn is unaffected.
func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) router.Decision {
if prev == nil || dec.Clarify || prev.IsExpired(now) {
return dec
}
// Case 1: same-intent inheritance (existing).
if prev.Intent == dialogue.Intent(dec.Intent) {
merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots))
dec.Slots = applyDialogueSlots(dec.Slots, merged)
return dec
}
// Cases 2 & 3: cross-intent anaphora + query-after-fact.
// A query after a fact may reference the fact's subject by pronoun.
_, isAnaphoric := anaphoraResolver.Resolve(dec.Utterance)
if !isAnaphoric && !dec.Slots.HasKey {
// No anaphora and no explicit key — this is a truly new topic.
return dec
}
// Inherit key from the prior session's key when the current utterance
// refers to it (anaphora) or when a query follows a fact.
switch {
case dec.Intent == router.IntentQuery && prev.Slots.HasKey:
dec.Slots.Key = prev.Slots.Key
dec.Slots.HasKey = true
if prev.Slots.HasTime {
dec.Slots.Time = prev.Slots.Time
dec.Slots.HasTime = true
}
case dec.Intent == router.IntentReminder && prev.Slots.HasKey && isAnaphoric:
dec.Slots.Key = prev.Slots.Key
dec.Slots.HasKey = true
case dec.Intent == router.IntentFact && !dec.Slots.HasKey && prev.Slots.HasKey && isAnaphoric:
dec.Slots.Key = prev.Slots.Key
dec.Slots.HasKey = true
}
return dec
}