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
+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