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), // except a reminder time the current sentence named and the parser missed. // 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) { // A reminder that named an hour nobody could read must not borrow the // last one's. Two reminders in a row and the second landed at the // first's time, confirmed as if it had been read from the sentence: // "напомни без четверти восемь выходить" fired at 07:30 (V-543). The // hour is also what fills before the action's own fallback parse can // run, so inheriting it hid a time that did parse. // // Inheriting is still right when the sentence names no time at all, // which is the follow-up this seam exists for. blockTime := dec.Intent == router.IntentReminder && !dec.Slots.HasTime && router.MentionsTime(dec.Utterance) merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots)) dec.Slots = applyDialogueSlots(dec.Slots, merged) if blockTime { dec.Slots.Time, dec.Slots.HasTime = time.Time{}, false } 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 }