From 990a4a99e914e4f05af46fea4e69440c8985498d Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 03:38:20 +0400 Subject: [PATCH] ecosystem: ask Nexus for the name he actually said (V-476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in one logged line, both of which put a working capability out of reach of every utterance. The resident model rewrites as it routes, and on the way it transliterates: "перезапусти muzick indexer" came back as "перезагрузить музик индексер", so Nexus was asked to resolve a service nobody has ever named. entityReferenceText takes the longest Latin run out of his own words, but only when the Text slot has lost every Latin letter the utterance had — an English turn and a Russian entity name are both left alone, and reversing the transliteration is not attempted. The second half: the stage-3 gate thins an act that matched no allowlisted fn, and that question was the whole turn, so handleHexisAct never ran. Hexis is where an act with no local fn belongs, so it gets one chance before she asks, and a "" back still leaves her asking. With no ecosystem wired nothing changes. Capability matching reads the phrase as the haystack when there is no fn, because no capability name contains "restart status muzick indexer". Authority is untouched: ambiguity still stops, a mutating capability still goes through the spoken confirm. --- cmd/mavend/ecosystem_acts.go | 50 +++++++++++-- cmd/mavend/entityname.go | 59 +++++++++++++++ cmd/mavend/entityname_test.go | 133 ++++++++++++++++++++++++++++++++++ cmd/mavend/voice.go | 3 + 4 files changed, 240 insertions(+), 5 deletions(-) create mode 100644 cmd/mavend/entityname.go create mode 100644 cmd/mavend/entityname_test.go diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index 867685d..900ae06 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -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) +} diff --git a/cmd/mavend/entityname.go b/cmd/mavend/entityname.go new file mode 100644 index 0000000..bc42b75 --- /dev/null +++ b/cmd/mavend/entityname.go @@ -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 +} diff --git a/cmd/mavend/entityname_test.go b/cmd/mavend/entityname_test.go new file mode 100644 index 0000000..05e7700 --- /dev/null +++ b/cmd/mavend/entityname_test.go @@ -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: "перезагрузить нгинкс", + 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") + } +} diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index eb8be4c..e7f4764 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -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) }