From 3d8224fb04e947f149d13b9d95b06a9aef9f72e6 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 03:42:07 +0400 Subject: [PATCH] router, mavend: a complaint about a thing is not a fact about him (V-481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "сеть какая-то медленная" and "интернет не работает" were written as `self` rows at confidence 1.00. Recall reads a self row back later as if it were still true, and that is the class of row that outranked live search in #470 — so a slow afternoon becomes a standing belief about his network. IsTransientComplaint is the same shape as IsQuestionShaped: deterministic, offline, and off by default in the two cases where losing a real capture would cost more than keeping a complaint. An explicit "запомни ..." wins, because he asked. A first-person marker wins, because "я сломал руку" is durable and the test is meant for sentences about things. She answers the turn as chat instead of storing it. actionChat now has the same nil-phraser floor the other model callers have. The second defect filed here — a reply body of literally "{" — was closed by the errBrokenJSON path in V-397 and needs nothing further. --- cmd/mavend/actions.go | 3 ++ cmd/mavend/actions_fact.go | 15 ++++++ cmd/mavend/factgate_test.go | 45 ++++++++++++++++++ internal/router/complaint.go | 79 +++++++++++++++++++++++++++++++ internal/router/complaint_test.go | 35 ++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 internal/router/complaint.go create mode 100644 internal/router/complaint_test.go diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index f6a9956..4935ac8 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -57,6 +57,9 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string { // Conversational: build history from dialogue session (prior user turns) // and let the LLM respond from general knowledge + context. + if h.phraser == nil { + return "поговорили." + } history := h.chatHistory() reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history) if err != nil { diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go index 5645282..487a16f 100644 --- a/cmd/mavend/actions_fact.go +++ b/cmd/mavend/actions_fact.go @@ -38,6 +38,21 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s q.Slots.Value = "" return h.actionQuery(ctx, q) } + // A complaint is not a fact either (#481). "сеть какая-то медленная" and + // "интернет не работает" were stored as `self` rows at confidence 1.00, and + // recall reads a self row back later as if it were still true — the same + // class of row that outranked live search in #470. The sentence describes a + // moment, so she answers it and stores nothing. An explicit "запомни ..." + // and anything about him are both left alone by the test. + if router.IsTransientComplaint(dec.Utterance) { + log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat", + dec.Utterance, dec.Slots.Key) + c := dec + c.Intent = router.IntentChat + c.Slots.Key, c.Slots.HasKey = "", false + c.Slots.Value = "" + return h.actionChat(ctx, c) + } now := h.now() req := ipc.WriteFactReq{ Ts: now, diff --git a/cmd/mavend/factgate_test.go b/cmd/mavend/factgate_test.go index 5dc205f..d51b9dc 100644 --- a/cmd/mavend/factgate_test.go +++ b/cmd/mavend/factgate_test.go @@ -123,3 +123,48 @@ func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 { } return vec } + +// The write half of #481: a complaint about a thing is a state of the +// afternoon, not a fact about him. Stored as a `self` row at confidence 1.00 +// it comes back on recall as if the network were still down. +func TestActionFact_ComplaintIsNotWritten(t *testing.T) { + ctx := context.Background() + h, api := newFactGateHandler(t, time.Now()) + + reply := h.actionFact(ctx, router.Decision{ + Intent: router.IntentFact, + Utterance: "сеть какая-то медленная", + Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"}, + }) + + if _, err := api.LatestFact(ctx, "network_speed"); err == nil { + t.Fatal("a passing complaint was stored as a fact about him") + } + hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3) + if err != nil { + t.Fatalf("memory search: %v", err) + } + if len(hits) != 0 { + t.Fatalf("the complaint was indexed for recall: %+v", hits) + } + if reply == "" { + t.Fatal("the turn was neither stored nor answered") + } +} + +// And the complaint he asked her to keep: the capture verb wins, as it does +// over the question gate. +func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) { + ctx := context.Background() + h, api := newFactGateHandler(t, time.Now()) + + h.actionFact(ctx, router.Decision{ + Intent: router.IntentFact, + Utterance: "запомни что интернет не работает", + Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"}, + }) + + if _, err := api.LatestFact(ctx, "internet"); err != nil { + t.Fatalf("an explicit capture was refused: %v", err) + } +} diff --git a/internal/router/complaint.go b/internal/router/complaint.go new file mode 100644 index 0000000..7647a89 --- /dev/null +++ b/internal/router/complaint.go @@ -0,0 +1,79 @@ +package router + +import "strings" + +// transientStems — the states a thing is in for an afternoon. Compared as +// prefixes because Russian inflects the ending: "медленн" covers "медленная", +// "медленный" and "медленно" without listing them. +var transientStems = []string{ + "медленн", "тормоз", "лаг", "завис", "виснет", "глюч", "барахл", + "отвал", "падает", "упал", "сдох", "греется", "перегре", + "slow", "laggy", "stuck", "frozen", "flaky", "broken", "down", +} + +// brokenVerbs — what "не ..." is denying when the sentence is a complaint. +// "не работает", "не грузит", "не открывается". Prefixes again. +var brokenVerbs = []string{ + "работ", "пашет", "груз", "открыва", "включа", "коннект", "подключ", + "work", "load", "connect", "respond", +} + +// selfMarkers — the words that make a sentence about him rather than about a +// thing. Their presence turns the test off, because losing a fact he meant to +// store is worse than keeping a complaint: "я сломал руку" is durable, and +// "интернет не работает" is not. +var selfMarkers = []string{"я", "мне", "меня", "мной", "i", "me", "my"} + +// IsTransientComplaint reports whether text observes a passing state of some +// thing rather than recording a fact. +// +// It exists because "сеть какая-то медленная" and "интернет не работает" were +// written to the fact store as `self` rows at confidence 1.00 (Vikunja #481), +// where recall reads them back later as if they were still true. A complaint +// describes a moment; the fact store describes him. +// +// Deterministic, offline, and shaped exactly like IsQuestionShaped: an +// explicit capture verb wins over everything, because "запомни что интернет +// не работает" is an instruction and not a passing remark. A first-person +// marker also turns it off — the test is meant to catch a sentence about a +// thing, and it errs toward storing. +func IsTransientComplaint(text string) bool { + t := strings.TrimSpace(text) + if t == "" { + return false + } + toks := planTokens(strings.ToLower(t)) + for _, v := range captureVerbs { + if hasTok(toks, v) { + return false + } + } + for _, m := range selfMarkers { + if hasTok(toks, m) { + return false + } + } + for _, tok := range toks { + for _, stem := range transientStems { + if strings.HasPrefix(tok, stem) { + return true + } + } + } + // "не" plus a verb of working, in either order of the two tokens that + // follow it — "не работает" and "не очень работает" both deny the same + // thing. + for i, tok := range toks { + if tok != "не" && tok != "not" && tok != "isn" { + continue + } + for j := i + 1; j < len(toks) && j <= i+2; j++ { + for _, v := range brokenVerbs { + if strings.HasPrefix(toks[j], v) { + return true + } + } + } + } + return false +} diff --git a/internal/router/complaint_test.go b/internal/router/complaint_test.go new file mode 100644 index 0000000..681af96 --- /dev/null +++ b/internal/router/complaint_test.go @@ -0,0 +1,35 @@ +package router + +import "testing" + +func TestIsTransientComplaint(t *testing.T) { + for _, tc := range []struct { + text string + want bool + }{ + // The two rows from the QA run that named this bug. + {"сеть какая-то медленная", true}, + {"интернет не работает", true}, + {"вайфай тормозит", true}, + {"сервер завис", true}, + {"the wifi is slow", true}, + + // An instruction wins: he asked for it to be written down. + {"запомни что интернет не работает", false}, + {"запиши что сеть медленная", false}, + + // About him, so it stays a fact even when it sounds like a complaint. + {"я сломал руку", false}, + {"мне медленно думается", false}, + + // Ordinary captures must not be touched. + {"поужинал", false}, + {"выпил воды", false}, + {"машина на парковке", false}, + {"", false}, + } { + if got := IsTransientComplaint(tc.text); got != tc.want { + t.Errorf("IsTransientComplaint(%q) = %v, want %v", tc.text, got, tc.want) + } + } +}