Preserve context across conversation intents (V-542)

Owner explicitly requested direct commits to master; bypass the branch-only hook.
This commit is contained in:
2026-08-13 02:14:46 +04:00
parent a0e6643465
commit da9114b623
15 changed files with 499 additions and 56 deletions
+16 -5
View File
@@ -55,11 +55,22 @@ type Candidate struct {
}
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
Intent Intent
Slots Slots
// Utterance is what he actually said on this turn. It is deliberately
// independent of Slots.Text: Text is an intent payload and several valid
// routes leave it empty or replace it with a normalized subject. Dialogue
// history needs the original words so a later pronoun can refer across
// fact, query and chat boundaries without pretending a storage key is a
// transcript.
Utterance string
// Conversational keeps an explicitly opened or chat-routed exchange alive
// across later fact/query routes. It does not change what those turns do;
// it only says their shared transcript uses the longer dialogue TTL.
Conversational bool
Timestamp time.Time
TTL time.Duration
History []Turn // prior turns in speaking order, oldest first; used for anaphora + cross-intent
// Candidates — the list she just offered, in the order she said it. Empty
// on every turn that offered no choice, which is most of them.
Candidates []Candidate
+16 -4
View File
@@ -29,10 +29,13 @@ func TestSessionSurvivesRestart(t *testing.T) {
first := openStore(t, path)
before := NewPersistentSessionStore(2*time.Minute, first)
before.Put("voice", &Session{
Intent: IntentReminder,
Slots: Slots{Text: "полить цветы", Time: now.Add(time.Hour), HasTime: true},
Timestamp: now,
TTL: 2 * time.Minute,
Intent: IntentReminder,
Slots: Slots{Text: "полить цветы", Time: now.Add(time.Hour), HasTime: true},
Utterance: "напомни полить цветы",
Conversational: true,
Timestamp: now,
TTL: 2 * time.Minute,
History: []Turn{{Intent: IntentChat, Text: "мы говорили о цветах"}},
})
if err := first.Close(); err != nil {
t.Fatalf("close: %v", err)
@@ -51,6 +54,15 @@ func TestSessionSurvivesRestart(t *testing.T) {
if sess.Intent != IntentReminder {
t.Fatalf("intent = %q, want reminder", sess.Intent)
}
if sess.Utterance != "напомни полить цветы" {
t.Fatalf("utterance = %q after restart", sess.Utterance)
}
if !sess.Conversational {
t.Fatal("conversation mode was lost across restart")
}
if len(sess.History) != 1 || sess.History[0].Text != "мы говорили о цветах" {
t.Fatalf("history after restart = %+v", sess.History)
}
// the follow-up carries no text of its own; it must inherit the old one
merged := InheritSlots(sess.Slots, Slots{Time: now.Add(2 * time.Hour), HasTime: true})
if merged.Text != "полить цветы" {
+7
View File
@@ -66,6 +66,7 @@ func mustLoad() lexiconFile {
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
"filler_particles", "task_done_words", "task_drop_words",
"confirm_yes", "confirm_no", "hour_units", "minute_units",
"conversation_verbs",
} {
s, ok := f.Sets[name]
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
@@ -212,6 +213,12 @@ func inSet(set, word string) bool {
// Distinct from TaskDropWords, which abandons an item that already exists.
func DialogueCancel() []string { return words("dialogue_cancel") }
// ConversationVerbs returns Maven's vocabulary for explicitly opening a
// conversation after a cooperative marker ("давай поговорим", "let's talk").
// The caller matches Russian members by lemma and English members exactly.
// Keeping the verbs here prevents session state from growing its own stem list.
func ConversationVerbs() []string { return words("conversation_verbs") }
// IsFillerParticle reports whether a word can never be the subject of a
// request: a particle, a politeness word, or the first-person object. See the
// set's own note for why this is not a stopword list.
+7
View File
@@ -262,6 +262,13 @@
"cancel", "nevermind", "forget"
]
},
"conversation_verbs": {
"note": "The verbs Maven accepts after a cooperative marker as an explicit request to converse: «давай поговорим», «давай поболтаем», «let's talk». This is her control vocabulary, like capture_verbs, not a claim to list every way humans can converse. Russian forms are matched by lemma, so one infinitive covers tense and number; English is exact because the Russian dictionary leaves it unchanged.",
"words": [
"говорить", "поговорить", "болтать", "поболтать", "общаться", "пообщаться",
"talk", "chat"
]
},
"confirm_yes": {
"note": "The whole vocabulary of saying yes to a parked confirm, Russian and English. Closed because it is her question that is being answered: she asked \"да или нет\", and the answers to that question can be listed. Matched as whole tokens and never as substrings — \"погода\", \"давление\" and \"дальше\" all contain \"да\", and a substring test executed a destructive act when he asked about the weather (V-567). Words that merely sound agreeable — \"хорошо\", \"ладно\", \"точно\" — are deliberately absent: they open a sentence about something else as often as they answer, and an unclear answer must route rather than execute.",
"words": [
+7 -2
View File
@@ -5,6 +5,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
@@ -507,8 +508,12 @@ type AnaphoraResolver struct{}
//
// Returns the matching pronoun class 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)
// Pronouns are a closed grammatical class. Split on punctuation instead of
// trying to encode word boundaries in a regexp: "это?" and "его," are the
// same pronouns as "это" and "его", including next to Cyrillic letters.
toks := strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
for _, tok := range toks {
switch tok {
case "это", "этого", "этому", "этим", "этом", "эти", "эта":
+19
View File
@@ -50,6 +50,25 @@ func TestDefaultFactParserRU(t *testing.T) {
}
}
func TestAnaphoraResolverUsesTokenBoundariesAcrossPunctuation(t *testing.T) {
r := AnaphoraResolver{}
for _, text := range []string{
"что это?",
"вернуть его, наверное",
"а он — большой?",
"расскажи о ней.",
} {
if _, ok := r.Resolve(text); !ok {
t.Errorf("Resolve(%q) missed a punctuated pronoun", text)
}
}
for _, text := range []string{"этология", "нейросеть", "онлайн"} {
if ref, ok := r.Resolve(text); ok {
t.Errorf("Resolve(%q) = %q; substring is not a pronoun token", text, ref)
}
}
}
func TestParseCalendarDate(t *testing.T) {
now := time.Date(2026, 7, 6, 14, 30, 0, 0, time.UTC)
cases := []struct {