3.2 conversation depth: cross-intent anaphora + fact-by-key query

- 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).
This commit is contained in:
kami
2026-07-06 13:40:16 +04:00
parent b7eb53a3b4
commit 05236ad480
5 changed files with 292 additions and 9 deletions
+47 -8
View File
@@ -37,19 +37,58 @@ func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
return base return base
} }
// followUpMerge fills the current turn's missing slots from a prior same-intent, // anaphoraResolver is a shared instance for pronoun detection.
// non-expired session — the multi-turn seam. A different intent is a fresh var anaphoraResolver router.AnaphoraResolver
// 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 // followUpMerge fills the current turn's missing slots from a prior
// current turn is unaffected. // 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 { func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) router.Decision {
if prev == nil || dec.Clarify || prev.IsExpired(now) { if prev == nil || dec.Clarify || prev.IsExpired(now) {
return dec 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 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 return dec
} }
+138
View File
@@ -95,4 +95,142 @@ func TestFollowUpMerge(t *testing.T) {
t.Errorf("Value lost through dialogue conversion: %q", got.Slots.Value) 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)
}
})
} }
+62 -1
View File
@@ -345,12 +345,31 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
// clarify turns carry (see followUpMerge). Best-effort: nil store ⇒ skipped. // clarify turns carry (see followUpMerge). Best-effort: nil store ⇒ skipped.
if h.dialogueSessions != nil { if h.dialogueSessions != nil {
now := h.now() 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 { 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{ h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
Intent: dialogue.Intent(dec.Intent), Intent: dialogue.Intent(dec.Intent),
Slots: toDialogueSlots(dec.Slots), Slots: toDialogueSlots(dec.Slots),
Timestamp: now, Timestamp: now,
History: history,
}) })
} }
} }
@@ -497,6 +516,26 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision)
return "" // replier phrases the "saved" reply return "" // replier phrases the "saved" reply
case router.IntentQuery: 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: "что у меня сегодня?", "планы на завтра?" // Calendar questions: "что у меня сегодня?", "планы на завтра?"
if date, ok := router.ParseCalendarDate(dec.Utterance, time.Now()); ok { if date, ok := router.ParseCalendarDate(dec.Utterance, time.Now()); ok {
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
@@ -999,6 +1038,28 @@ func extractWeatherLocation(u, defaultLoc string) string {
return "Moscow" 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 { func jsonStringImpl(s string) string {
// minimal JSON string escape — quotes + backslash + control chars. // minimal JSON string escape — quotes + backslash + control chars.
// adequate for the reminder payload's text field; not a general JSON // adequate for the reminder payload's text field; not a general JSON
+10
View File
@@ -27,11 +27,21 @@ type Slots struct {
HasFn bool 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 { type Session struct {
Intent Intent Intent Intent
Slots Slots Slots Slots
Timestamp time.Time Timestamp time.Time
TTL time.Duration TTL time.Duration
History []Turn // most recent turns, newest last; used for anaphora + cross-intent
} }
func (s *Session) IsExpired(now time.Time) bool { func (s *Session) IsExpired(now time.Time) bool {
+35
View File
@@ -328,6 +328,41 @@ func parseDurationValue(s string) (string, bool) {
return strconv.Itoa(n) + unit, true 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 // ParseCalendarDate detects RU calendar date words in text and returns the
// resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow"). // resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow").
// Returns zero time + false if no match. // Returns zero time + false if no match.