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:
+47
-8
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+62
-1
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user