05236ad480
- 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).
237 lines
7.7 KiB
Go
237 lines
7.7 KiB
Go
package main
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/dialogue"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
func TestFollowUpMerge(t *testing.T) {
|
|
base := time.Date(2026, 7, 6, 12, 0, 0, 0, time.UTC)
|
|
fireAt := base.Add(24 * time.Hour)
|
|
|
|
// prior turn: a reminder that resolved a fire time.
|
|
prev := &dialogue.Session{
|
|
Intent: dialogue.IntentReminder,
|
|
Slots: dialogue.Slots{Time: fireAt, HasTime: true, Text: "старый текст"},
|
|
Timestamp: base,
|
|
TTL: 2 * time.Minute,
|
|
}
|
|
|
|
t.Run("same intent inherits the missing time", func(t *testing.T) {
|
|
// follow-up reminder with text but no parsed time.
|
|
cur := router.Decision{
|
|
Intent: router.IntentReminder,
|
|
Slots: router.Slots{Text: "позвонить маме"},
|
|
}
|
|
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
|
if !got.Slots.HasTime || !got.Slots.Time.Equal(fireAt) {
|
|
t.Errorf("time not inherited: HasTime=%v Time=%v", got.Slots.HasTime, got.Slots.Time)
|
|
}
|
|
if got.Slots.Text != "позвонить маме" {
|
|
t.Errorf("current text was overwritten: %q", got.Slots.Text)
|
|
}
|
|
})
|
|
|
|
t.Run("current slot wins over prior (gaps only)", func(t *testing.T) {
|
|
own := base.Add(48 * time.Hour)
|
|
cur := router.Decision{
|
|
Intent: router.IntentReminder,
|
|
Slots: router.Slots{Time: own, HasTime: true},
|
|
}
|
|
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
|
if !got.Slots.Time.Equal(own) {
|
|
t.Errorf("current time clobbered by prior: %v", got.Slots.Time)
|
|
}
|
|
})
|
|
|
|
t.Run("different intent does not inherit", func(t *testing.T) {
|
|
cur := router.Decision{Intent: router.IntentFact, Slots: router.Slots{Key: "water", HasKey: true}}
|
|
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
|
if got.Slots.HasTime {
|
|
t.Error("time bled across a different intent")
|
|
}
|
|
})
|
|
|
|
t.Run("clarify turn does not inherit", func(t *testing.T) {
|
|
cur := router.Decision{Intent: router.IntentReminder, Clarify: true}
|
|
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
|
if got.Slots.HasTime {
|
|
t.Error("clarify turn inherited slots")
|
|
}
|
|
})
|
|
|
|
t.Run("expired prior does not inherit", func(t *testing.T) {
|
|
cur := router.Decision{Intent: router.IntentReminder, Slots: router.Slots{Text: "x"}}
|
|
got := followUpMerge(prev, cur, base.Add(3*time.Minute)) // past the 2-min TTL
|
|
if got.Slots.HasTime {
|
|
t.Error("expired session still inherited")
|
|
}
|
|
})
|
|
|
|
t.Run("nil prior is a no-op", func(t *testing.T) {
|
|
cur := router.Decision{Intent: router.IntentReminder, Slots: router.Slots{Text: "x"}}
|
|
got := followUpMerge(nil, cur, base)
|
|
if got.Slots.HasTime {
|
|
t.Error("nil prior produced inheritance")
|
|
}
|
|
})
|
|
|
|
t.Run("router-only Value survives the round-trip", func(t *testing.T) {
|
|
cur := router.Decision{
|
|
Intent: router.IntentFact,
|
|
Slots: router.Slots{Key: "sleep", HasKey: true, Value: "6h"},
|
|
}
|
|
factPrev := &dialogue.Session{
|
|
Intent: dialogue.IntentFact,
|
|
Slots: dialogue.Slots{Text: "спал"},
|
|
Timestamp: base,
|
|
TTL: 2 * time.Minute,
|
|
}
|
|
got := followUpMerge(factPrev, cur, base.Add(10*time.Second))
|
|
if got.Slots.Value != "6h" {
|
|
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)
|
|
}
|
|
})
|
|
}
|