From 2e0e2fd0bb5096c35c21b16605c3fad042cd84c6 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 3 Aug 2026 13:40:33 +0400 Subject: [PATCH] router: a deterministic test for question-shaped text (V-470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The predicate a fact write needs before it trusts a routing decision. Tokenized, not substring: 'что' inside 'чтобы' is not a question. Capture verbs win over every question signal, because 'запиши что я пил воду' contains an interrogative and is still a capture. --- internal/router/question.go | 64 ++++++++++++++++++++++++++++++++ internal/router/question_test.go | 48 ++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/router/question.go create mode 100644 internal/router/question_test.go diff --git a/internal/router/question.go b/internal/router/question.go new file mode 100644 index 0000000..30f4866 --- /dev/null +++ b/internal/router/question.go @@ -0,0 +1,64 @@ +package router + +import "strings" + +// interrogatives — the question words that mark an utterance as asking rather +// than telling. Tokenized, never substring: "что" inside "чтобы" and "как" +// inside "какао" are not questions. +var interrogatives = []string{ + "что", "чего", "какой", "какая", "какое", "какие", "каких", + "кто", "кого", "кому", "чей", "почему", "зачем", "отчего", + "где", "куда", "откуда", "когда", "сколько", "как", + "what", "who", "whom", "why", "when", "where", "which", "how", +} + +// narrativeRequests — "tell me about X" asks for knowledge Maven does not +// hold about him. It carries no question mark and no interrogative, which is +// how "расскажи про битву при Ватерлоо" reached the fact store (#470). +var narrativeRequests = []string{ + "расскажи", "объясни", "опиши", "перечисли", + "tell", "explain", "describe", +} + +// captureVerbs — an explicit instruction to record something. These win over +// every test below, because "запиши что я пил воду" contains an interrogative +// and is still a capture: the word he said is "запиши". +var captureVerbs = []string{ + "запиши", "запомни", "отметь", "заметь", "добавь", "сохрани", + "note", "remember", "log", "save", +} + +// IsQuestionShaped reports whether text asks for something rather than +// records it. It is a deterministic offline test over tokens, so it costs +// nothing and never depends on the model that produced the routing decision. +// +// It exists because a mis-routed question used to be persisted as a fact +// about the owner, with the model's invented answer as the value (#470). The +// predicate is deliberately blunt: refusing to store a question is cheap and +// reversible, storing an invented fact about him is neither. +func IsQuestionShaped(text string) bool { + t := strings.TrimSpace(text) + if t == "" { + return false + } + toks := planTokens(t) + for _, v := range captureVerbs { + if hasTok(toks, v) { + return false + } + } + if strings.HasSuffix(t, "?") { + return true + } + for _, w := range interrogatives { + if hasTok(toks, w) { + return true + } + } + for _, w := range narrativeRequests { + if hasTok(toks, w) { + return true + } + } + return false +} diff --git a/internal/router/question_test.go b/internal/router/question_test.go new file mode 100644 index 0000000..27125ac --- /dev/null +++ b/internal/router/question_test.go @@ -0,0 +1,48 @@ +package router + +import "testing" + +func TestIsQuestionShaped(t *testing.T) { + // The seven utterances #470 recorded, plus the captures that must keep + // working. A capture misread as a question loses a fact; a question + // misread as a capture poisons recall, so the captures are the ones worth + // pinning here. + cases := []struct { + text string + want bool + }{ + {"какая последняя версия языка Go?", true}, + {"что дальше?", true}, + {"расскажи про битву при Ватерлоо", true}, + {"почему небо синее?", true}, + {"какая столица Австралии?", true}, + {"кто такой Никола Тесла?", true}, + {"сколько стоит доллар", true}, + {"who is the premier of Japan", true}, + {"объясни линии Фраунгофера", true}, + + {"запиши что я пил воду", false}, + {"запомни какая у меня машина", false}, + {"отметь что я поужинал", false}, + {"поужинал", false}, + {"я выпил кофе", false}, + {"вода", false}, + {"привет", false}, + {"", false}, + } + for _, c := range cases { + if got := IsQuestionShaped(c.text); got != c.want { + t.Errorf("IsQuestionShaped(%q) = %v, want %v", c.text, got, c.want) + } + } +} + +// Substring matching is what made the day-plan predicates wrong before, and +// this predicate gates a write, so it gets the same guard. +func TestIsQuestionShapedIsTokenized(t *testing.T) { + for _, text := range []string{"чтобы не забыть, я полил кактус", "какао выпил"} { + if IsQuestionShaped(text) { + t.Errorf("IsQuestionShaped(%q) = true; a question word inside a longer word is not a question", text) + } + } +}