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
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user