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
+83 -6
View File
@@ -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
}