Files
Maven/cmd/mavend/followup_test.go
T
claude da9114b623 Preserve context across conversation intents (V-542)
Owner explicitly requested direct commits to master; bypass the branch-only hook.
2026-08-13 02:14:46 +04:00

340 lines
12 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)
}
})
// V-543, measured on the box: four reminders in a row all landed at the
// first one's hour, each confirmed as if it had been read from the sentence.
// A sentence that names a time and fails to parse must ask, not borrow.
t.Run("a named time that did not parse is not inherited", func(t *testing.T) {
for _, utt := range []string{
"напомни без четверти восемь выходить",
"напомни в половине первого пообедать",
"напомни завтра принять лекарство",
"remind me at noon to stretch",
} {
cur := router.Decision{
Intent: router.IntentReminder,
Utterance: utt,
Slots: router.Slots{Text: utt},
}
got := followUpMerge(prev, cur, base.Add(30*time.Second))
if got.Slots.HasTime {
t.Errorf("%q borrowed the previous hour %v", utt, got.Slots.Time)
}
}
})
// The follow-up this seam exists for still works: the sentence names no
// time, so the previous one is the only one it could mean.
t.Run("a follow-up naming no time still inherits", func(t *testing.T) {
cur := router.Decision{
Intent: router.IntentReminder,
Utterance: "и ещё полить цветы",
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)
}
})
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},
Utterance: "я выпил воду",
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)
}
if got.Intent != router.IntentChat {
t.Errorf("anaphoric query intent = %s, want chat with dialogue context", got.Intent)
}
})
t.Run("anaphoric query after unkeyed query uses raw dialogue context", func(t *testing.T) {
prior := &dialogue.Session{
Intent: dialogue.IntentQuery,
Slots: dialogue.Slots{Text: "кто изобрёл телефон?"},
Utterance: "кто изобрёл телефон?",
Timestamp: base,
TTL: 2 * time.Minute,
}
cur := router.Decision{
Intent: router.IntentQuery,
Utterance: "а когда он это сделал?",
Source: router.SourceWorld,
SourceAnchored: true,
}
got := followUpMerge(prior, cur, base.Add(30*time.Second))
if got.Intent != router.IntentChat {
t.Fatalf("intent = %s, want chat", got.Intent)
}
if got.Source != router.SourceUnknown || got.SourceAnchored {
t.Errorf("query-only source survived contextual chat: source=%s anchored=%v", got.Source, got.SourceAnchored)
}
})
t.Run("anaphora without a usable prior session stays routed", func(t *testing.T) {
prior := &dialogue.Session{
Intent: dialogue.IntentQuery,
Timestamp: base,
TTL: 2 * time.Minute,
}
cur := router.Decision{Intent: router.IntentQuery, Utterance: "что это?"}
got := followUpMerge(prior, cur, base.Add(30*time.Second))
if got.Intent != router.IntentQuery {
t.Errorf("empty session changed intent to %s", got.Intent)
}
})
t.Run("possessive determiner does not turn an explicit query into chat", func(t *testing.T) {
prior := &dialogue.Session{
Intent: dialogue.IntentChat, Utterance: "привет",
Timestamp: base, TTL: 2 * time.Minute,
}
cur := router.Decision{Intent: router.IntentQuery, Utterance: "где мой телефон?"}
got := followUpMerge(prior, cur, base.Add(30*time.Second))
if got.Intent != router.IntentQuery {
t.Errorf("explicit possessive query changed intent to %s", got.Intent)
}
})
t.Run("anaphoric act is never widened into chat", func(t *testing.T) {
prior := &dialogue.Session{
Intent: dialogue.IntentChat, Utterance: "сервер homesrv",
Timestamp: base, TTL: 2 * time.Minute,
}
cur := router.Decision{Intent: router.IntentAct, Utterance: "выключи его"}
got := followUpMerge(prior, cur, base.Add(30*time.Second))
if got.Intent != router.IntentAct {
t.Errorf("act intent changed to %s", got.Intent)
}
if got.Slots.HasFn {
t.Error("anaphora invented an executable function")
}
})
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)
}
})
}