Preserve context across conversation intents (V-542)
Owner explicitly requested direct commits to master; bypass the branch-only hook.
This commit is contained in:
+21
-11
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
-6
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
+22
-16
@@ -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
|
||||
}
|
||||
|
||||
+14
-6
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user