diff --git a/CLAUDE.md b/CLAUDE.md index 779e26b..aeee1ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -198,6 +198,32 @@ All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_ll note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training workspace enforces that the Go and relabelling prompts remain identical. +## Russian patterns — three mechanisms, no fourth + +Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a +pattern, and the resident model cannot be asked per turn either). A regex whose output is a +fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an +argv list — is not. Before writing a Russian word list, pick one of these: + +- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives, + capture verbs, cardinals, day offsets, weekdays, months, spoken hours. Editing a word is + a data change, and there is exactly one copy: months used to live in three files. +- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm` + and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb + slot that means the imperative must be matched exactly — `говори` and `говорил` are one + lemma and only one of them is a command (`cmd/mavend/quiet_toggle.go`). +- **`cmd/mavend/topics.go` and the embedder** — open sets, where the question is what a + turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored against the + turn's own query vector. Same shape as the personal boundary in `personalboundary.go`, + with one difference: a topic must clear the runner-up by `topicMargin`, because a false + claim here spends a network scan rather than one honest "не знаю". The old keyword tests + stay as the offline floor and may remain narrow, since they are no longer the only answer. +- **The ecosystem trio** — when the answer is not in the utterance at all. Identity is + Nexus's, never a local pattern. + +Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the +`TestONNX*` tests, not eyeballed. + ## Non-goals (hard constraints) Not a nag, not autonomous. Maven's persona is **feminine** — Russian diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 236f3fb..5a4ebce 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -346,7 +346,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri // calls States and nothing else, so there is no confirm turn here — the only // way to CHANGE something is an enabled allowlist row through tool.Executor. func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) { - if !isHomeQuery(t.dec.Utterance) { + if !h.turnIsAbout(ctx, t, topicHome, isHomeQuery) { return "", false } if h.home == nil { @@ -368,7 +368,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, // because Scan takes no target — the utterance selects the question, never the // subnet. func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) { - if !isNetworkQuery(t.dec.Utterance) { + if !h.turnIsAbout(ctx, t, topicNetwork, isNetworkQuery) { return "", false } if h.netscan == nil { @@ -383,7 +383,7 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin } func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) { - if !isWeatherQuery(t.dec.Utterance) { + if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) { return "", false } loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation) diff --git a/cmd/mavend/topics.go b/cmd/mavend/topics.go new file mode 100644 index 0000000..f6bbbc0 --- /dev/null +++ b/cmd/mavend/topics.go @@ -0,0 +1,203 @@ +package main + +import ( + "context" + "log" + "sync" + + "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 +// 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, +// 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 "какая температура на улице" +// and "какая температура в доме" share their only content word. isNetworkQuery +// matched "сети" as a whole token because the substring lives inside "посетил", +// so "сколько машин я посетил" read as a request to scan the LAN. Those are not +// bugs in the lists, they are the lists being asked to do semantics. +// +// So the seeds decide, the same way the personal boundary does +// (personalboundary.go), against the same embedder and the same query vector the +// turn already carries. One difference in the gate, and it is deliberate. The +// boundary claims on the sign of the difference, because there a false claim +// costs one honest "не знаю". Here a false claim runs a network scan, or names a +// capability as off on a box where it is simply not the subject — so a topic has +// 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. +// +// 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. + +// topicLabel — the subjects worth telling apart, plus the one that means none of +// them. topicOther is a real class and not a threshold: a question needs +// somewhere to lose TO, and "интернет не работает" losing to a set that contains +// complaints is a better statement than it failing a number. +type topicLabel string + +const ( + topicWeather topicLabel = "weather" + topicHome topicLabel = "home" + topicNetwork topicLabel = "network" + topicOther topicLabel = "other" +) + +// topicMargin — how far a topic must clear the runner-up. Small, because the +// margins between neighbouring topics are small: measured on held-out +// utterances, a true weather question clears the home set by roughly 0.02 to +// 0.09 and the nearest wrong call sits under 0.01. It exists at all for the +// asymmetry named above — this gate spends a scan, so a coin-flip falls +// through rather than acts. +const topicMargin = 0.01 + +// topicSeedSets — frozen scoring data, like personalSeeds. Editing one moves a +// recogniser and has to be re-measured against TestONNXTopics, not eyeballed. +// +// Each set covers the phrasings its old regex covered, INCLUDING the ones it +// needed a bail-out list for: the weather set carries "какая температура на +// улице" and the home set "какая температура в доме", so the pair that forced +// isHomeQuery to exclude weather words by hand is now just two seeds sitting on +// their own sides. +var topicSeedSets = map[topicLabel][]string{ + topicWeather: { + "какая сегодня погода", + "какая температура на улице", + "будет дождь сегодня", + "на улице холодно", + "прогноз погоды на завтра", + "сколько градусов сейчас", + "what is the weather like", + "is it going to rain today", + }, + topicHome: { + "что включено в доме", + "какая температура в доме", + "свет в квартире горит", + "сколько лампочек включено дома", + "что у меня дома с датчиками", + "умный дом что сейчас работает", + "розетки в доме включены", + "what is on in the house", + }, + topicNetwork: { + "какие устройства в сети", + "кто в сети сейчас", + "просканируй локальную сеть", + "сколько машин в сетке", + "покажи хосты в сети", + "какие адреса заняты в локальной сети", + "кто подключён к вайфаю", + "what devices are on the network", + }, + 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 + // the noisy behaviour the bounds exist to prevent. + "интернет не работает", + "вайфай тормозит", + "свет погас", + // Statements. "я дома" was the reason isHomeQuery needed an ask test. + "я дома", + "я уже дома", + // The collision that made "сети" a whole-token match. + "сколько машин я посетил", + "сколько домов мы посмотрели", + // Ordinary questions, his and the world's, so a topic has something + // real to lose to rather than an arbitrary floor. + "почему небо синее", + "какая столица франции", + "что я говорил про бэкапы", + "что у меня сегодня по календарю", + "напомни мне позвонить маме", + "what did i say about backups", + }, +} + +// topicIndex holds the embedded seeds. Zero value is usable and means "not +// loaded yet"; a handler built without an embedder never loads and every caller +// uses its own floor instead. +type topicIndex struct { + once sync.Once + vecs map[topicLabel][][]float32 + loaded bool +} + +// load embeds every set once per process, on the QUERY side — a question +// compared with a question, for the reason personalBoundary.load gives. +func (x *topicIndex) load(ctx context.Context, emb router.Embedder) { + x.once.Do(func() { + if emb == nil { + return + } + vecs := make(map[topicLabel][][]float32, len(topicSeedSets)) + for label, seeds := range topicSeedSets { + out := make([][]float32, 0, len(seeds)) + for _, s := range seeds { + v, err := router.EmbedQuery(ctx, emb, s) + if err != nil { + log.Printf("voice: topic seeds unavailable (%v); falling back to keyword matching", err) + return + } + out = append(out, v) + } + vecs[label] = out + } + x.vecs, x.loaded = vecs, true + }) +} + +// best returns the nearest label, how far it cleared the runner-up, and whether +// the seeds answered at all. ok is false when they are not loaded, which is the +// caller's signal to use its floor. +func (x *topicIndex) best(vec []float32) (label topicLabel, margin float64, ok bool) { + if !x.loaded || len(vec) == 0 { + return "", 0, false + } + first, second := -1.0, -1.0 + for l, seeds := range x.vecs { + top := -1.0 + for _, s := range seeds { + if c := cosine(vec, s); c > top { + top = c + } + } + switch { + case top > first: + label, first, second = l, top, first + case top > second: + second = top + } + } + return label, first - second, true +} + +// turnIsAbout — the recogniser every topic source calls. The seeds decide when +// 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. +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) + if !ok { + return floor(t.dec.Utterance) + } + if label != want { + 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 + } + return true +} diff --git a/cmd/mavend/topics_test.go b/cmd/mavend/topics_test.go new file mode 100644 index 0000000..e62627d --- /dev/null +++ b/cmd/mavend/topics_test.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/kami/maven/internal/router" +) + +// 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. +func TestTopicFloorAnswersWithoutSeeds(t *testing.T) { + h := &reactiveHandler{} + for _, tc := range []struct { + utterance string + label topicLabel + floor func(string) bool + want bool + }{ + {"какая сегодня погода", topicWeather, isWeatherQuery, true}, + {"что включено в доме?", topicHome, isHomeQuery, true}, + {"какие устройства в сети?", topicNetwork, isNetworkQuery, true}, + {"почему небо синее", topicWeather, isWeatherQuery, false}, + {"я дома", topicHome, isHomeQuery, false}, + {"интернет не работает", topicNetwork, isNetworkQuery, false}, + } { + turn := &queryTurn{dec: router.Decision{Utterance: tc.utterance}} + if got := h.turnIsAbout(context.Background(), turn, tc.label, tc.floor); got != tc.want { + t.Errorf("turnIsAbout(%q, %s) = %v, want %v", tc.utterance, tc.label, got, tc.want) + } + } +} + +// TestONNXTopics — the number that matters, scored against the embedder homesrv +// 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. +// +// 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. +func TestONNXTopics(t *testing.T) { + lib := os.Getenv("MAVEN_ONNX_LIB") + if lib == "" { + t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") + } + dir := filepath.Join("../..", "models/embedder/multilingual-e5-small") + emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib) + if err != nil { + t.Skipf("onnx embedder unavailable: %v", err) + } + defer emb.Close() + + cases := []struct { + utterance string + want topicLabel + }{ + {"какая температура на улице?", 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}, + } + + h := &reactiveHandler{embedder: emb} + ctx := context.Background() + h.topics.load(ctx, emb) + if !h.topics.loaded { + t.Fatal("topic seeds did not load") + } + right := 0 + for _, tc := range cases { + vec, err := router.EmbedQuery(ctx, emb, tc.utterance) + if err != nil { + t.Fatalf("embed %q: %v", tc.utterance, err) + } + label, margin, ok := h.topics.best(vec) + if !ok { + 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. + got := label + if margin < topicMargin { + got = topicOther + } + if got == tc.want { + right++ + } else { + t.Errorf("%q: %s by %.4f, want %s", tc.utterance, label, margin, tc.want) + } + t.Logf(" %-40s -> %-8s margin %.4f", tc.utterance, label, margin) + } + t.Logf("topics: %d/%d", right, len(cases)) +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 144cbb1..a0d3ac2 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -77,6 +77,11 @@ type reactiveHandler struct { tts tts.Synthesizer router *router.Router embedder router.Embedder // reused for note write/query (same model as the classifier) + // topics — the embedded seed sets behind the weather, house and LAN + // recognisers (topics.go). Same lifecycle as boundary below: zero value is + // usable, loads on first query, and with no embedder it never loads and + // each source falls back to its own keyword test. + topics topicIndex // boundary — the embedded seed sets behind the personal boundary // (personalboundary.go). Zero value is usable and loads on first query; // with no embedder it never loads and the boundary uses personalMarkers. diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index 92445f1..65b5bca 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -20,6 +20,7 @@ import ( "embed" "encoding/json" "fmt" + "sort" "strings" "unicode" "unicode/utf8" @@ -112,6 +113,20 @@ func DayOffset(word string) (int, bool) { return n, ok } +// DayOffsetWords lists the relative day words themselves, sorted so the order is +// stable across builds — a caller that folds them into a regexp alternation would +// otherwise produce a different pattern every run. Map iteration order is why +// this sorts rather than the caller. +func DayOffsetWords() []string { + vals := ru.Sets["day_offsets"].Values + out := make([]string, 0, len(vals)) + for w := range vals { + out = append(out, w) + } + sort.Strings(out) + return out +} + // DayOffsetIn finds a relative day word anywhere in a phrase and reports its // offset. Where two words appear, the one that moves furthest from today wins in // absolute terms: "не сегодня, а послезавтра" is about the day after tomorrow, diff --git a/internal/router/stage0.go b/internal/router/stage0.go index fac9fb6..8425bba 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -3,6 +3,9 @@ package router import ( "regexp" "strings" + "unicode" + + "github.com/kami/maven/internal/lexicon" ) // Grammar — one stage-0 exact-match pattern. Wake-word + known command grammar @@ -237,7 +240,7 @@ func NarrativeQueryGrammars() []Grammar { // Anchored at the start: "запиши что мне рассказали" is a capture, // and a narrative verb buried mid-utterance is not the shape. Name: "narrative-query", - Pattern: regexp.MustCompile(`(?i)^\s*(расскажи|объясни|опиши|перечисли|tell|explain|describe)(\s+(.*))?$`), + Pattern: narrativeQueryPattern, Build: narrativeQueryBuild, }, } @@ -275,11 +278,72 @@ func narrativeQueryBuild(m []string) (Decision, bool) { return agendaQueryBuild(m) } +// narrativeQueryPattern — "расскажи про X", built from the lexicon rather than +// spelled out here (Vikunja #527). The verbs used to be a second copy of +// lexicon.NarrativeRequests, and a second copy of a closed set is a set that +// drifts: adding "поясни" in the data file left this rule not knowing it. +// +// Anchored at the start, which was the point of the old literal and still is: +// "запиши что мне рассказали" is a capture, and a narrative verb buried +// mid-utterance is not the shape. +var narrativeQueryPattern = regexp.MustCompile( + `(?i)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") + `)(\s+(.*))?$`) + // dayWordPattern — the day words an agenda question can name. Weekdays appear // in the accusative and prepositional forms the questions actually use ("в -// среду", "на среде"), which is why the stems carry an inflection tail rather -// than a fixed ending. -const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)` +// среду", "на среде"), so each one contributes its stem plus an inflection +// tail; the relative day words are exact. +// +// Built from the lexicon for the same reason as above. The literal that stood +// here spelled all seven weekdays out a second time, in a third file after +// cmd/mavend/voice.go and internal/ttsnorm. +var dayWordPattern = buildDayWordPattern() + +// buildDayWordPattern — one alternation over the relative day words, the +// weekday stems, and the two period words that are in no closed set ("на +// выходных", "на неделе" name a span, not a day). +func buildDayWordPattern() string { + alts := []string{`выходн[а-я]+`, `недел[а-я]+`} + for _, w := range lexicon.DayOffsetWords() { + if strings.Contains(w, " ") || !isCyrillic(w) { + // Multi-word and English members belong to the offset lookup, not + // to a Russian agenda pattern. + continue + } + alts = append(alts, regexp.QuoteMeta(w)) + } + for i := 0; i < 7; i++ { + day := lexicon.Weekday(i) + if day == "" { + continue + } + alts = append(alts, weekdayStem(day)+`[а-я]*`) + } + return `(` + strings.Join(alts, "|") + `)` +} + +// weekdayStem trims the nominative ending off a weekday so the pattern matches +// the case forms an agenda question uses: "среда" has to reach "в среду", and +// "понедельник" already ends on its stem. +func weekdayStem(day string) string { + r := []rune(day) + switch r[len(r)-1] { + case 'а', 'я', 'е', 'о', 'ь': + return string(r[:len(r)-1]) + } + return day +} + +// isCyrillic reports whether every rune is Cyrillic. Used to keep the English +// members of a bilingual lexicon set out of a Russian-only pattern. +func isCyrillic(s string) bool { + for _, r := range s { + if !unicode.Is(unicode.Cyrillic, r) { + return false + } + } + return s != "" +} // agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on // the intent only: the utterance travels intact and the query chain's own