Merge: nothing the model writes reaches a note (#221)

V-576. The diagnosis in the task was wrong and the agent said so. The stored
body was never generated: actions_note.go writes dec.Utterance and always has.

The invention was one layer up, in the two places the owner hears. llmrouter.go
set a note's Slots.Text to firstNonEmpty(a.Text, utterance), where a.Text is the
router model's own free-text field. replier.go renders that slot into the
confirmation. So a fragment with no content let the router write anything into
the payload slot and then had it read back. Two runs, two different inventions,
which is what was measured.

A note's Slots.Text is now the utterance, unconditionally.

A correction fragment also writes nothing at all. correctionFragment uses no new
Russian stem patterns: the first token is a one-word refusal from the confirm_no
lexicon, the sentence negates and then contrasts, and no token is a verb form
per morph.IsVerbForm. The verb test is what spares a real note, so "нет, я не
поеду, а останусь" is still stored.

The repair path is left to V-573. This fragment carries no intent word, so
parseRepair correctly declines it, and widening repair to claim fragments it
cannot redo would be the wrong fix.
This commit is contained in:
2026-08-06 01:40:38 +04:00
5 changed files with 199 additions and 3 deletions
+5 -1
View File
@@ -232,7 +232,11 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time)
d.Slots.Text = a.Text
case IntentNote:
d.Intent = IntentNote
d.Slots.Text = firstNonEmpty(a.Text, utterance)
// The utterance, never the model's text field (V-576). A note is his
// own words, and the daemon phrases the confirmation from this slot.
// The model is free to write anything here, and on the box it did: one
// fragment came back twice as two different sentences he never said.
d.Slots.Text = utterance
case IntentQuery:
d.Intent = IntentQuery
d.Slots.Text = firstNonEmpty(a.Text, utterance)
+4 -2
View File
@@ -78,13 +78,15 @@ func TestLLMRouterFactMapping(t *testing.T) {
}
}
// A note keeps the utterance, whatever the model wrote in its text field
// (V-576). The note is durable and it is his own words.
func TestLLMRouterNoteMapping(t *testing.T) {
lr := NewLLMRouter(mockLLM{out: `{"intent":"note","text":"кофе закончился"}`})
lr := NewLLMRouter(mockLLM{out: `{"intent":"note","text":"ты поедешь на дачу"}`})
d, ok, err := lr.Route(context.Background(), "запомни что кофе закончился", time.Now())
if err != nil || !ok {
t.Fatalf("ok=%v err=%v", ok, err)
}
if d.Intent != IntentNote || d.Slots.Text != "кофе закончился" {
if d.Intent != IntentNote || d.Slots.Text != "запомни что кофе закончился" {
t.Fatalf("bad decision %+v", d)
}
}