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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user