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