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
+15
View File
@@ -9,9 +9,24 @@ import (
"github.com/kami/maven/internal/router"
)
// nothingToCorrectReply — what she says to a correction that points at
// nothing. Filing it would put a sentence in his memory that reads as a fact.
const nothingToCorrectReply = "не поняла, что поправить. скажи целиком, и я запишу."
// actionNote handles router.IntentNote: embed the note, persist it, and
// index it for recall.
//
// The stored body is dec.Utterance and nothing else (V-576). It is not
// Slots.Text, not phraser output and not any other model string: a note is
// durable, the embedder indexes it, and it comes back later as recall in his
// own words. Phrasing belongs in the spoken confirmation.
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
// A correction with no referent. Everything that could own one has already
// run by here: clarify, confirm and repair are all resolved before routing,
// so a fragment reaching the note path has nothing behind it (V-576).
if correctionFragment(dec.Utterance) {
return nothingToCorrectReply
}
// An utterance that explicitly files a task is work, not recall, and
// belongs in the task store (Vikunja #130). Checked before the embedding
// is paid for. Everything else is a note, exactly as before.
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// correctionFragment reports that an utterance replaces a referent and states
// nothing of its own: "нет, не маме, а папе" (V-576).
//
// Measured on the box 2026-08-06, that fragment routed to note and was filed.
// It is not a repair either, because it names no intent, so parseRepair
// declines it and repair.go is the wrong place to catch it. This is the note
// path saying it has nothing to store.
//
// Three offline tests, all of them narrow on purpose. The sentence opens with a
// refusal word from the lexicon, it carries the contrastive "а" that names the
// replacement, and no token in it is a verb form. The verb test is what keeps
// the rule off real notes: "нет, я не поеду, а останусь" says something, and a
// Russian verb carries its own subject and tense.
func correctionFragment(utterance string) bool {
toks := repairTokens(strings.ToLower(strings.TrimSpace(utterance)))
if len(toks) < 3 {
return false
}
if !refusalWord(toks[0]) {
return false
}
var negated, contrasted bool
for _, tok := range toks[1:] {
switch tok {
case "не", "not":
negated = true
case "а", "but":
contrasted = true
}
if morph.IsVerbForm(tok) {
return false
}
}
return negated && contrasted
}
// refusalWord reports that a token is a one-word refusal. The lexicon set holds
// phrases too ("не надо"), and those are not what opens a correction.
func refusalWord(tok string) bool {
for _, w := range lexicon.ConfirmNo() {
if strings.ContainsRune(w, ' ') {
continue
}
if w == tok {
return true
}
}
return false
}
+117
View File
@@ -0,0 +1,117 @@
package main
import (
"context"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
)
func TestCorrectionFragment(t *testing.T) {
cases := []struct {
utterance string
want bool
}{
{"нет, не маме, а папе", true},
{"Нет, не маме — а папе", true},
{"no, not mom, but dad", true},
// States something of its own, so it is his to keep.
{"нет, я не поеду, а останусь дома", false},
{"нет", false},
{"не маме, а папе", false}, // no refusal word opening it
{"нет, маме и папе", false}, // nothing negated
{"нет, не маме", false}, // nothing put in its place
{"запомни что кофе закончился", false},
}
for _, c := range cases {
if got := correctionFragment(c.utterance); got != c.want {
t.Errorf("correctionFragment(%q) = %v, want %v", c.utterance, got, c.want)
}
}
}
func newNoteHandler(t *testing.T) (*reactiveHandler, *store.Store) {
t.Helper()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Now()
emb := router.NewHashEmbedder(1024)
h := &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
}
return h, st
}
// TestNoteBodyIsTheUtterance — the stored body comes from the utterance, never
// from Slots.Text, which the LLM router is free to write anything into (V-576).
func TestNoteBodyIsTheUtterance(t *testing.T) {
ctx := context.Background()
h, st := newNoteHandler(t)
dec := router.Decision{
Intent: router.IntentNote,
Utterance: "купил хлеб и молоко",
Slots: router.Slots{Text: "ты поедешь на дачу"},
}
if reply := h.applyAction(ctx, dec); reply != "" {
t.Fatalf("applyAction = %q, want empty", reply)
}
notes, err := st.RecentNotes(ctx, 10)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
if len(notes) != 1 || notes[0].Text != dec.Utterance {
t.Fatalf("stored note = %+v, want body %q", notes, dec.Utterance)
}
}
// TestNoteBodyIsStable — the same utterance twice stores the same text.
func TestNoteBodyIsStable(t *testing.T) {
ctx := context.Background()
h, st := newNoteHandler(t)
dec := router.Decision{Intent: router.IntentNote, Utterance: "кофе закончился"}
h.applyAction(ctx, dec)
h.applyAction(ctx, dec)
notes, err := st.RecentNotes(ctx, 10)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
if len(notes) != 2 {
t.Fatalf("notes = %d, want 2", len(notes))
}
if notes[0].Text != notes[1].Text || notes[0].Text != dec.Utterance {
t.Fatalf("bodies differ: %q vs %q", notes[0].Text, notes[1].Text)
}
}
// TestCorrectionFragmentWritesNoNote — a correction with nothing behind it is
// not a note, and she says so instead of filing it (V-576).
func TestCorrectionFragmentWritesNoNote(t *testing.T) {
ctx := context.Background()
h, st := newNoteHandler(t)
dec := router.Decision{Intent: router.IntentNote, Utterance: "нет, не маме, а папе"}
if reply := h.applyAction(ctx, dec); reply != nothingToCorrectReply {
t.Fatalf("reply = %q, want %q", reply, nothingToCorrectReply)
}
notes, err := st.RecentNotes(ctx, 10)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
if len(notes) != 0 {
t.Fatalf("notes = %+v, want none", notes)
}
}
+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)
}
}