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) } }