a Russian act reaches a tool, and the seeds stop disagreeing (V-633)

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
This commit is contained in:
2026-08-06 18:09:11 +04:00
parent 1f8e9f21ce
commit e7ecce2859
9 changed files with 254 additions and 38 deletions
+15 -1
View File
@@ -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
+24 -12
View File
@@ -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": ["перезагрузи сервер", "перезагрузи хост"] }
]
}
}
@@ -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.
+6
View File
@@ -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.
+72
View File
@@ -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)
}
}
+43 -13
View File
@@ -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
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
+16 -3
View File
@@ -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)
}
-7
View File
@@ -64,19 +64,13 @@ what did I note about the garden
weather in london
сколько времени осталось до вечера
какая температура воздуха
сколько человек дома
кто сейчас дома
есть ли кто дома
кто дома сейчас
все ли дома
сколько памяти занято
какая загрузка процессора
сколько свободного места на диске
какой ip адрес у сервера
всё ли работает
сколько сервер работает без перезагрузки
когда сервер запускался
какая версия софта
сколько аптайм
какой статус сервисов
все ли сервисы работают
@@ -86,7 +80,6 @@ weather in london
какая скорость интернета
сколько процессов запущено
как загрузка системы
сколько оперативной памяти свободно
какая температура процессора
почему небо голубое
что такое любовь
-1
View File
@@ -6,7 +6,6 @@
сколько сейчас времени
который час у нас
какой сегодня день недели
как дела у сервера
интернет работает
загрузка сети
тихий режим