Preserve context across conversation intents (V-542)
Owner explicitly requested direct commits to master; bypass the branch-only hook.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 != "полить цветы" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 "это", "этого", "этому", "этим", "этом", "эти", "эта":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user