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) }