Bug: the router transliterates Latin entity names into Cyrillic before Nexus sees them #131
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: "перезагрузить музик индексер",
|
||||
|
|
||||
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: "перезагрузить нгинкс",
|
||||
|
kami
commented
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user
ew.