Bug: the router transliterates Latin entity names into Cyrillic before Nexus sees them #131

Merged
claude merged 1 commits from task/476-bug-the-router-transliterates-latin-enti into master 2026-08-04 18:23:16 +02:00
4 changed files with 240 additions and 5 deletions
+45 -5
View File
@@ -514,11 +514,14 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
// Resolve the utterance text as an entity reference through Nexus. An
// ambiguous match must stop and clarify — never guess a mutation target.
// The name comes from entityReferenceText, not straight from the Text slot:
// the model transliterates Latin names as it routes (Vikunja #476).
subject := entityReferenceText(dec)
started := h.now()
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
if err != nil {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
}
@@ -535,7 +538,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
}
if entityID == "" {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
map[string]any{"subject": redactSubject(dec.Slots.Text)})
map[string]any{"subject": redactSubject(subject)})
return ""
}
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
@@ -569,10 +572,21 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
}
verbLower := strings.ToLower(verb)
// With no allowlisted fn the verb is a whole phrase ("restart status muzick
// indexer"), which no capability name ever contains. Read it the other way
// round then: the phrase is the haystack and the capability name is what we
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
// fn is a single verb and containment already means what it says.
loose := !dec.Slots.HasFn
var matches []*hexisclient.Capability
for i, c := range caps {
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
name := strings.ToLower(c.Name)
hit := strings.Contains(name, verbLower) ||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower))
if loose && name != "" && strings.Contains(verbLower, name) {
hit = true
}
if hit {
matches = append(matches, &caps[i])
}
}
@@ -632,3 +646,29 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
})
return "команда выполнена для " + displayName + "."
}
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
// asks what to do.
//
// The stage-3 gate thins an act that never matched an allowlisted fn, so
// "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis
// path was never entered — the capability existed and no utterance could reach
// it (Vikunja #476). Hexis is exactly where an act with no local fn belongs:
// the verb is matched against the capabilities Hexis registers for the entity,
// not against the allowlist.
//
// Narrow on purpose. Only an act, only when the fn slot is still empty, and
// only when Hexis is wired — a box with no ecosystem asks the question it
// always asked. A "" back means Nexus knew no such entity or Hexis had no
// matching capability, and then she asks after all. Authority is unchanged:
// resolution stops on ambiguity and a mutating capability still goes through
// the spoken confirm in handleHexisAct.
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
if h.ecosystem == nil || h.ecosystem.hexis == nil {
return ""
}
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
return ""
}
return h.handleHexisAct(ctx, dec)
}
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"regexp"
"strings"
"unicode"
"github.com/kami/maven/internal/router"
)
// latinRun matches a run of Latin-script words — the shape a service, host or
// project name takes in a Russian sentence. Digits, dot, dash and underscore
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
// hasLatin reports whether s carries a Latin letter.
func hasLatin(s string) bool {
for _, r := range s {
if unicode.In(r, unicode.Latin) {
return true
}
}
return false
}
// entityReferenceText is the name Nexus is asked to resolve.
//
// Normally that is the router's Text slot, which is the verb phrase the model
// wrote. But the resident model rewrites a Russian utterance as it routes, and
// on the way it transliterates: "перезапусти muzick indexer" came back as
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
// service nobody has ever named, so the act cannot resolve its target even
// with every gate open.
//
// The recovery is deliberately narrow. Only when the utterance holds a Latin
// run and the model's Text holds none has a name certainly been rewritten —
// then the longest Latin run in his own words is the reference. Anything else
// keeps the Text slot, so an English utterance and a Russian entity name are
// both untouched. Un-transliterating the Cyrillic back is not attempted: the
// surface form he said is right there, and guessing at a reverse mapping would
// invent a second name to be wrong about.
func entityReferenceText(dec router.Decision) string {
text := dec.Slots.Text
if hasLatin(text) || !hasLatin(dec.Utterance) {
return text
}
longest := ""
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
if len(m) > len(longest) {
longest = m
}
}
longest = strings.TrimSpace(longest)
// A single stray letter is not a name.
if len(longest) < 2 {
return text
}
return longest
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/router"
)
// TestEntityReferenceText pins when his own words win over the model's.
func TestEntityReferenceText(t *testing.T) {
for _, tc := range []struct {
name string
utterance string
text string
want string
}{
{
name: "the model transliterated the name",
utterance: "перезапусти muzick indexer",
text: "перезагрузить музик индексер",
Review

ew.

ew.
want: "muzick indexer",
},
{
name: "it kept the name, so nothing to repair",
utterance: "перезапусти muzick indexer",
text: "перезагрузить muzick indexer",
want: "перезагрузить muzick indexer",
},
{
name: "an all-Russian entity name is not a rewrite",
utterance: "перезапусти домашний сервер",
text: "перезагрузить домашний сервер",
want: "перезагрузить домашний сервер",
},
{
name: "an English turn never enters the recovery",
utterance: "restart muzick indexer",
text: "restart muzick indexer",
want: "restart muzick indexer",
},
{
name: "the longest Latin run is the name",
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
text: "перезагрузить нгинкс",
Review

wtf is this?

wtf is this?
want: "muzick-indexer",
},
{
name: "one stray letter is not a name",
utterance: "перезапусти сервер a",
text: "перезагрузить сервер",
want: "перезагрузить сервер",
},
} {
t.Run(tc.name, func(t *testing.T) {
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
if got := entityReferenceText(dec); got != tc.want {
t.Fatalf("entityReferenceText = %q, want %q", got, tc.want)
}
})
}
}
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
// router hands over a transliterated Text, and Nexus must still be asked about
// the service that exists.
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
}
h.handleHexisAct(ctx, dec)
reqs := nexus.Requests()
if len(reqs) == 0 {
t.Fatal("nexus was never asked")
}
body := string(reqs[0].Body)
if !strings.Contains(body, "muzick indexer") {
t.Fatalf("nexus resolve body = %s, want the name he said", body)
}
}
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
// stage-3 gate thins an act with no allowlisted fn, and that question used to
// be the whole turn, so the Hexis path was unreachable from voice or chat.
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
ctx := context.Background()
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Stage: 3,
Clarify: true,
Slots: router.Slots{Text: "restart status muzick indexer"},
}
reply := h.hexisBeforeClarify(ctx, dec)
if reply == "" {
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
}
if hexis.Count("", "/api/v1") == 0 {
t.Fatal("hexis was never contacted")
}
}
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
// she asks exactly what she asked before.
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
h, _, _ := newClarifyHandler(t)
dec := router.Decision{
Utterance: "перезапусти muzick indexer",
Intent: router.IntentAct,
Stage: 3,
Clarify: true,
Slots: router.Slots{Text: "перезагрузить музик индексер"},
}
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
t.Fatalf("no hexis must mean no reply, got %q", reply)
}
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
t.Fatal("she must still ask what to do")
}
}
+3
View File
@@ -351,6 +351,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// and park the request (clarify.go); otherwise the replier's canned reply
// stands.
if dec.Clarify {
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
return withNotice(expiredNotice, reply)
}
if question, asked := h.askClarify(ctx, dec); asked {
return withNotice(expiredNotice, question)
}