Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d8224fb04 | |||
| 990a4a99e9 |
@@ -57,6 +57,9 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r
|
||||
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||
// Conversational: build history from dialogue session (prior user turns)
|
||||
// and let the LLM respond from general knowledge + context.
|
||||
if h.phraser == nil {
|
||||
return "поговорили."
|
||||
}
|
||||
history := h.chatHistory()
|
||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||
if err != nil {
|
||||
|
||||
@@ -38,6 +38,21 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
q.Slots.Value = ""
|
||||
return h.actionQuery(ctx, q)
|
||||
}
|
||||
// A complaint is not a fact either (#481). "сеть какая-то медленная" and
|
||||
// "интернет не работает" were stored as `self` rows at confidence 1.00, and
|
||||
// recall reads a self row back later as if it were still true — the same
|
||||
// class of row that outranked live search in #470. The sentence describes a
|
||||
// moment, so she answers it and stores nothing. An explicit "запомни ..."
|
||||
// and anything about him are both left alone by the test.
|
||||
if router.IsTransientComplaint(dec.Utterance) {
|
||||
log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat",
|
||||
dec.Utterance, dec.Slots.Key)
|
||||
c := dec
|
||||
c.Intent = router.IntentChat
|
||||
c.Slots.Key, c.Slots.HasKey = "", false
|
||||
c.Slots.Value = ""
|
||||
return h.actionChat(ctx, c)
|
||||
}
|
||||
now := h.now()
|
||||
req := ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
|
||||
@@ -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: "перезагрузить нгинкс",
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -123,3 +123,48 @@ func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
||||
}
|
||||
return vec
|
||||
}
|
||||
|
||||
// The write half of #481: a complaint about a thing is a state of the
|
||||
// afternoon, not a fact about him. Stored as a `self` row at confidence 1.00
|
||||
// it comes back on recall as if the network were still down.
|
||||
func TestActionFact_ComplaintIsNotWritten(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
reply := h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "сеть какая-то медленная",
|
||||
Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"},
|
||||
})
|
||||
|
||||
if _, err := api.LatestFact(ctx, "network_speed"); err == nil {
|
||||
t.Fatal("a passing complaint was stored as a fact about him")
|
||||
}
|
||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("the complaint was indexed for recall: %+v", hits)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("the turn was neither stored nor answered")
|
||||
}
|
||||
}
|
||||
|
||||
// And the complaint he asked her to keep: the capture verb wins, as it does
|
||||
// over the question gate.
|
||||
func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запомни что интернет не работает",
|
||||
Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"},
|
||||
})
|
||||
|
||||
if _, err := api.LatestFact(ctx, "internet"); err != nil {
|
||||
t.Fatalf("an explicit capture was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// transientStems — the states a thing is in for an afternoon. Compared as
|
||||
// prefixes because Russian inflects the ending: "медленн" covers "медленная",
|
||||
// "медленный" and "медленно" without listing them.
|
||||
var transientStems = []string{
|
||||
"медленн", "тормоз", "лаг", "завис", "виснет", "глюч", "барахл",
|
||||
"отвал", "падает", "упал", "сдох", "греется", "перегре",
|
||||
"slow", "laggy", "stuck", "frozen", "flaky", "broken", "down",
|
||||
}
|
||||
|
||||
// brokenVerbs — what "не ..." is denying when the sentence is a complaint.
|
||||
// "не работает", "не грузит", "не открывается". Prefixes again.
|
||||
var brokenVerbs = []string{
|
||||
"работ", "пашет", "груз", "открыва", "включа", "коннект", "подключ",
|
||||
"work", "load", "connect", "respond",
|
||||
}
|
||||
|
||||
// selfMarkers — the words that make a sentence about him rather than about a
|
||||
// thing. Their presence turns the test off, because losing a fact he meant to
|
||||
// store is worse than keeping a complaint: "я сломал руку" is durable, and
|
||||
// "интернет не работает" is not.
|
||||
var selfMarkers = []string{"я", "мне", "меня", "мной", "i", "me", "my"}
|
||||
|
||||
// IsTransientComplaint reports whether text observes a passing state of some
|
||||
// thing rather than recording a fact.
|
||||
//
|
||||
// It exists because "сеть какая-то медленная" and "интернет не работает" were
|
||||
// written to the fact store as `self` rows at confidence 1.00 (Vikunja #481),
|
||||
// where recall reads them back later as if they were still true. A complaint
|
||||
// describes a moment; the fact store describes him.
|
||||
//
|
||||
// Deterministic, offline, and shaped exactly like IsQuestionShaped: an
|
||||
// explicit capture verb wins over everything, because "запомни что интернет
|
||||
// не работает" is an instruction and not a passing remark. A first-person
|
||||
// marker also turns it off — the test is meant to catch a sentence about a
|
||||
// thing, and it errs toward storing.
|
||||
func IsTransientComplaint(text string) bool {
|
||||
t := strings.TrimSpace(text)
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
toks := planTokens(strings.ToLower(t))
|
||||
for _, v := range captureVerbs {
|
||||
if hasTok(toks, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, m := range selfMarkers {
|
||||
if hasTok(toks, m) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, tok := range toks {
|
||||
for _, stem := range transientStems {
|
||||
if strings.HasPrefix(tok, stem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// "не" plus a verb of working, in either order of the two tokens that
|
||||
// follow it — "не работает" and "не очень работает" both deny the same
|
||||
// thing.
|
||||
for i, tok := range toks {
|
||||
if tok != "не" && tok != "not" && tok != "isn" {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(toks) && j <= i+2; j++ {
|
||||
for _, v := range brokenVerbs {
|
||||
if strings.HasPrefix(toks[j], v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsTransientComplaint(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
// The two rows from the QA run that named this bug.
|
||||
{"сеть какая-то медленная", true},
|
||||
{"интернет не работает", true},
|
||||
{"вайфай тормозит", true},
|
||||
{"сервер завис", true},
|
||||
{"the wifi is slow", true},
|
||||
|
||||
// An instruction wins: he asked for it to be written down.
|
||||
{"запомни что интернет не работает", false},
|
||||
{"запиши что сеть медленная", false},
|
||||
|
||||
// About him, so it stays a fact even when it sounds like a complaint.
|
||||
{"я сломал руку", false},
|
||||
{"мне медленно думается", false},
|
||||
|
||||
// Ordinary captures must not be touched.
|
||||
{"поужинал", false},
|
||||
{"выпил воды", false},
|
||||
{"машина на парковке", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := IsTransientComplaint(tc.text); got != tc.want {
|
||||
t.Errorf("IsTransientComplaint(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user