diff --git a/cmd/mavend/attentionq.go b/cmd/mavend/attentionq.go index bb5befa..07064a1 100644 --- a/cmd/mavend/attentionq.go +++ b/cmd/mavend/attentionq.go @@ -7,8 +7,10 @@ import ( "github.com/kami/maven/internal/router" ) -// attentionMarkers — the ways he asks what Praxis is holding. Substrings on a -// stem, because "внимание", "внимания" and "вниманию" are one word to him. +// attentionMarkers — the offline floor under the attention topic (topics.go). +// The seeds decide when the embedder is there; this answers when it is not, and +// it stays a substring list on purpose for the reason the other floors do: a +// narrow test made blind beats a broad guess made blind. // // "что нового" is deliberately absent: the feeds source claims it, and it // still should — a question about news is a question about the feeds she @@ -19,6 +21,7 @@ var attentionMarkers = []string{ } // isAttentionQuery reports whether the utterance asks what needs looking at. +// Called through turnIsAbout, never directly. func isAttentionQuery(u string) bool { s := strings.ToLower(strings.TrimSpace(u)) if s == "" { @@ -50,7 +53,7 @@ func isAttentionQuery(u string) bool { // degradation the ecosystem contract asks for, and it comes from the same // handler the act path uses. func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) { - if !isAttentionQuery(t.dec.Utterance) { + if !h.turnIsAbout(ctx, t, topicAttend, isAttentionQuery) { return "", false } if h.ecosystem == nil || h.ecosystem.praxis == nil { diff --git a/cmd/mavend/repair.go b/cmd/mavend/repair.go index 63329e0..f38a667 100644 --- a/cmd/mavend/repair.go +++ b/cmd/mavend/repair.go @@ -5,7 +5,10 @@ import ( "log" "strings" "time" + "unicode" + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/morph" "github.com/kami/maven/internal/router" ) @@ -44,33 +47,28 @@ const repairWindow = 5 * time.Minute // naming an intent alone is an ordinary sentence ("напиши заметку"), and // treating it as a correction would rewrite the last turn every time he used // the word. -var repairMarkers = []string{ - "не так поняла", - "неправильно поняла", - "ты не поняла", - "не поняла меня", - "ты ошиблась", - "это не", - "а не", - "не про то", - "got it wrong", - "not a ", - "that was wrong", -} +// +// From the lexicon, and staying a list rather than becoming seeds (Vikunja +// #528). This runs pre-route, before the turn vector exists, and a correction +// redoes the previous request — so a near-miss would act on something he never +// said. The set's note in lexicon_ru_v1.json carries the same reasoning. +var repairMarkers = lexicon.RepairMarkers() -// repairIntents — the words he uses for each intent. Prefixes, because Russian -// declines them: "заметка", "заметку", "заметкой". +// repairIntents — the words he uses for each intent, as dictionary forms. They +// used to be prefixes ("заметк"), which is what a prefix list costs: "команд" +// also matched "командировка", and "факт" matched "фактически". morph.SameWord +// compares the words themselves (Vikunja #528). var repairIntents = []struct { word string intent router.Intent say string }{ - {"заметк", router.IntentNote, "заметка"}, - {"напоминани", router.IntentReminder, "напоминание"}, + {"заметка", router.IntentNote, "заметка"}, + {"напоминание", router.IntentReminder, "напоминание"}, {"напомнить", router.IntentReminder, "напоминание"}, {"факт", router.IntentFact, "факт"}, {"вопрос", router.IntentQuery, "вопрос"}, - {"команд", router.IntentAct, "команда"}, + {"команда", router.IntentAct, "команда"}, {"note", router.IntentNote, "заметка"}, {"reminder", router.IntentReminder, "напоминание"}, {"fact", router.IntentFact, "факт"}, @@ -101,15 +99,28 @@ func parseRepair(utterance string) (router.Intent, string, bool) { if !marked { return "", "", false } + // Over tokens, not byte offsets. The negation test used to read the string + // immediately before a match, which meant it could only see "не" spelled + // exactly there; a token list makes the previous word plain to read. + toks := repairTokens(s) best, say, at := router.Intent(""), "", -1 - for _, w := range repairIntents { - i := strings.Index(s, w.word) - if i < 0 || negatedAt(s, i) { - continue + for i, tok := range toks { + if at >= 0 && i > at { + break } - // Leftmost wins: "это заметка, а не напоминание" corrects to the first. - if at < 0 || i < at { - best, say, at = w.intent, w.say, i + for _, w := range repairIntents { + if !morph.SameWord(tok, w.word) { + continue + } + if i > 0 && (toks[i-1] == "не" || toks[i-1] == "not") { + // The one he is ruling out: "не напоминание, а заметка". + continue + } + // Leftmost wins: "это заметка, а не напоминание" corrects to the + // first. + if at < 0 || i < at { + best, say, at = w.intent, w.say, i + } } } if at < 0 { @@ -118,12 +129,13 @@ func parseRepair(utterance string) (router.Intent, string, bool) { return best, say, true } -// negatedAt reports whether the word at i is the one he is ruling out. Only -// the words immediately before it are read, so "не напоминание, а заметка" -// negates the first and leaves the second alone. -func negatedAt(s string, i int) bool { - before := strings.TrimSpace(s[:i]) - return strings.HasSuffix(before, "не") || strings.HasSuffix(before, "not") +// repairTokens splits a correction into lowercase word tokens. Punctuation goes, +// because "это заметка, а не напоминание" glues a comma to the word the negation +// test has to look past. +func repairTokens(s string) []string { + return strings.FieldsFunc(s, func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) } // recordTurn keeps the utterance a correction would point at. Only turns she diff --git a/cmd/mavend/repair_test.go b/cmd/mavend/repair_test.go index 4bf8958..c1d69ed 100644 --- a/cmd/mavend/repair_test.go +++ b/cmd/mavend/repair_test.go @@ -117,3 +117,35 @@ func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) { t.Error("a correction to the intent she already used was handled") } } + +// TestRepairIntentWordCollisions — the prefix list matched more than the word +// (Vikunja #528). "команд" is inside "командировка" and "факт" inside +// "фактически", and either one used to name an intent she would redo the turn +// under. +func TestRepairIntentWordCollisions(t *testing.T) { + for _, s := range []string{ + "нет, это про командировку", + "нет, фактически всё нормально", + } { + if _, _, ok := parseRepair(s); ok { + t.Errorf("parseRepair(%q) claimed a correction", s) + } + } + // The declined forms the prefixes existed to cover still work, and the + // negated half is still skipped. + for _, tc := range []struct { + utterance string + want router.Intent + }{ + {"нет, это заметка", router.IntentNote}, + {"ты не так поняла, это заметку надо было", router.IntentNote}, + {"нет, это напоминание, а не заметка", router.IntentReminder}, + {"нет, не напоминание, а заметка", router.IntentNote}, + {"нет, это командой было", router.IntentAct}, + } { + got, _, ok := parseRepair(tc.utterance) + if !ok || got != tc.want { + t.Errorf("parseRepair(%q) = %q, %v; want %q, true", tc.utterance, got, ok, tc.want) + } + } +} diff --git a/cmd/mavend/topics.go b/cmd/mavend/topics.go index f6bbbc0..db17f15 100644 --- a/cmd/mavend/topics.go +++ b/cmd/mavend/topics.go @@ -8,14 +8,14 @@ import ( "github.com/kami/maven/internal/router" ) -// Which subject is this question about — the weather, the house, the LAN, or -// none of them. Third of the three mechanisms replacing hand-written Russian +// Which subject is this question about — the weather, the house, the LAN, what +// needs looking at, or none of them. Third of the three mechanisms replacing hand-written Russian // patterns (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the // sets that can be finished and internal/morph answers the grammar questions; // this is for the sets that can never be finished, because "is this about the // house" is a question about meaning and no word list closes it. // -// The three recognisers this replaces were each built the same way: a stem list, +// The recognisers this replaces were each built the same way: a stem list, // an ask test, a device-noun list, and a bail-out list for the neighbouring // topic. Every one of their own comments admits the shape. isHomeQuery excluded // "погод", "на улице" and "прогноз" by hand because "какая температура на улице" @@ -33,6 +33,10 @@ import ( // to win by a margin, and the losing side of a thin call falls through to the // next query source, which is what the narrow regexes were achieving. // +// A fourth subject joined on the same day: isAttentionQuery, "что требует +// внимания", which is Praxis's operational state and reached the web search +// before the source existed (Vikunja #475). +// // The regexes stay as the offline floor, unchanged, for a handler with no // embedder or a turn whose vector never got computed. They are allowed to remain // narrow now precisely because they are no longer the only answer. @@ -47,6 +51,7 @@ const ( topicWeather topicLabel = "weather" topicHome topicLabel = "home" topicNetwork topicLabel = "network" + topicAttend topicLabel = "attention" topicOther topicLabel = "other" ) @@ -97,6 +102,21 @@ var topicSeedSets = map[topicLabel][]string{ "кто подключён к вайфаю", "what devices are on the network", }, + topicAttend: { + "что требует внимания", + "что не так сейчас", + "на что мне посмотреть", + "что важное я пропустил", + "есть что-то срочное", + "что там висит нерешённое", + // With a thing named. Every other seed asks in the abstract, and the + // attention question he actually asks names his services or his + // projects. + "что не так с сервисами", + "что там с моими проектами", + "what needs attention", + "what needs looking at right now", + }, topicOther: { // Complaints, which are not requests to scan or to read the house. // isNetworkQuery's comment names this one: a scan she runs unasked is @@ -117,6 +137,8 @@ var topicSeedSets = map[topicLabel][]string{ "что я говорил про бэкапы", "что у меня сегодня по календарю", "напомни мне позвонить маме", + // An attention question is about the state of his things; this is not. + "что ты умеешь", "what did i say about backups", }, } @@ -183,9 +205,12 @@ func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok b // the embedder is there, which is every deployed box; floor is the source's own // keyword test, which answers when they are not. // -// A topic that wins without the margin is reported as a pass, and logged: it is -// the one outcome where the seeds and the old regexes are most likely to -// disagree, and a silent near-miss is how a recogniser drifts. +// A topic that wins WITHOUT the margin is handed to the floor rather than +// claimed or dropped, and the near-miss is logged. That is the cascade shape +// again: the better test leads, the offline one always answers, and a thin call +// is exactly where the cheap high-precision test earns its place. Measured, the +// one held-out case that lands there is "вайфай опять отвалился", which reads as +// network by 0.0055; isNetworkQuery says no, so it stays the complaint it is. func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want topicLabel, floor func(string) bool) bool { h.topics.load(ctx, h.embedder) label, margin, ok := h.topics.best(t.vec) @@ -196,8 +221,9 @@ func (h *reactiveHandler) turnIsAbout(ctx context.Context, t *queryTurn, want to return false } if margin < topicMargin { - log.Printf("voice: %q reads as %s by only %.4f; passing it on", t.dec.Utterance, want, margin) - return false + claimed := floor(t.dec.Utterance) + log.Printf("voice: %q reads as %s by only %.4f; the keyword floor says %v", t.dec.Utterance, want, margin, claimed) + return claimed } return true } diff --git a/cmd/mavend/topics_test.go b/cmd/mavend/topics_test.go index e62627d..f5522e3 100644 --- a/cmd/mavend/topics_test.go +++ b/cmd/mavend/topics_test.go @@ -11,8 +11,8 @@ import ( // TestTopicFloorAnswersWithoutSeeds — a handler with no embedder never loads the // seeds, and every topic source has to keep working. This is the case that used -// to be the only one, so a regression here is the three recognisers going -// silent on a box with no embedder at all. +// to be the only one, so a regression here is all four recognisers going silent +// on a box with no embedder at all. func TestTopicFloorAnswersWithoutSeeds(t *testing.T) { h := &reactiveHandler{} for _, tc := range []struct { @@ -24,6 +24,7 @@ func TestTopicFloorAnswersWithoutSeeds(t *testing.T) { {"какая сегодня погода", topicWeather, isWeatherQuery, true}, {"что включено в доме?", topicHome, isHomeQuery, true}, {"какие устройства в сети?", topicNetwork, isNetworkQuery, true}, + {"что требует внимания?", topicAttend, isAttentionQuery, true}, {"почему небо синее", topicWeather, isWeatherQuery, false}, {"я дома", topicHome, isHomeQuery, false}, {"интернет не работает", topicNetwork, isNetworkQuery, false}, @@ -39,13 +40,13 @@ func TestTopicFloorAnswersWithoutSeeds(t *testing.T) { // actually runs. Opt-in via MAVEN_ONNX_LIB, like TestONNXPersonalBoundary. // // Every case is held out: none of these strings is a seed. It asserts what the -// gate does, not what the raw scorer says — a label under topicMargin is not a -// claim, and one held-out case turns on exactly that. +// gate does, not what the raw scorer says — a label under topicMargin is handed +// to the keyword floor, and one case turns on exactly that. // // The first three rows are the collisions the old regexes needed hand-written // bail-outs for: the temperature pair that made isHomeQuery exclude weather -// words, and the -// "посетил" substring that made isNetworkQuery match "сети" as a whole token. +// words, and the "посетил" substring that made isNetworkQuery match "сети" as a +// whole token. func TestONNXTopics(t *testing.T) { lib := os.Getenv("MAVEN_ONNX_LIB") if lib == "" { @@ -61,27 +62,30 @@ func TestONNXTopics(t *testing.T) { cases := []struct { utterance string want topicLabel + floor func(string) bool }{ - {"какая температура на улице?", topicWeather}, - {"какая температура в доме?", topicHome}, - {"сколько машин я посетил?", topicOther}, - {"сколько сейчас градусов", topicWeather}, - {"дождь будет вечером?", topicWeather}, - {"тепло сегодня на улице?", topicWeather}, - {"свет на кухне включен?", topicHome}, - {"что сейчас включено дома", topicHome}, - {"датчики в квартире что показывают", topicHome}, - {"просканируй сеть", topicNetwork}, - {"сколько устройств в локальной сети", topicNetwork}, - {"кто сейчас в сетке", topicNetwork}, - // The case the margin exists for. It reads as network by 0.0055, under - // topicMargin, so the gate passes it on — which is right: it is a - // complaint, and a scan she runs unasked is the behaviour the bounds - // prevent. - {"вайфай опять отвалился", topicOther}, - {"я уже приехал домой", topicOther}, - {"что я говорил про погоду в москве", topicOther}, - {"напомни полить цветы", topicOther}, + {"какая температура на улице?", topicWeather, isWeatherQuery}, + {"какая температура в доме?", topicHome, isHomeQuery}, + {"сколько машин я посетил?", topicOther, nil}, + {"сколько сейчас градусов", topicWeather, isWeatherQuery}, + {"дождь будет вечером?", topicWeather, isWeatherQuery}, + {"тепло сегодня на улице?", topicWeather, isWeatherQuery}, + {"свет на кухне включен?", topicHome, isHomeQuery}, + {"что сейчас включено дома", topicHome, isHomeQuery}, + {"датчики в квартире что показывают", topicHome, isHomeQuery}, + {"просканируй сеть", topicNetwork, isNetworkQuery}, + {"сколько устройств в локальной сети", topicNetwork, isNetworkQuery}, + {"кто сейчас в сетке", topicNetwork, isNetworkQuery}, + // The case the margin exists for. It reads as network by 0.0055, and + // isNetworkQuery says no, so it stays the complaint it is — a scan she + // runs unasked is the behaviour the bounds prevent. + {"вайфай опять отвалился", topicOther, isNetworkQuery}, + {"я уже приехал домой", topicOther, nil}, + {"что я говорил про погоду в москве", topicOther, nil}, + {"напомни полить цветы", topicOther, nil}, + {"что требует моего внимания сейчас", topicAttend, isAttentionQuery}, + {"что не так с базой данных", topicAttend, isAttentionQuery}, + {"есть что-то срочное на сегодня", topicAttend, isAttentionQuery}, } h := &reactiveHandler{embedder: emb} @@ -101,10 +105,13 @@ func TestONNXTopics(t *testing.T) { t.Fatalf("best(%q) not ok", tc.utterance) } // What the gate would do, which is the thing under test: a label that - // does not clear the margin is not a claim. + // does not clear the margin is handed to that source's keyword floor. got := label if margin < topicMargin { got = topicOther + if tc.floor != nil && tc.floor(tc.utterance) { + got = label + } } if got == tc.want { right++ diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index 65b5bca..d044327 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -91,6 +91,16 @@ func CaptureVerbs() []string { return words("capture_verbs") } // NarrativeRequests returns the imperatives that mean "tell me about". func NarrativeRequests() []string { return words("narrative_requests") } +// RepairMarkers lists the ways he says the previous turn was routed wrong. See +// the set's own note for why this one is a list and not a seed set. +func RepairMarkers() []string { return words("repair_markers") } + +// FirstPerson lists every form of the first-person pronoun. Callers use it to +// decide that a sentence is about him: internal/router/complaint.go keeps a +// complaint out of the fact store unless one of these appears, because losing a +// fact he meant to store is the worse mistake. +func FirstPerson() []string { return words("first_person") } + // NotPlaceAfterV returns the words that follow "в" without naming a place. func NotPlaceAfterV() []string { return words("not_place_after_v") } diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index e9f134d..15fdf20 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -109,6 +109,22 @@ "девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три" ] }, + "repair_markers": { + "note": "The ways he says she got it wrong. A closed set of Maven's own vocabulary, like capture_verbs: its members are decided here rather than discovered. NOT the embedder, and that is deliberate — a correction rewrites the previous turn and runs pre-route, before the turn vector exists, so a near-miss would redo a request he did not make. Phrases, matched as substrings.", + "words": [ + "не так поняла", "неправильно поняла", "ты не поняла", "не поняла меня", + "ты ошиблась", "это не", "а не", "не про то", + "got it wrong", "not a ", "that was wrong" + ] + }, + "first_person": { + "note": "Every form of the first-person pronoun, plus the English ones. Closed class in the strictest sense: the language has these and no others. A sentence carrying one is about him, which is what makes it a fact rather than a passing complaint.", + "words": [ + "я", "меня", "мне", "мной", "мною", + "мы", "нас", "нам", "нами", + "i", "me", "my", "mine", "we", "us", "our" + ] + }, "not_place_after_v": { "note": "Words that follow the preposition \"в\" without naming a place, so \"в общем\" and \"в котором часу\" are not read as a city we do not know.", "words": [ diff --git a/internal/router/complaint.go b/internal/router/complaint.go index 7647a89..ac59d09 100644 --- a/internal/router/complaint.go +++ b/internal/router/complaint.go @@ -1,20 +1,33 @@ package router -import "strings" +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{ - "медленн", "тормоз", "лаг", "завис", "виснет", "глюч", "барахл", - "отвал", "падает", "упал", "сдох", "греется", "перегре", + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/morph" +) + +// transientWords — the states a thing is in for an afternoon, as dictionary +// forms. They used to be prefixes, which is how a prefix list always goes wrong: +// "лаг" matched "лагерь" and "падает" matched nothing else it inflected into. +// morph.SameWord compares the words themselves (Vikunja #528). +// +// Russian aspect pairs are two separate verbs, so a slot lists both where both +// are said. English members have no dictionary here and fall back to exact +// comparison, which is what they had. +var transientWords = []string{ + "медленный", "медленно", "тормозить", "тормоз", "лагать", "лаг", + "зависать", "зависнуть", "виснуть", "глючить", "барахлить", + "отваливаться", "отвалиться", "падать", "упасть", "сдохнуть", + "греться", "перегреваться", "перегреться", "slow", "laggy", "stuck", "frozen", "flaky", "broken", "down", } -// brokenVerbs — what "не ..." is denying when the sentence is a complaint. -// "не работает", "не грузит", "не открывается". Prefixes again. -var brokenVerbs = []string{ - "работ", "пашет", "груз", "открыва", "включа", "коннект", "подключ", +// brokenWords — what "не ..." is denying when the sentence is a complaint. +// Dictionary forms, same reason. +var brokenWords = []string{ + "работать", "пахать", "грузить", "грузиться", "открываться", + "включаться", "коннектиться", "подключаться", "work", "load", "connect", "respond", } @@ -22,7 +35,11 @@ var brokenVerbs = []string{ // 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"} +// +// A closed class, so it comes from the lexicon: the first-person pronoun has a +// fixed number of forms and this file was the third place they were typed out +// (Vikunja #528). +var selfMarkers = lexicon.FirstPerson() // IsTransientComplaint reports whether text observes a passing state of some // thing rather than recording a fact. @@ -54,8 +71,8 @@ func IsTransientComplaint(text string) bool { } } for _, tok := range toks { - for _, stem := range transientStems { - if strings.HasPrefix(tok, stem) { + for _, w := range transientWords { + if morph.SameWord(tok, w) { return true } } @@ -68,8 +85,8 @@ func IsTransientComplaint(text string) bool { continue } for j := i + 1; j < len(toks) && j <= i+2; j++ { - for _, v := range brokenVerbs { - if strings.HasPrefix(toks[j], v) { + for _, v := range brokenWords { + if morph.SameWord(toks[j], v) { return true } } diff --git a/internal/router/complaint_test.go b/internal/router/complaint_test.go index 681af96..3104111 100644 --- a/internal/router/complaint_test.go +++ b/internal/router/complaint_test.go @@ -33,3 +33,34 @@ func TestIsTransientComplaint(t *testing.T) { } } } + +// TestComplaintPrefixCollisions — what the prefix list got wrong and the +// dictionary does not (Vikunja #528). Each of these contains a word that starts +// with one of the old stems and is a different word. +func TestComplaintPrefixCollisions(t *testing.T) { + for _, s := range []string{ + // "лаг" matched "лагерь". + "детский лагерь под москвой", + // "падает" was literal, but "падеж" and "падение" start on "пад". + "падение цен на квартиры", + // "отвал" matched "отвальная". + "отвальная в пятницу", + } { + if IsTransientComplaint(s) { + t.Errorf("IsTransientComplaint(%q) = true, want false", s) + } + } + // And the real complaints still read as complaints, in the inflections the + // prefixes were there to cover. + for _, s := range []string{ + "сеть какая-то медленная", + "интернет не работает", + "nextcloud тормозит", + "диск сдохнет скоро", + "сервис не открывается", + } { + if !IsTransientComplaint(s) { + t.Errorf("IsTransientComplaint(%q) = false, want true", s) + } + } +}