From e7ecce2859325907fc68b488b207693d29268d88 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 18:09:11 +0400 Subject: [PATCH] a Russian act reaches a tool, and the seeds stop disagreeing (V-633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tangled defects, fixed together because each one hid the others. DefaultActMatcher matched an exact English prefix and internal/tool.Matcher delegated straight to it, so no Russian utterance could reach a tool: 55 of the 69 lines in models/seeds/act.txt routed to IntentAct and fell to proposeGap. Tools now carry spoken aliases from deploy/mavend.json, matched as exact leading tokens, longest phrase first. Config data, not a stem pattern in code. The comment claiming "the production matcher is fuzzy" was false and is gone. Seven lines were exact duplicates inside models/seeds/query.txt, each one a second identical vector double-weighting its region. "как дела у сервера" carried both a query and a system label. It leaves system.txt, because replySystem's stats arm answers "системная статистика пока не подключена." and always did. The mode inventory records that shape as act.tool.hoststats rather than a system mode. Fixture unchanged at 69/91, and it cannot see any of this: no host-stat case and no Russian act in it. TestActMatcherAliases is the coverage. docs/evals/2026-08-06-russian-acts-reach-tools.md has the numbers. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua --- cmd/mavend/voicewire.go | 16 +++- deploy/mavend.json | 36 ++++++--- .../2026-08-06-russian-acts-reach-tools.md | 77 +++++++++++++++++++ internal/config/voice.go | 6 ++ internal/router/actmatcher_alias_test.go | 72 +++++++++++++++++ internal/router/slots.go | 58 ++++++++++---- internal/tool/tool.go | 19 ++++- models/seeds/query.txt | 7 -- models/seeds/system.txt | 1 - 9 files changed, 254 insertions(+), 38 deletions(-) create mode 100644 docs/evals/2026-08-06-russian-acts-reach-tools.md create mode 100644 internal/router/actmatcher_alias_test.go diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 7fb9772..ede4628 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -176,7 +176,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem // The LAN scanner (Vikunja #257): a read, bounded to the configured // subnets and rate-limited. Off unless the `netscan` block is enabled. w.netscan = wireNetScan(cfg, coreAPI) - matcher := tool.NewMatcher(coreAPI) + matcher := tool.NewMatcher(coreAPI).WithAliases(toolAliases(cfg.Voice.Tools)) // ----- weather provider (Open-Meteo when configured, Stub otherwise) ----- var weatherProvider weather.Provider @@ -515,6 +515,20 @@ func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) { return count, nil } +// toolAliases collects the spoken phrases per tool name. Without them the act +// matcher only ever matched the English tool name, so no Russian utterance could +// reach a tool and every homelab act fell to proposeGap (V-633). +func toolAliases(tools []config.ToolConfig) map[string][]string { + out := make(map[string][]string, len(tools)) + for _, tc := range tools { + if tc.Name == "" || len(tc.Aliases) == 0 { + continue + } + out[tc.Name] = tc.Aliases + } + return out +} + // seedTools upserts the config-declared tools into the store as enabled. Editing // mavend.json is a human act, so a config tool is enabled by definition; this // makes the declarative config the reproducible bootstrap while the store stays diff --git a/deploy/mavend.json b/deploy/mavend.json index ef0785d..42b74fe 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -212,18 +212,30 @@ "clarify_max_attempts": 3, "tool_timeout": "30s", "tools": [ - { "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false }, - { "name": "ps", "cmd": ["docker", "ps"], "scope": "homelab", "destructive": false }, - { "name": "uptime", "cmd": ["uptime"], "scope": "homelab", "destructive": false }, - { "name": "disk", "cmd": ["df", "-h"], "scope": "homelab", "destructive": false }, - { "name": "memory", "cmd": ["free", "-h"], "scope": "homelab", "destructive": false }, - { "name": "logs", "cmd": ["journalctl", "-n", "50", "-u"], "scope": "homelab", "destructive": false }, - { "name": "restart", "cmd": ["systemctl", "restart"], "scope": "homelab", "destructive": true }, - { "name": "stop", "cmd": ["systemctl", "stop"], "scope": "homelab", "destructive": true }, - { "name": "start", "cmd": ["systemctl", "start"], "scope": "homelab", "destructive": true }, - { "name": "docker-restart", "cmd": ["docker", "restart"], "scope": "homelab", "destructive": true }, - { "name": "docker-stop", "cmd": ["docker", "stop"], "scope": "homelab", "destructive": true }, - { "name": "reboot", "cmd": ["systemctl", "reboot"], "scope": "homelab", "destructive": true } + { "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false, + "aliases": ["статус", "покажи статус", "проверь статус"] }, + { "name": "ps", "cmd": ["docker", "ps"], "scope": "homelab", "destructive": false, + "aliases": ["статус докера", "лог докера", "покажи запущенные контейнеры", "покажи контейнеры", "список контейнеров", "что запущено"] }, + { "name": "uptime", "cmd": ["uptime"], "scope": "homelab", "destructive": false, + "aliases": ["покажи uptime", "аптайм", "как работает сервер", "сколько работает сервер"] }, + { "name": "disk", "cmd": ["df", "-h"], "scope": "homelab", "destructive": false, + "aliases": ["сколько места на диске", "сколько свободного места на диске", "место на диске", "покажи диск"] }, + { "name": "memory", "cmd": ["free", "-h"], "scope": "homelab", "destructive": false, + "aliases": ["свободная память", "сколько оперативной памяти свободно", "покажи память"] }, + { "name": "logs", "cmd": ["journalctl", "-n", "50", "-u"], "scope": "homelab", "destructive": false, + "aliases": ["покажи логи", "логи", "лог"] }, + { "name": "restart", "cmd": ["systemctl", "restart"], "scope": "homelab", "destructive": true, + "aliases": ["перезапусти", "перезагрузи", "рестарт"] }, + { "name": "stop", "cmd": ["systemctl", "stop"], "scope": "homelab", "destructive": true, + "aliases": ["останови", "останови сервис"] }, + { "name": "start", "cmd": ["systemctl", "start"], "scope": "homelab", "destructive": true, + "aliases": ["запусти", "запусти сервис"] }, + { "name": "docker-restart", "cmd": ["docker", "restart"], "scope": "homelab", "destructive": true, + "aliases": ["перезапусти контейнер", "перезагрузи контейнер"] }, + { "name": "docker-stop", "cmd": ["docker", "stop"], "scope": "homelab", "destructive": true, + "aliases": ["останови контейнер"] }, + { "name": "reboot", "cmd": ["systemctl", "reboot"], "scope": "homelab", "destructive": true, + "aliases": ["перезагрузи сервер", "перезагрузи хост"] } ] } } diff --git a/docs/evals/2026-08-06-russian-acts-reach-tools.md b/docs/evals/2026-08-06-russian-acts-reach-tools.md new file mode 100644 index 0000000..371b384 --- /dev/null +++ b/docs/evals/2026-08-06-russian-acts-reach-tools.md @@ -0,0 +1,77 @@ +# Russian acts reach tools + +**06-08-2026. V-633.** Measured with `TestONNXBaseline`, 91-case RU routing fixture, +classifier plus the ONNX embedder. No LLM arm in this run. + +## What was wrong + +Three defects, tangled enough that fixing one alone would have looked like progress. + +**No Russian utterance could reach a tool.** `DefaultActMatcher` in +`internal/router/slots.go` matched an exact English prefix, and `internal/tool.Matcher` +delegated straight to it. Its comment claimed "the production matcher is fuzzy, this is the +scaffold floor". There is no other matcher, and `DefaultGrammars` is the only place +`Slots.Fn` is set at stage 0, so the floor was the ceiling. Measured with a throwaway +matcher test over the seeds: + +```text +"покажи статус nginx" ok=false "restart nginx" ok=true fn=restart +"сколько места на диске" ok=false "disk" ok=true fn=disk +"свободная память" ok=false "uptime" ok=true fn=uptime +"перезагрузи роутер" ok=false +``` + +55 of the 69 lines in `models/seeds/act.txt` routed to `IntentAct` and then fell to +`proposeGap`. Praxis was never affected: `PraxisGrammars` fills `Slots.Fn` itself. + +**Seven lines were duplicated inside `models/seeds/query.txt`.** A duplicate is a second +identical vector, so it double-weights its region in nearest-neighbour scoring. + +```text +сколько человек дома +кто сейчас дома +какая загрузка процессора +сколько свободного места на диске +какой ip адрес у сервера +какая версия софта +сколько оперативной памяти свободно +``` + +**`как дела у сервера` carried two labels**, in `query.txt:13` and `system.txt:9`. One +string, two identical vectors, disagreeing about the answer. + +## The change + +Tools carry spoken aliases as config data, in `deploy/mavend.json`. They are not a Russian +stem pattern in code, which CLAUDE.md forbids. They are not on the tool row either. An +ad-hoc tool enabled through `/tools` has no aliases and needs none. + +Aliases and names compete in one table, longest phrase first, so "перезагрузи контейнер" +beats "перезагрузи" and "docker-restart" is not shadowed by "restart". Matching is on exact +leading tokens rather than lemmas. `перезагрузи роутер` is a command and `перезагрузил +роутер` is a fact, and a lemma cannot tell the two apart. That is the trap +`cmd/mavend/quiet_toggle.go` documents for `говори`. + +The seven duplicates are gone, and `как дела у сервера` stays in `query.txt` only. It left +`system.txt` because system cannot answer it: `replySystem`'s +память/загрузк/аптайм arm returns "системная статистика пока не подключена." and always +did. That arm is a stub, not a mode, so the mode inventory now lists the shape as +`act.tool.hoststats`. + +## Result + +**69/91, 75.8% full, unchanged.** Clarify counts unchanged at 0 false and 8 missed. + +Nothing moved, and that is the honest number. The fixture holds no host-stat case and no +Russian act that reaches a tool, so it cannot see either fix. The new coverage is +`TestActMatcherAliases`, which asserts the twelve utterances above plus the two refusals. + +## What this does not fix + +Argument quality. `статус sshd` reaches `systemctl status sshd`, but `логи nginx` reaches +`journalctl -n 50 -u nginx` only because the tool's argv prefix ends in `-u`. An alias whose +remainder is a Russian noun ("перезагрузи роутер") hands `systemctl restart роутер` a target +that does not exist. Free text still reaches an argv, which is the resolution rule the +ecosystem contract states for Hexis and not yet true here. + +The fixture cannot measure any of this. That is the observability gap V-629 is for. diff --git a/internal/config/voice.go b/internal/config/voice.go index 9d9ca09..752a8f6 100644 --- a/internal/config/voice.go +++ b/internal/config/voice.go @@ -48,11 +48,17 @@ type WeatherConfig struct { // ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is // the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that // must not fire from the voice path (they need a confirm on an authed surface). +// +// Aliases are the spoken phrases that reach this tool, Russian included. They +// are config data rather than a pattern in code, and they match as exact leading +// tokens, so an imperative reaches the tool and the past tense of the same verb +// does not. type ToolConfig struct { Name string `json:"name"` Scope string `json:"scope,omitempty"` Cmd []string `json:"cmd"` Destructive bool `json:"destructive,omitempty"` + Aliases []string `json:"aliases,omitempty"` } // Voice defaults, applied in normaliseVoice. diff --git a/internal/router/actmatcher_alias_test.go b/internal/router/actmatcher_alias_test.go new file mode 100644 index 0000000..e1f4345 --- /dev/null +++ b/internal/router/actmatcher_alias_test.go @@ -0,0 +1,72 @@ +package router + +import "testing" + +// Before V-633 the matcher only ever matched the English tool name, so no +// Russian utterance could reach a tool: 55 of the 69 lines in models/seeds/act.txt +// routed to IntentAct and then fell to proposeGap. These are those lines. +func TestActMatcherAliases(t *testing.T) { + m := DefaultActMatcher{ + Fns: []string{"status", "ps", "uptime", "disk", "memory", "logs", + "restart", "stop", "start", "docker-restart", "docker-stop", "reboot"}, + Aliases: map[string][]string{ + "status": {"статус", "покажи статус"}, + "ps": {"статус докера", "что запущено"}, + "uptime": {"покажи uptime", "как работает сервер"}, + "disk": {"сколько места на диске"}, + "memory": {"свободная память"}, + "logs": {"покажи логи", "логи"}, + "restart": {"перезагрузи", "перезапусти"}, + "docker-restart": {"перезагрузи контейнер"}, + "reboot": {"перезагрузи сервер"}, + }, + } + cases := []struct { + utterance string + wantFn string + wantArgs []string + }{ + {"покажи статус nginx", "status", []string{"nginx"}}, + {"статус sshd", "status", []string{"sshd"}}, + {"статус докера", "ps", nil}, + {"что запущено", "ps", nil}, + {"сколько места на диске", "disk", nil}, + {"свободная память", "memory", nil}, + {"покажи uptime", "uptime", nil}, + {"логи nginx", "logs", []string{"nginx"}}, + {"перезагрузи nginx", "restart", []string{"nginx"}}, + // Longest phrase first, so the two-word alias wins over the one word + // inside it and the act reaches the right tool. + {"перезагрузи контейнер maven", "docker-restart", []string{"maven"}}, + {"перезагрузи сервер", "reboot", nil}, + // The English names still match, unchanged. + {"restart nginx", "restart", []string{"nginx"}}, + {"uptime", "uptime", nil}, + } + for _, c := range cases { + fn, args, ok := m.Match(c.utterance) + if !ok || fn != c.wantFn { + t.Errorf("%q: got fn=%q ok=%v, want %q", c.utterance, fn, ok, c.wantFn) + continue + } + if len(args) != len(c.wantArgs) { + t.Errorf("%q: got args=%v, want %v", c.utterance, args, c.wantArgs) + continue + } + for i := range args { + if args[i] != c.wantArgs[i] { + t.Errorf("%q: got args=%v, want %v", c.utterance, args, c.wantArgs) + break + } + } + } + // Past tense is a fact, not a command, and aliases match exact tokens so it + // stays one. This is the trap cmd/mavend/quiet_toggle.go documents. + if fn, _, ok := m.Match("перезагрузил роутер"); ok { + t.Errorf("past tense reached a tool: fn=%q", fn) + } + // A phrase nobody configured still refuses, so the router can clarify. + if fn, _, ok := m.Match("свари кофе"); ok { + t.Errorf("unconfigured phrase reached a tool: fn=%q", fn) + } +} diff --git a/internal/router/slots.go b/internal/router/slots.go index 5907d66..a14d1b8 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -90,27 +90,57 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string, // --- default implementations (scaffold floors; production swaps wholesale) --- -// DefaultActMatcher — exact verb prefix + remainder-as-args. The production -// matcher is fuzzy; this is the scaffold floor. "restart nginx" → fn=restart, -// args=[nginx]. Not on the list → ok=false → the router refuses the act. +// DefaultActMatcher — exact phrase prefix + remainder-as-args. This is the only +// matcher there is: internal/tool.Matcher delegates here over the live enabled +// names, so a phrase that does not match exactly cannot reach a tool. +// "restart nginx" → fn=restart, args=[nginx]. Not on the list → ok=false → the +// router refuses the act. +// +// Aliases map a tool name to spoken phrases, so a Russian utterance reaches an +// English tool name. They come from the deployment config as data, never from a +// stem pattern in code, and they match as exact leading tokens: "перезагрузи +// роутер" is a command and "перезагрузил роутер" is a fact, and lemma matching +// cannot tell the two apart (the trap cmd/mavend/quiet_toggle.go documents). type DefaultActMatcher struct { - Fns []string + Fns []string + Aliases map[string][]string } func (m DefaultActMatcher) Allowlist() []string { return m.Fns } func (m DefaultActMatcher) Match(utterance string) (string, []string, bool) { - u := strings.TrimSpace(utterance) - // longest-verb-first so "restart" can't be shadowed by a shorter prefix. - sorted := append([]string(nil), m.Fns...) - sortDescByLen(sorted) - for _, fn := range sorted { - if u == fn { - return fn, nil, true + u := strings.TrimSpace(strings.ToLower(utterance)) + u = strings.TrimRight(u, "?!.") + // One table of phrase → fn, so an alias and a name compete on length rather + // than on which loop ran first. Longest-first, so "docker-restart" cannot be + // shadowed by "restart" and a two-word alias beats the one-word one inside it. + phrases := make([]string, 0, len(m.Fns)) + fnOf := make(map[string]string, len(m.Fns)) + add := func(phrase, fn string) { + phrase = strings.TrimSpace(strings.ToLower(phrase)) + if phrase == "" { + return } - if strings.HasPrefix(u, fn+" ") { - rest := strings.TrimSpace(strings.TrimPrefix(u, fn+" ")) - return fn, splitArgs(rest), true + if _, seen := fnOf[phrase]; seen { + return + } + fnOf[phrase] = fn + phrases = append(phrases, phrase) + } + for _, fn := range m.Fns { + add(fn, fn) + for _, a := range m.Aliases[fn] { + add(a, fn) + } + } + sortDescByLen(phrases) + for _, p := range phrases { + if u == p { + return fnOf[p], nil, true + } + if strings.HasPrefix(u, p+" ") { + rest := strings.TrimSpace(strings.TrimPrefix(u, p+" ")) + return fnOf[p], splitArgs(rest), true } } return "", nil, false diff --git a/internal/tool/tool.go b/internal/tool/tool.go index f7859f5..60842d2 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -222,11 +222,23 @@ func runProcess(ctx context.Context, argv []string) (string, error) { // router's default prefix logic over the current names. The interface's Match // has no ctx, so it queries with a background context — an in-process sqlite // read on the daemon. -type Matcher struct{ api API } +// Aliases are spoken phrases per tool name, wired from the deployment config so +// a Russian utterance can reach an English tool name. They are not stored on the +// tool row: an ad-hoc tool enabled through /tools has no aliases and needs none. +type Matcher struct { + api API + aliases map[string][]string +} // NewMatcher builds a store-backed act matcher. func NewMatcher(api API) *Matcher { return &Matcher{api: api} } +// WithAliases returns the matcher carrying spoken aliases per tool name. +func (m *Matcher) WithAliases(a map[string][]string) *Matcher { + m.aliases = a + return m +} + func (m *Matcher) names() []string { ts, err := m.api.ListTools(context.Background(), "enabled") if err != nil { @@ -243,7 +255,8 @@ func (m *Matcher) names() []string { // Allowlist — the enabled verbs (for stage-0 grammar wiring / introspection). func (m *Matcher) Allowlist() []string { return m.names() } -// Match — longest-verb-first prefix match over the live enabled allowlist. +// Match — longest-phrase-first prefix match over the live enabled allowlist and +// its configured aliases. func (m *Matcher) Match(utterance string) (string, []string, bool) { - return router.DefaultActMatcher{Fns: m.names()}.Match(utterance) + return router.DefaultActMatcher{Fns: m.names(), Aliases: m.aliases}.Match(utterance) } diff --git a/models/seeds/query.txt b/models/seeds/query.txt index 7d1339b..950911a 100644 --- a/models/seeds/query.txt +++ b/models/seeds/query.txt @@ -64,19 +64,13 @@ what did I note about the garden weather in london сколько времени осталось до вечера какая температура воздуха -сколько человек дома -кто сейчас дома есть ли кто дома кто дома сейчас все ли дома сколько памяти занято -какая загрузка процессора -сколько свободного места на диске -какой ip адрес у сервера всё ли работает сколько сервер работает без перезагрузки когда сервер запускался -какая версия софта сколько аптайм какой статус сервисов все ли сервисы работают @@ -86,7 +80,6 @@ weather in london какая скорость интернета сколько процессов запущено как загрузка системы -сколько оперативной памяти свободно какая температура процессора почему небо голубое что такое любовь diff --git a/models/seeds/system.txt b/models/seeds/system.txt index 885074f..e395eaf 100644 --- a/models/seeds/system.txt +++ b/models/seeds/system.txt @@ -6,7 +6,6 @@ сколько сейчас времени который час у нас какой сегодня день недели -как дела у сервера интернет работает загрузка сети тихий режим