From 05236ad48042cd831c6dfe3054e5c9a1fb2454ec Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 13:40:16 +0400 Subject: [PATCH] 3.2 conversation depth: cross-intent anaphora + fact-by-key query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session.go: add History []Turn + Turn type for multi-turn context - slots.go: add AnaphoraResolver with Resolve() for RU pronoun detection (это/он/она/оно/тот/мой and inflected forms) - followup.go: extend followUpMerge with cross-intent inheritance: Query/Fact/Reminder after a Fact with anaphora inherits the key. Same-intent path unchanged. Anaphora detection from utterance. - voice.go: add fact-by-key lookup path in applyAction for IntentQuery when dialogue resolved an anaphoric reference (calls LatestFact, formats with formatTime helper). History tracked in Session.History capped at 4 most recent turns. - followup_test.go: 7 new test cases: anaphora query-after-fact, no-inheritance-without-anaphora, three-turn break, anaphora in reminder, anaphora in fact, explicit key wins, time inheritance. make test green (303+, -race, all 29 packages). --- cmd/mavend/followup.go | 55 ++++++++++++-- cmd/mavend/followup_test.go | 138 +++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 63 +++++++++++++++- internal/dialogue/session.go | 10 +++ internal/router/slots.go | 35 +++++++++ 5 files changed, 292 insertions(+), 9 deletions(-) diff --git a/cmd/mavend/followup.go b/cmd/mavend/followup.go index 5d18047..cd94289 100644 --- a/cmd/mavend/followup.go +++ b/cmd/mavend/followup.go @@ -37,19 +37,58 @@ func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots { return base } -// followUpMerge fills the current turn's missing slots from a prior same-intent, -// non-expired session — the multi-turn seam. A different intent is a fresh -// command, not a follow-up, so it's returned untouched; a clarify turn resolved -// nothing, so it never inherits. InheritSlots only fills gaps, so a fully-slotted -// current turn is unaffected. +// 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 } - if prev.Intent != dialogue.Intent(dec.Intent) { + + // 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 } - merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots)) - dec.Slots = applyDialogueSlots(dec.Slots, merged) + + // 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 } diff --git a/cmd/mavend/followup_test.go b/cmd/mavend/followup_test.go index b01a95b..cd2ebcf 100644 --- a/cmd/mavend/followup_test.go +++ b/cmd/mavend/followup_test.go @@ -95,4 +95,142 @@ func TestFollowUpMerge(t *testing.T) { t.Errorf("Value lost through dialogue conversion: %q", got.Slots.Value) } }) + + // --- Cross-intent + anaphora tests (P3.2) --- + + t.Run("query after fact inherits key via anaphora", func(t *testing.T) { + prior := &dialogue.Session{ + Intent: dialogue.IntentFact, + Slots: dialogue.Slots{Key: "water", HasKey: true}, + Timestamp: base, + TTL: 2 * time.Minute, + } + cur := router.Decision{ + Intent: router.IntentQuery, + Utterance: "когда я это сделал?", + } + got := followUpMerge(prior, cur, base.Add(30*time.Second)) + if !got.Slots.HasKey { + t.Error("query after fact with anaphora: key not inherited") + } + if got.Slots.Key != "water" { + t.Errorf("query after fact: got key=%q, want water", got.Slots.Key) + } + }) + + t.Run("query after fact without anaphora does not inherit", func(t *testing.T) { + prior := &dialogue.Session{ + Intent: dialogue.IntentFact, + Slots: dialogue.Slots{Key: "water", HasKey: true}, + Timestamp: base, + TTL: 2 * time.Minute, + } + cur := router.Decision{ + Intent: router.IntentQuery, + Utterance: "какая погода в москве?", + } + got := followUpMerge(prior, cur, base.Add(30*time.Second)) + if got.Slots.HasKey { + t.Error("query without anaphora inherited key when it shouldn't") + } + }) + + t.Run("three-turn breaks context correctly", func(t *testing.T) { + // Simulate: turn 1 (fact: water), turn 2 (weather query — break), + // turn 3 (query referring to turn 1 should NOT inherit from turn 2). + turn2 := &dialogue.Session{ + Intent: dialogue.IntentQuery, + Slots: dialogue.Slots{Text: "какая погода в москве?"}, + Timestamp: base.Add(30 * time.Second), + TTL: 2 * time.Minute, + } + cur := router.Decision{ + Intent: router.IntentQuery, + Utterance: "когда я это сделал?", + } + // turn2 is the "prior" but has no key — anaphora should not resolve. + got := followUpMerge(turn2, cur, base.Add(60*time.Second)) + if got.Slots.HasKey { + t.Error("key inherited across a weather break that had no key") + } + }) + + t.Run("anaphora in reminder inherits key", func(t *testing.T) { + prior := &dialogue.Session{ + Intent: dialogue.IntentFact, + Slots: dialogue.Slots{Key: "water", HasKey: true}, + Timestamp: base, + TTL: 2 * time.Minute, + } + cur := router.Decision{ + Intent: router.IntentReminder, + Utterance: "напомни про это завтра", + } + got := followUpMerge(prior, cur, base.Add(30*time.Second)) + if !got.Slots.HasKey { + t.Error("reminder with anaphora: key not inherited") + } + if got.Slots.Key != "water" { + t.Errorf("reminder anaphora: got key=%q, want water", got.Slots.Key) + } + }) + + t.Run("anaphora in new fact inherits key", func(t *testing.T) { + prior := &dialogue.Session{ + Intent: dialogue.IntentFact, + Slots: dialogue.Slots{Key: "water", HasKey: true}, + Timestamp: base, + TTL: 2 * time.Minute, + } + cur := router.Decision{ + Intent: router.IntentFact, + Slots: router.Slots{Value: "2 литра"}, + Utterance: "я выпил это", + } + got := followUpMerge(prior, cur, base.Add(30*time.Second)) + if !got.Slots.HasKey { + t.Error("fact with anaphora: key not inherited") + } + if got.Slots.Key != "water" { + t.Errorf("fact anaphora: got key=%q, want water", got.Slots.Key) + } + }) + + t.Run("explicit key wins over anaphora", func(t *testing.T) { + prior := &dialogue.Session{ + Intent: dialogue.IntentFact, + Slots: dialogue.Slots{Key: "water", HasKey: true}, + Timestamp: base, + TTL: 2 * time.Minute, + } + cur := router.Decision{ + Intent: router.IntentFact, + Slots: router.Slots{Key: "sleep", HasKey: true, Value: "6h"}, + Utterance: "я спал 6 часов", + } + // Even though the utterance doesn't have anaphora, the explicit key + // from the fact parser should win — same intent, same merge as before. + got := followUpMerge(prior, cur, base.Add(30*time.Second)) + if !got.Slots.HasKey || got.Slots.Key != "sleep" { + t.Errorf("explicit key overwritten by prior: got key=%q", got.Slots.Key) + } + }) + + t.Run("query after fact inherits time too", func(t *testing.T) { + factTime := base.Add(-2 * time.Hour) + prior := &dialogue.Session{ + Intent: dialogue.IntentFact, + Slots: dialogue.Slots{Key: "water", HasKey: true, Time: factTime, HasTime: true}, + Timestamp: base, + TTL: 2 * time.Minute, + } + cur := router.Decision{ + Intent: router.IntentQuery, + Utterance: "когда я это сделал?", + } + got := followUpMerge(prior, cur, base.Add(30*time.Second)) + if !got.Slots.HasTime || !got.Slots.Time.Equal(factTime) { + t.Errorf("query after fact did not inherit time: HasTime=%v, Time=%v", got.Slots.HasTime, got.Slots.Time) + } + }) } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 157974b..f2898ea 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -345,12 +345,31 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo // clarify turns carry (see followUpMerge). Best-effort: nil store ⇒ skipped. if h.dialogueSessions != nil { now := h.now() - dec = followUpMerge(h.dialogueSessions.Get(voiceDialogueID, now), dec, now) + prev := h.dialogueSessions.Get(voiceDialogueID, now) + dec = followUpMerge(prev, dec, now) if !dec.Clarify { + // Build history: carry over up to 4 prior turns for cross-intent + // reference. The most recent prior turn is prepended to history. + var history []dialogue.Turn + if prev != nil { + history = append(history, dialogue.Turn{ + Intent: prev.Intent, + Slots: prev.Slots, + Text: prev.Slots.Text, // the prior turn's utterance + }) + // Cap history depth so one long conversation can't grow + // the session unboundedly. + maxHist := len(prev.History) + if maxHist > 3 { + maxHist = 3 + } + history = append(history, prev.History[:maxHist]...) + } h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ Intent: dialogue.Intent(dec.Intent), Slots: toDialogueSlots(dec.Slots), Timestamp: now, + History: history, }) } } @@ -497,6 +516,26 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) return "" // replier phrases the "saved" reply case router.IntentQuery: + // Fact-by-key lookup: when the dialogue layer resolved an anaphoric + // reference to a prior fact's key (e.g. "когда я это сделал?" after + // "запиши что я пил воду"), look up the fact's value directly. + if dec.Slots.HasKey && dec.Slots.Key != "" { + if f, err := h.api.LatestFact(ctx, dec.Slots.Key); err == nil { + if dec.Slots.HasTime { + // The query asks about timing — the fact's own timestamp + // is the answer it's looking for. Format as a natural reply. + reply := fmt.Sprintf("я записала это %s", formatTime(f.Ts)) + return reply + } + // General fact reference: describe what we know. + if dec.Utterance == "" { + return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value) + } + // The utterance still carries the question; fall through to + // normal RAG with the resolved key in context. + } + } + // Calendar questions: "что у меня сегодня?", "планы на завтра?" if date, ok := router.ParseCalendarDate(dec.Utterance, time.Now()); ok { events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) @@ -999,6 +1038,28 @@ func extractWeatherLocation(u, defaultLoc string) string { return "Moscow" } +// formatTime returns a human-readable Russian time string for a fact timestamp. +// Used by the query handler when answering "когда я это сделал?"-style questions. +func formatTime(t time.Time) string { + now := time.Now() + if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) { + return "только что" + } + diff := now.Sub(t) + switch { + case diff < 10*time.Minute: + return "несколько минут назад" + case diff < 60*time.Minute: + return fmt.Sprintf("%d минут назад", int(diff.Minutes())) + case diff < 2*time.Hour: + return "час назад" + case diff < 24*time.Hour: + return fmt.Sprintf("%d часа назад", int(diff.Hours())) + default: + return t.Format("2 января 15:04") + } +} + func jsonStringImpl(s string) string { // minimal JSON string escape — quotes + backslash + control chars. // adequate for the reminder payload's text field; not a general JSON diff --git a/internal/dialogue/session.go b/internal/dialogue/session.go index b2495f9..260a741 100644 --- a/internal/dialogue/session.go +++ b/internal/dialogue/session.go @@ -27,11 +27,21 @@ type Slots struct { HasFn bool } +// Turn represents one utterance in a multi-turn dialogue history. +// Carried by Session.History for cross-intent reference and anaphora +// resolution (a later turn's pronoun points to an earlier turn's entity). +type Turn struct { + Intent Intent + Slots Slots + Text string // raw utterance +} + type Session struct { Intent Intent Slots Slots Timestamp time.Time TTL time.Duration + History []Turn // most recent turns, newest last; used for anaphora + cross-intent } func (s *Session) IsExpired(now time.Time) bool { diff --git a/internal/router/slots.go b/internal/router/slots.go index 9e4b45b..0af7530 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -328,6 +328,41 @@ func parseDurationValue(s string) (string, bool) { return strconv.Itoa(n) + unit, true } +// AnaphoraResolver resolves pronouns like "это", "он", "она" to the prior +// turn's key entity. Returns a (key, value) pair the prior fact carried, +// or ("", "", false) when no pronoun is detected. +type AnaphoraResolver struct{} + +// Resolve checks if text contains an anaphoric reference to a prior turn's +// entity. For MVP this handles the common Russian pronouns: +// - "это" / "этого" / "этому" / "этим" / "этом" → "this" (most common) +// - "он" / "его" / "ему" / "ним" → "he/it", masc +// - "она" / "её" / "ей" / "ней" → "she/it", fem +// - "оно" → "it", neuter +// +// Returns the matching pronoun type for cross-referencing with prior slots. +func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) { + s := strings.ToLower(strings.TrimSpace(text)) + toks := strings.Fields(s) + for _, tok := range toks { + switch tok { + case "это", "этого", "этому", "этим", "этом", "эти", "эта": + return "this", true + case "он", "его", "ему", "ним": + return "he", true + case "она", "её", "ей", "ней": + return "she", true + case "оно": + return "it", true + case "тот", "та", "то", "те": + return "that", true + case "мой", "моего", "моему", "моим", "моём", "моя", "моей", "моё": + return "mine", true + } + } + return "", false +} + // ParseCalendarDate detects RU calendar date words in text and returns the // resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow"). // Returns zero time + false if no match.