From b705a786efef8d0fb578782021ed0e25749c9a2d Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 01:39:38 +0400 Subject: [PATCH] a note stores his words, not the model's (V-576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The note body was already the utterance. Two other holes were not. The LLM router filled Slots.Text for a note from the model's own text field, and that slot is what the replier reads out. So the confirmation he heard named things he never said, twice over, differently each time. The note payload is now the utterance and the model cannot touch it. A correction with no referent is also not a note. 'нет, не маме, а папе' names no intent, so parseRepair declines it and it routed as a fresh note. actionNote now declines it and asks instead of filing it. Co-Authored-By: Claude Opus 5 --- cmd/mavend/actions_note.go | 15 ++++ cmd/mavend/notefragment.go | 58 +++++++++++++++ cmd/mavend/notefragment_test.go | 117 ++++++++++++++++++++++++++++++ internal/router/llmrouter.go | 6 +- internal/router/llmrouter_test.go | 6 +- 5 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 cmd/mavend/notefragment.go create mode 100644 cmd/mavend/notefragment_test.go diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go index db3212b..b1d7964 100644 --- a/cmd/mavend/actions_note.go +++ b/cmd/mavend/actions_note.go @@ -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. diff --git a/cmd/mavend/notefragment.go b/cmd/mavend/notefragment.go new file mode 100644 index 0000000..d799990 --- /dev/null +++ b/cmd/mavend/notefragment.go @@ -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 +} diff --git a/cmd/mavend/notefragment_test.go b/cmd/mavend/notefragment_test.go new file mode 100644 index 0000000..cf9becc --- /dev/null +++ b/cmd/mavend/notefragment_test.go @@ -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) + } +} diff --git a/internal/router/llmrouter.go b/internal/router/llmrouter.go index 270acf4..1889efc 100644 --- a/internal/router/llmrouter.go +++ b/internal/router/llmrouter.go @@ -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) diff --git a/internal/router/llmrouter_test.go b/internal/router/llmrouter_test.go index 24e7b00..492061f 100644 --- a/internal/router/llmrouter_test.go +++ b/internal/router/llmrouter_test.go @@ -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) } }