diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index 1743350..de97854 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -622,15 +622,23 @@ const maxCarriedHistory = 3 func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) { var history []dialogue.Turn if prev != nil { - history = append(history, sessionAsTurn(prev)) - maxHist := len(prev.History) - if maxHist > maxCarriedHistory { - maxHist = maxCarriedHistory + // History is chronological. Keep the newest tail of the older history, + // then append the immediate prior turn. The previous implementation put + // the newest turn first while the type contract said newest-last, so the + // model read a conversation backwards. + from := len(prev.History) - maxCarriedHistory + if from < 0 { + from = 0 } - history = append(history, prev.History[:maxHist]...) + history = append(history, prev.History[from:]...) + history = append(history, sessionAsTurn(prev)) + } + conversational := dec.Intent == router.IntentChat || opensConversation(dec.Utterance) + if prev != nil && (prev.Conversational || prev.Intent == dialogue.IntentChat) { + conversational = true } ttl := time.Duration(0) // use the store default (2 min) - if dec.Intent == router.IntentChat { + if conversational { ttl = 15 * time.Minute // conversational turns should last longer } // A system or query turn often carries no Text slot at all — a stage-0 @@ -652,10 +660,12 @@ func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Sessi slots.Text = dec.Utterance } h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{ - Intent: dialogue.Intent(dec.Intent), - Slots: slots, - Timestamp: now, - TTL: ttl, - History: history, + Intent: dialogue.Intent(dec.Intent), + Slots: slots, + Utterance: dec.Utterance, + Conversational: conversational, + Timestamp: now, + TTL: ttl, + History: history, }) } diff --git a/cmd/mavend/conversation_context_test.go b/cmd/mavend/conversation_context_test.go new file mode 100644 index 0000000..002e3fc --- /dev/null +++ b/cmd/mavend/conversation_context_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +func TestRememberTurnKeepsIntentIndependentTranscriptInSpeakingOrder(t *testing.T) { + now := time.Date(2026, 8, 13, 1, 0, 0, 0, time.UTC) + h := &reactiveHandler{ + now: func() time.Time { return now }, + dialogueSessions: dialogue.NewSessionStore(time.Hour), + } + ctx := context.Background() + turns := []router.Decision{ + {Intent: router.IntentFact, Utterance: "я купил новый монитор", Slots: router.Slots{Key: "purchase", Value: "новый монитор", HasKey: true}}, + {Intent: router.IntentQuery, Utterance: "а он большой?", Slots: router.Slots{Text: "normalized query"}}, + {Intent: router.IntentChat, Utterance: "кажется, я переплатил", Slots: router.Slots{Text: "normalized chat"}}, + {Intent: router.IntentQuery, Utterance: "стоит его вернуть?", Slots: router.Slots{Text: "normalized return query"}}, + } + for i, dec := range turns { + prev := h.dialogueSessions.Get(voiceDialogueID, now) + h.rememberTurn(ctx, prev, dec, now.Add(time.Duration(i)*time.Second)) + } + + got := h.dialogueSessions.Get(voiceDialogueID, now.Add(4*time.Second)) + if got == nil { + t.Fatal("no dialogue session") + } + if got.Utterance != turns[3].Utterance { + t.Fatalf("current utterance = %q, want %q", got.Utterance, turns[3].Utterance) + } + want := []string{turns[0].Utterance, turns[1].Utterance, turns[2].Utterance} + if len(got.History) != len(want) { + t.Fatalf("history = %+v, want %d prior turns", got.History, len(want)) + } + for i := range want { + if got.History[i].Text != want[i] { + t.Errorf("history[%d] = %q, want %q", i, got.History[i].Text, want[i]) + } + } + + // actionChat runs after rememberTurn. It must receive only prior turns; + // handing over the current turn here would duplicate the model's user input. + history := h.chatHistory(ctx) + if len(history) != len(want) { + t.Fatalf("chat history = %+v, want exactly the prior turns", history) + } + for _, turn := range history { + if turn.Text == got.Utterance { + t.Fatalf("current utterance was duplicated into chat history: %+v", history) + } + } +} + +func TestSessionAsTurnReadsLegacySlotText(t *testing.T) { + legacy := &dialogue.Session{ + Intent: dialogue.IntentQuery, + Slots: dialogue.Slots{Text: "старый сохранённый вопрос"}, + } + if got := sessionAsTurn(legacy).Text; got != legacy.Slots.Text { + t.Fatalf("legacy turn text = %q, want %q", got, legacy.Slots.Text) + } +} + +func TestExplicitConversationOpenerKeepsCrossIntentSessionAlive(t *testing.T) { + now := time.Date(2026, 8, 13, 1, 0, 0, 0, time.UTC) + h := &reactiveHandler{ + now: func() time.Time { return now }, + dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), + } + ctx := context.Background() + h.rememberTurn(ctx, nil, router.Decision{ + Intent: router.IntentFact, Utterance: "давай поболтаем: я купил новый монитор", + Slots: router.Slots{Key: "purchase", Value: "новый монитор", HasKey: true}, + }, now) + + later := now.Add(10 * time.Minute) + prev := h.dialogueSessions.Get(voiceDialogueID, later) + if prev == nil { + t.Fatal("explicit conversation expired at the ordinary two-minute TTL") + } + if !prev.Conversational || prev.TTL != 15*time.Minute { + t.Fatalf("conversation state = %+v, want conversational 15m session", prev) + } + got := followUpMerge(prev, router.Decision{ + Intent: router.IntentQuery, Utterance: "а он большой?", + }, later) + if got.Intent != router.IntentChat { + t.Fatalf("anaphoric follow-up intent = %s, want chat", got.Intent) + } +} + +func TestConversationOpenerDoesNotMatchAnotherDavaiCommand(t *testing.T) { + if opensConversation("давай запишем новый монитор") { + t.Fatal("an ordinary cooperative command opened a conversation") + } + for _, text := range []string{ + "давай поговорим: я купил монитор", + "давайте пообщаемся", + "let's talk: I bought a monitor", + } { + if !opensConversation(text) { + t.Errorf("%q did not open a conversation", text) + } + } +} diff --git a/cmd/mavend/followup.go b/cmd/mavend/followup.go index 2a18a5c..1a818d6 100644 --- a/cmd/mavend/followup.go +++ b/cmd/mavend/followup.go @@ -5,6 +5,8 @@ import ( "time" "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/morph" "github.com/kami/maven/internal/router" ) @@ -94,15 +96,18 @@ func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots { // anaphoraResolver is a shared instance for pronoun detection. var anaphoraResolver router.AnaphoraResolver -// followUpMerge fills the current turn's missing slots from a prior -// non-expired session — the multi-turn seam. It handles three cases: +// followUpMerge carries the current conversation across a prior non-expired +// session — the multi-turn seam. It handles four cases: // // 1. Same-intent: inherit missing slots via InheritSlots (existing behavior), // except a reminder time the current sentence named and the parser missed. -// 2. Cross-intent anaphora: if the current utterance contains a pronoun +// 2. An anaphoric query becomes chat. A question whose subject lives in this +// conversation is answered from its transcript, not sent through unrelated +// note, web and encyclopedia sources as a context-free lookup. +// 3. 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 +// 4. 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 @@ -112,6 +117,33 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r return dec } + ref, isAnaphoric := anaphoraResolver.Resolve(dec.Utterance) + if dec.Intent == router.IntentQuery && isAnaphoric && ref != "mine" && sessionHasContext(prev) { + // The router correctly identified a question. What it cannot know from + // one utterance is that its subject is in the live dialogue. Chat is the + // only action path that receives that dialogue, so preserve the route's + // slots but answer it there. Clear query-only provenance: no query source + // was selected and an anchored destination must not survive an intent + // change the daemon made from state the router could not see. + dec.Intent = router.IntentChat + dec.Source = router.SourceUnknown + dec.SourceAnchored = false + // Keep a structured referent when the prior route had one. The chat + // phraser primarily reads the transcript, but the session must not lose + // the fact identity merely because one follow-up crossed an intent. + if !dec.Slots.HasKey && prev.Slots.HasKey { + dec.Slots.Key = prev.Slots.Key + dec.Slots.HasKey = true + } + if dec.Slots.Value == "" { + dec.Slots.Value = prev.Slots.Value + } + if !dec.Slots.HasTime && prev.Slots.HasTime { + dec.Slots.Time = prev.Slots.Time + dec.Slots.HasTime = true + } + } + // Case 1: same-intent inheritance (existing). if prev.Intent == dialogue.Intent(dec.Intent) { // A reminder that named an hour nobody could read must not borrow the @@ -133,9 +165,8 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r return dec } - // Cases 2 & 3: cross-intent anaphora + query-after-fact. + // Cases 3 & 4: 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 @@ -161,3 +192,49 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r return dec } + +// sessionHasContext distinguishes a live transcript from a session that only +// carries timing/candidate bookkeeping. The raw utterance is the primary +// source. The slot fallback keeps sessions persisted by older binaries useful +// after an upgrade: those blobs have no Utterance field, but may still carry +// the exact turn in Text or a structured fact key/value. +func sessionHasContext(s *dialogue.Session) bool { + if s == nil { + return false + } + return s.Utterance != "" || s.Slots.Text != "" || s.Slots.HasKey || s.Slots.Value != "" +} + +// opensConversation recognises an explicit cooperative opener without making +// it a competing route. The substantive clause after the colon may still be a +// fact worth storing; this function only chooses the session's lifetime. +// +// The marker is grammatical and closed (Russian давай/давайте, English let's), +// and the action vocabulary lives in lexicon rather than a substring pattern. +// A bare chat route needs none of this — rememberTurn marks it conversational +// from its intent. This catches the compound shape whose fact clause otherwise +// hides the opener from the single-intent router. +func opensConversation(text string) bool { + tokens := quietTokens(text) + if len(tokens) < 2 { + return false + } + from := 1 + switch { + case tokens[0] == "давай" || tokens[0] == "давайте": + case tokens[0] == "lets": + case len(tokens) >= 3 && tokens[0] == "let" && tokens[1] == "s": + from = 2 + default: + return false + } + verbs := lexicon.ConversationVerbs() + for _, token := range tokens[from:] { + for _, verb := range verbs { + if token == verb || morph.SameWord(token, verb) { + return true + } + } + } + return false +} diff --git a/cmd/mavend/followup_test.go b/cmd/mavend/followup_test.go index 6facfb1..42a7c87 100644 --- a/cmd/mavend/followup_test.go +++ b/cmd/mavend/followup_test.go @@ -138,6 +138,7 @@ func TestFollowUpMerge(t *testing.T) { prior := &dialogue.Session{ Intent: dialogue.IntentFact, Slots: dialogue.Slots{Key: "water", HasKey: true}, + Utterance: "я выпил воду", Timestamp: base, TTL: 2 * time.Minute, } @@ -152,6 +153,72 @@ func TestFollowUpMerge(t *testing.T) { 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) { diff --git a/cmd/mavend/simulator_test.go b/cmd/mavend/simulator_test.go index 23b5957..ebc8e9e 100644 --- a/cmd/mavend/simulator_test.go +++ b/cmd/mavend/simulator_test.go @@ -127,10 +127,14 @@ type toolRow struct { // Route and Reply are separate because the same model serves both contracts // (CLAUDE.md, "LLM output contract"): a grammar-constrained call is a routing // call and gets Route, an unconstrained one is a phrasing call and gets Reply. +// HistoryContains makes a chat reply conditional on the transcript the daemon +// supplied. It prevents a canned answer from making a continuity scenario pass +// while the referent is still absent from the model input. type scriptEntry struct { - Match string `json:"match"` - Route string `json:"route,omitempty"` - Reply string `json:"reply,omitempty"` + Match string `json:"match"` + Route string `json:"route,omitempty"` + Reply string `json:"reply,omitempty"` + HistoryContains []string `json:"history_contains,omitempty"` } // step — one scripted moment. At is "HH:MM" or "HH:MM:SS", interpreted in the @@ -369,7 +373,7 @@ type scriptedPhraser struct { // matches scriptedLLM: actionChat logs it and falls back to ChatFallback(), so a // scenario that never meant to assert on a chat reply behaves exactly as it did // before, and one that DID means to is told its script has a hole. -func (p *scriptedPhraser) PhraseChat(_ context.Context, utterance string, _ []dialogue.Turn) (string, error) { +func (p *scriptedPhraser) PhraseChat(_ context.Context, utterance string, history []dialogue.Turn) (string, error) { for _, e := range p.entries { if e.Reply == "" { continue @@ -377,6 +381,19 @@ func (p *scriptedPhraser) PhraseChat(_ context.Context, utterance string, _ []di if e.Match != "" && !strings.Contains(strings.ToLower(utterance), strings.ToLower(e.Match)) { continue } + for _, want := range e.HistoryContains { + found := false + for _, turn := range history { + if containsFold(turn.Text, want) { + found = true + break + } + } + if !found { + return "", fmt.Errorf("simulator: chat history for %q does not contain %q: %+v", + truncateRunes(utterance, 60), want, history) + } + } return chatReplyText(e.Reply), nil } return "", fmt.Errorf("simulator: no scripted chat reply for %q", truncateRunes(utterance, 60)) diff --git a/cmd/mavend/testdata/scenarios/conversation_anaphora.json b/cmd/mavend/testdata/scenarios/conversation_anaphora.json index 6998da9..c055c9d 100644 --- a/cmd/mavend/testdata/scenarios/conversation_anaphora.json +++ b/cmd/mavend/testdata/scenarios/conversation_anaphora.json @@ -1,7 +1,7 @@ { "schema_version": 1, "name": "conversation_anaphora", - "description": "Five consecutive Russian turns about one object, replayed from the run that found V-542 on the box on 05-08-2026. He names a monitor, then asks four questions that all say \"он\" and never name it again.\n\nThis scenario exists because the shape had nowhere to fail. The routing fixture scores one utterance at a time, so a conversation that breaks on its second turn cannot lose a point there, and V-44 step 2 could only be verified by hand. That is item 3 of V-542.\n\nFour of the five replies below are WRONG, and the assertions pin them anyway. Read them as the recorded defect rather than the contract: she has the last four turns in front of her and never once names the thing he is asking about. Every wrong assertion is marked in its step note with what it must become. When V-542 lands, those flip and the ones marked correct do not move.\n\nWhat the four assert is that the reply LACKS \"монитор\". Absence is the defect itself: she is answering a question about a thing she wrote down two minutes ago and cannot name it. It also survives the fallback picker, which matters on the three query turns — they refuse from internal/phraser/fallbacks_ru_v1.json, four variants deep, and the same scenario returned \"тут я пас.\" one run and \"не знаю, честно.\" the next, so a string assertion there would pin the picker rather than the daemon.\n\nTurn 4 asserts its text as well, because that turn goes through the chat path and the chat path is now scriptable. scriptedPhraser in simulator_test.go answers PhraseChat from the same script entries the router reads (V-542 item 4); before it, the simulator wired phraser.NewStub() and no scenario could say what she SAYS on a chat turn at all.\n\nThe routes are scripted exactly as the box produced them, because the failure is not the model's. Turn 1 went to fact despite \"давай поболтаем\", every question after it went to query, and turn 4 went to chat. A scripted route is what lets this scenario pin the daemon's half without a llama-server in the loop.", + "description": "Five consecutive Russian turns about one object, replayed from the run that found V-542 on the box on 05-08-2026. He names a monitor once, then refers to it by pronoun or ellipsis for four turns.\n\nThe router decisions stay exactly as the box produced them: fact, query, query, chat, query. That is intentional. Routing sees one utterance; dialogue continuity owns what earlier turns make it mean. A grounded fact inside an explicit conversational opener is still stored, while its exact utterance also enters the transcript. Anaphoric queries are answered through chat with that transcript instead of walking note and world sources without their referent.\n\nEvery scripted chat answer below is conditional on history containing the original monitor turn. A canned reply therefore cannot make this scenario pass if session capture, cross-intent merge, history ordering or PhraseChat wiring loses the referent. The visible assertions require the answer to name the monitor, and the final tick remains the not-a-nag control.", "start": "2026-08-05T14:00:00+03:00", "script": [ { @@ -11,20 +11,27 @@ }, { "match": "он большой", - "route": "[{\"intent\":\"query\",\"text\":\"а он большой?\"}]" + "route": "[{\"intent\":\"query\",\"text\":\"а он большой?\"}]", + "reply": "{\"response\":\"Ты про новый монитор; диагональ ты пока не называл.\",\"mood\":\"neutral\"}", + "history_contains": ["купил новый монитор"] }, { "match": "сколько он примерно стоит", - "route": "[{\"intent\":\"query\",\"text\":\"сколько он примерно стоит по-твоему?\"}]" + "route": "[{\"intent\":\"query\",\"text\":\"сколько он примерно стоит по-твоему?\"}]", + "reply": "{\"response\":\"Новый монитор без модели и диагонали я честно не оценю.\",\"mood\":\"thinking\"}", + "history_contains": ["купил новый монитор"] }, { "match": "переплатил", "route": "[{\"intent\":\"chat\",\"text\":\"мне кажется я переплатил\"}]", - "reply": "{\"response\":\"я не знаю, о каком именно устройстве ты говоришь.\",\"mood\":\"neutral\"}" + "reply": "{\"response\":\"Про новый монитор поняла; цену лучше сравнить по точной модели.\",\"mood\":\"thinking\"}", + "history_contains": ["купил новый монитор"] }, { "match": "стоит его вернуть", - "route": "[{\"intent\":\"query\",\"text\":\"стоит его вернуть?\"}]" + "route": "[{\"intent\":\"query\",\"text\":\"стоит его вернуть?\"}]", + "reply": "{\"response\":\"Новый монитор стоит вернуть, если сравнение подтвердит переплату или он тебе не подходит.\",\"mood\":\"neutral\"}", + "history_contains": ["купил новый монитор"] }, { "match": "", @@ -35,43 +42,42 @@ "steps": [ { "at": "14:00", - "note": "CORRECT, and it is the first half of the defect. \"давай поболтаем\" is an explicit request to converse and the turn is filed as a fact anyway. Storing what he said is not wrong on its own — he did buy a monitor — but the object then lives in the fact store and never enters the transcript PhraseChat reads. That is V-542 decision 2: either the marker claims the turn at stage 0, or it means nothing and comes out of the fixture.", + "note": "A substantive statement inside an explicit conversational opener remains a grounded fact, and the exact same utterance becomes dialogue context. Conversation is session state, not a competing storage intent.", "say": "давай поболтаем: я вчера купил новый монитор", "expect_events": ["purchase"], "expect_no_send": true }, { "at": "14:01", - "note": "WRONG. \"он\" is the monitor from one turn ago, and she says she has no record of it. followUpMerge inherits prev.Slots.Key, and a query turn asking about a pronoun has no key to merge, so the question reaches the query sources naked and the notes source answers the only way it can. Must become: an answer about the monitor, or a route to chat where the transcript is.", + "note": "The query route cannot see earlier turns. The dialogue merge sees the anaphora and answers through chat with the transcript, which names the monitor.", "say": "а он большой?", - "expect_reply_lacks": ["монитор"], + "expect_reply_contains": ["монитор"], "expect_no_send": true }, { "at": "14:02", - "note": "WRONG, and it rules out one explanation. This is not the previous turn failing to stick — it is the same wall a second time, two turns from where the monitor was named. Nothing accumulates across query turns.", + "note": "The referent survives a second routed-query boundary; the previous contextual turn did not replace the transcript anchor.", "say": "сколько он примерно стоит по-твоему?", - "expect_reply_lacks": ["монитор"], + "expect_reply_contains": ["монитор"], "expect_no_send": true }, { "at": "14:03", - "note": "WRONG, and it is the same wall from the other side. This turn routed chat, so it HAD the history that Session.History holds, and it asks which device he means anyway — because turn 1's object went to the fact store rather than the transcript. So a source reading the conversation is not sufficient on its own; decision 1 has to say which store the referent comes from. This is the one step whose text is pinned: the reply is scripted and reaches PhraseChat, so it is the box's own words rather than a fallback pick. Must become: a reply that names the monitor.", + "note": "A native chat route reads the same cross-intent transcript, in chronological order, without receiving the current utterance twice.", "say": "мне кажется я переплатил", - "expect_reply_contains": ["о каком именно устройстве"], - "expect_reply_lacks": ["монитор"], + "expect_reply_contains": ["монитор"], "expect_no_send": true }, { "at": "14:04", - "note": "WRONG. The fifth turn is the one that shows the cost. A returns question about a purchase two minutes old is answered with \"не нашла у тебя такой записи\", which is wrong in kind rather than merely unhelpful: the record exists, she wrote it herself at 14:00 under the key purchase.", + "note": "The fifth turn proves the oldest retained turn still supplies the referent after fact, query and chat crossings.", "say": "стоит его вернуть?", - "expect_reply_lacks": ["монитор"], + "expect_reply_contains": ["монитор"], "expect_no_send": true }, { "at": "14:05", - "note": "CORRECT, and it is the control. Nothing in five conversational turns was sent at him unprompted, and a tick with him mid-conversation stays silent. Whatever V-542 changes must not change this.", + "note": "Control: session continuity is reactive state only. A tick during the conversation sends nothing unprompted.", "tick": true, "expect_no_send": true } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index d148ac9..6b84763 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -555,10 +555,17 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) // history lists. Shared by chatHistory and rememberTurn (clarify.go) so the // same session is described the same way in both places. func sessionAsTurn(s *dialogue.Session) dialogue.Turn { + text := s.Utterance + if text == "" { + // Compatibility with a session blob written before Utterance became a + // first-class field. Slots.Text was the old transcript by convention + // for query/chat/system turns, and is still better than dropping it. + text = s.Slots.Text + } return dialogue.Turn{ Intent: s.Intent, Slots: s.Slots, - Text: s.Slots.Text, + Text: text, } } @@ -574,11 +581,12 @@ func (h *reactiveHandler) chatHistory(ctx context.Context) []dialogue.Turn { if prev == nil { return nil } - // History already includes the immediate prior turn (set by the dialogue - // merge in runTurn's step 6, above), plus up to 3 more from deeper history. - out := make([]dialogue.Turn, 0, 1+len(prev.History)) - out = append(out, sessionAsTurn(prev)) - out = append(out, prev.History...) + // rememberTurn runs before the action so query handlers can bind candidate + // lists to the current session. Therefore prev is the CURRENT turn here; + // its History is precisely the prior transcript. Adding sessionAsTurn(prev) + // would hand the model the current utterance twice. + out := make([]dialogue.Turn, len(prev.History)) + copy(out, prev.History) return out } diff --git a/docs/design.md b/docs/design.md index 130f55d..dbee079 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,6 +1,6 @@ # Maven — Design -*Last verified: 2026-08-07 @ beb093a. Living doc: correct it in place, do not append.* +*Last verified: 2026-08-13 @ a0e6643 + V-542 working tree. Living doc: correct it in place, do not append.* > Folded 2026-07-30 from `SPEC.md` (north star, 2026-07-03), `maven.md` > (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan, @@ -338,6 +338,44 @@ one run-on thought: > вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить > напоминание? +#### Conversation context is independent of intent + +Decided 2026-08-13 (V-542). A conversation is a sequence of turns, not a run of +one route label. The utterance "давай поболтаем: я купил новый монитор" may +correctly produce a grounded fact, and the next question may correctly route as +query. Neither decision is permission to discard the words that make "он" in +the next turn mean the monitor. + +`dialogue.Session.Utterance` therefore stores the exact user turn separately +from `Slots.Text`. Slots are intent payloads: a fact may normalize them, a +stage-0 route may leave them empty, and a continuation may deliberately carry +an older topic. None of those is a transcript. `Session.History` holds up to +four prior turns in speaking order and is persisted with the session; blobs +written by older binaries fall back to their old `Slots.Text` field until they +expire. + +`Session.Conversational` is orthogonal state too. A chat route sets it, as does +an explicit cooperative opener such as "давай поговорим" even when the +substantive clause routes fact. The opener is recognised from the closed marker +plus `lexicon.ConversationVerbs`, with Russian forms compared by `morph`; there +is no route regex or substring carve-out. Conversational state carries across +later intents and uses the existing 15-minute chat TTL instead of expiring the +anchor after two minutes. + +After routing, `followUpMerge` may use state the router cannot see. A routed +query containing an anaphoric pronoun and a live prior transcript becomes chat, +with query-only source provenance cleared. `PhraseChat` then receives the prior +turns and the current utterance exactly once. This is narrower than adding the +transcript to every query source: a non-anaphoric calendar, recall or world +question still walks its evidence chain unchanged. Acts are never widened by +this rule; an unresolved "выключи его" still has no executable function and +must fail closed. + +An explicit conversational opener does not suppress a substantive side effect. +The monitor statement remains a fact and also becomes the dialogue anchor. +Conversation state and save-where are orthogonal, so making the first route +chat would merely lose a true fact to work around a session defect. + ### save-where — the two-memory routing axis One discriminator: **does the loop evaluate a predicate against it?** @@ -896,7 +934,7 @@ Condensed from `ROADMAP.md` (2026-07-06). The live queue is the Vikunja board | 1.3 | desk_active presence script on desk PC | P1 | **not done** — operator action on `linux` (systemd user timer + hypridle listener); 0 facts ever written, presence runs on `page_heartbeat` alone | | 2.1 | Cold-start unlock (passkey → L3 key seam) | P2 | code done `b0932a1`+`15fe7bb`, **tests missing** — wrap/unwrap round-trip, wrong-cred unwrap fails, locked-mode IPC rejects non-unlock methods | | 3.1 | Always-on listening | P3 | MVP `e57647c` (energy-VAD only); remaining: wake-word model in `vad.go` | -| 3.2 | Conversation depth (multi-turn) | P3 | done `05236ad` — anaphora resolver + cross-intent `followUpMerge` + `Session.History` | +| 3.2 | Conversation depth (multi-turn) | P3 | repaired V-542 on 2026-08-13 — intent-independent utterance history; anaphoric queries reach chat context across fact/query/chat boundaries | | 3.3 | Latency / streaming (streaming STT/TTS, barge-in) | P3 | not started; recommended path is WebSocket voice, keeping TCP for non-browser clients | | 4.1 | Routing quality (dev embedder) | P4 | done `b7eb53a` — `make download-embedder`, configurable `voice.query_min_score` | | 4.2 | Act surface broadening | P4 | not a code item (operator config) | diff --git a/docs/evals/2026-08-13-conversation-continuity.md b/docs/evals/2026-08-13-conversation-continuity.md new file mode 100644 index 0000000..c0bf0dd --- /dev/null +++ b/docs/evals/2026-08-13-conversation-continuity.md @@ -0,0 +1,48 @@ +# Five turns retain one referent across fact, query and chat routes + +Measured 2026-08-13 on homesrv from `master` at `a0e6643` plus the V-542 +working tree. Task: V-542. The run uses the deterministic simulator phraser; +no resident model is needed. + +## Setup + +`conversation_anaphora.json` replays the five Russian turns that exposed the +defect. Their scripted router decisions remain the observed +fact/query/query/chat/query sequence. Each of the four contextual replies is +accepted only if the chat phraser receives history containing the original +`купил новый монитор` turn; this prevents a canned response from hiding a +missing transcript. + +Commands: + +```sh +make t PKG=./cmd/mavend RUN='TestExplicitConversationOpenerKeepsCrossIntentSessionAlive|TestConversationOpenerDoesNotMatchAnotherDavaiCommand|TestFollowUpMerge|TestSimulatorScenarios/conversation_anaphora' V=1 RACE=1 +make t PKG=./internal/dialogue RUN=TestSessionSurvivesRestart V=1 RACE=1 +make t PKG=./internal/router RUN=TestAnaphoraResolverUsesTokenBoundariesAcrossPunctuation V=1 RACE=1 +MAVEN_DIALOGUE_NO_SKIP=1 make t PKG=./cmd/mavend RUN=TestDialogueTraces V=1 RACE=0 +``` + +## Result + +| Gate | before | after | +| --- | ---: | ---: | +| contextual replies that name the monitor | 0/4 | **4/4** | +| replies proven to receive the original turn in history | 0/4 | **4/4** | +| original grounded fact still written | 1/1 | **1/1** | +| unsolicited sends in five turns plus one tick | 0/6 | **0/6** | +| focused race-tested packages | — | **3/3 pass** | + +The forced dialogue suite passed 18/22 rows. Its four failures are the existing, +unrelated offline-floor cases for a mid-flow note, a correction while a question +is parked, a whole-day reminder, and a short correction that does not park a +clarification. None exercises V-542; the continuity scenario and all focused +race gates pass. + +## What this rules out + +The fix does not rewrite pronouns into guessed nouns or add a phrase regex. +Exact utterances form a persisted, chronological transcript independently of +intent slots. An anaphoric query with live dialogue context moves to chat and +clears query-source provenance; non-anaphoric queries retain their source path, +and acts are never widened. An explicit conversation opener extends session +lifetime without suppressing the fact side effect in its substantive clause. diff --git a/internal/dialogue/session.go b/internal/dialogue/session.go index 32c9051..9280587 100644 --- a/internal/dialogue/session.go +++ b/internal/dialogue/session.go @@ -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 diff --git a/internal/dialogue/session_persist_test.go b/internal/dialogue/session_persist_test.go index fc4f308..c2a164d 100644 --- a/internal/dialogue/session_persist_test.go +++ b/internal/dialogue/session_persist_test.go @@ -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 != "полить цветы" { diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index 731687b..258fea3 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -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. diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index aac8e20..b7c2946 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -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": [ diff --git a/internal/router/slots.go b/internal/router/slots.go index 58b069a..91010a0 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -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 "это", "этого", "этому", "этим", "этом", "эти", "эта": diff --git a/internal/router/slots_ru_test.go b/internal/router/slots_ru_test.go index cc9d33b..ea17997 100644 --- a/internal/router/slots_ru_test.go +++ b/internal/router/slots_ru_test.go @@ -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 {