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
+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)
}
}
+44 -14
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
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)
}