mavend: say the act replies from the file (V-504)

Also fixes a flake this stack introduced: the feeds test matched "ничего
нового" as a substring, and query_ru_v1.json can answer with "в лентах тихо".
It asks the entry now, like the others.
This commit is contained in:
2026-08-04 01:38:14 +04:00
parent 5b4192acb5
commit f3c0540b42
5 changed files with 54 additions and 42 deletions
+9 -8
View File
@@ -6,6 +6,7 @@ import (
"log" "log"
"github.com/kami/maven/internal/mcp" "github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/tool" "github.com/kami/maven/internal/tool"
) )
@@ -50,31 +51,31 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
// destructive: park it and ask. The next utterance answers. // destructive: park it and ask. The next utterance answers.
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args) phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
h.park(dec.Slots.Fn, dec.Slots.Args, phrase) h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
return "выполнить «" + phrase + "»? скажи «да» или «нет»." return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
case errors.Is(err, tool.ErrNotEnabled): case errors.Is(err, tool.ErrNotEnabled):
return h.proposeGap(ctx, dec) return h.proposeGap(ctx, dec)
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer): case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
// The row is enabled and the backend is gone. Drafting a proposal // The row is enabled and the backend is gone. Drafting a proposal
// for it (the ErrNotEnabled path) would be answering the wrong // for it (the ErrNotEnabled path) would be answering the wrong
// question. // question.
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён." return phraser.A(phraser.ActServerDown, nil)
case errors.Is(err, mcp.ErrToolGone): case errors.Is(err, mcp.ErrToolGone):
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools." return phraser.A(phraser.ActWithdrawn, nil)
case errors.Is(err, mcp.ErrNeedsArgs): case errors.Is(err, mcp.ErrNeedsArgs):
// An MCP tool that wants named arguments a spoken verb cannot // An MCP tool that wants named arguments a spoken verb cannot
// supply. Guessing them would be a wrong act, so she says so // supply. Guessing them would be a wrong act, so she says so
// instead — the tool is still runnable from the authed surface, // instead — the tool is still runnable from the authed surface,
// where a human types them. // where a human types them.
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать." return phraser.A(phraser.ActNeedsArgs, nil)
} }
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err) log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
if out != "" { if out != "" {
return "не получилось выполнить команду: " + firstLine(out) return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
} }
return "не получилось выполнить команду." return phraser.A(phraser.ActFail, nil)
} }
if out != "" { if out != "" {
return "готово: " + firstLine(out) return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
} }
return "готово." return phraser.A(phraser.ActDone, nil)
} }
+27 -26
View File
@@ -9,6 +9,7 @@ import (
"time" "time"
hexisclient "github.com/kami/hexis/pkg/client" hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router" "github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
) )
@@ -144,10 +145,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
log.Printf("ecosystem: praxis attention: %v", err) log.Printf("ecosystem: praxis attention: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err), h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
started, traceErrorFields(err)) started, traceErrorFields(err))
return "не могу сейчас узнать, что требует внимания." return phraser.A(phraser.AttentionFail, nil)
} }
if len(items) == 0 { if len(items) == 0 {
return "ничего не требует внимания." return phraser.A(phraser.AttentionNone, nil)
} }
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)}) h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
var parts []string var parts []string
@@ -175,7 +176,7 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
} }
} }
} }
return "требует внимания: " + strings.Join(parts, "; ") return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
} }
// listChangesCapability reads the recent-changes feed. // listChangesCapability reads the recent-changes feed.
@@ -192,10 +193,10 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
log.Printf("ecosystem: praxis changes: %v", err) log.Printf("ecosystem: praxis changes: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err), h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
started, traceErrorFields(err)) started, traceErrorFields(err))
return "не могу сейчас узнать об изменениях." return phraser.A(phraser.ChangesFail, nil)
} }
if len(changes) == 0 { if len(changes) == 0 {
return "нет изменений." return phraser.A(phraser.ChangesNone, nil)
} }
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)}) h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
var parts []string var parts []string
@@ -204,7 +205,7 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
typ, _ := c["change_type"].(string) typ, _ := c["change_type"].(string)
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
} }
return "изменения: " + strings.Join(parts, "; ") return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
} }
// entityAttentionCapability answers "what's going on with X" by resolving X to // entityAttentionCapability answers "what's going on with X" by resolving X to
@@ -230,12 +231,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
subject = dec.Slots.Text subject = dec.Slots.Text
} }
if subject == "" { if subject == "" {
return "про что именно спросить?" return phraser.A(phraser.EcoAboutWhat, nil)
} }
if h.ecosystem == nil || h.ecosystem.nexus == nil { if h.ecosystem == nil || h.ecosystem.nexus == nil {
// Without Nexus there is no canonical ref to scope by. Say so rather // Without Nexus there is no canonical ref to scope by. Say so rather
// than quietly answering about something else. // than quietly answering about something else.
return "не могу связать это с сущностью — Nexus не настроен." return phraser.A(phraser.EcoNoNexus, nil)
} }
started := h.now() started := h.now()
@@ -248,15 +249,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)})) mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
if unauthorizedEcosystemError(err) { if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен." return phraser.A(phraser.EcoDenied, nil)
} }
return "экосистема недоступна, попробуй ещё раз." return phraser.A(phraser.EcoDown, nil)
} }
if len(ambiguous) > 0 { if len(ambiguous) > 0 {
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?" return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
} }
if entityID == "" { if entityID == "" {
return "не знаю такой сущности." return phraser.A(phraser.EcoUnknownEntity, nil)
} }
if displayName == "" { if displayName == "" {
displayName = subject displayName = subject
@@ -268,7 +269,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err) log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err), h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»." return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
} }
items, scoped := scopedToEntity(items, entityID) items, scoped := scopedToEntity(items, entityID)
if !scoped { if !scoped {
@@ -279,7 +280,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID) log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried, h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
map[string]any{"entity_id": entityID, "class": "unscoped_response"}) map[string]any{"entity_id": entityID, "class": "unscoped_response"})
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»." return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
} }
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{ h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
"entity_id": entityID, "count": len(items), "entity_id": entityID, "count": len(items),
@@ -303,9 +304,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
parts = append(parts, known) parts = append(parts, known)
} }
if len(parts) == 0 { if len(parts) == 0 {
return "по «" + displayName + "» ничего нет." return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
} }
return "по «" + displayName + "»: " + strings.Join(parts, "; ") return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
} }
// scopedToEntity drops items that carry an entity_id other than the one asked // scopedToEntity drops items that carry an entity_id other than the one asked
@@ -370,7 +371,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
if len(parts) == 0 { if len(parts) == 0 {
return "" return ""
} }
out := "я помню: " + strings.Join(parts, ", ") out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
if more { if more {
out += ", и это не всё" out += ", и это не всё"
} }
@@ -520,18 +521,18 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, 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(dec.Slots.Text)}))
if unauthorizedEcosystemError(err) { if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен." return phraser.A(phraser.EcoDenied, nil)
} }
// A genuine Nexus dependency failure, not "no such entity" — stop here // A genuine Nexus dependency failure, not "no such entity" — stop here
// and report degradation rather than silently falling through to the // and report degradation rather than silently falling through to the
// local command executor (ECOSYSTEM-SPEC.md: services degrade // local command executor (ECOSYSTEM-SPEC.md: services degrade
// independently, never a silent all-clear). // independently, never a silent all-clear).
return "экосистема недоступна, попробуй ещё раз." return phraser.A(phraser.EcoDown, nil)
} }
if len(ambiguous) > 0 { if len(ambiguous) > 0 {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
map[string]any{"candidates": len(ambiguous)}) map[string]any{"candidates": len(ambiguous)})
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?" return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
} }
if entityID == "" { if entityID == "" {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
@@ -550,9 +551,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered, h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
if unauthorizedEcosystemError(err) { if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен." return phraser.A(phraser.EcoDenied, nil)
} }
return "экосистема недоступна, попробуй ещё раз." return phraser.A(phraser.EcoDown, nil)
} }
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered, h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
map[string]any{"entity_id": entityID, "count": len(caps)}) map[string]any{"entity_id": entityID, "count": len(caps)})
@@ -584,7 +585,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
for _, m := range matches { for _, m := range matches {
names = append(names, m.Name) names = append(names, m.Name)
} }
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?" return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
} }
matched := matches[0] matched := matches[0]
@@ -602,7 +603,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.mu.Unlock() h.mu.Unlock()
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started, h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
map[string]any{"entity_id": entityID, "capability": matched.Name}) map[string]any{"entity_id": entityID, "capability": matched.Name})
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»." return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName})
} }
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName) return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
@@ -622,7 +623,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
mergeFields(traceErrorFields(err), map[string]any{ mergeFields(traceErrorFields(err), map[string]any{
"entity_id": entityID, "capability": capName, "causation_id": causationID, "entity_id": entityID, "capability": capName, "causation_id": causationID,
})) }))
return "не получилось выполнить команду для " + displayName + "." return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
} }
// One record per hop: the second write this used to make said the same // One record per hop: the second write this used to make said the same
// thing under a different key, in a different shape. // thing under a different key, in a different shape.
@@ -630,5 +631,5 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
"entity_id": entityID, "entity_name": displayName, "entity_id": entityID, "entity_name": displayName,
"capability": capName, "causation_id": causationID, "capability": capName, "causation_id": causationID,
}) })
return "команда выполнена для " + displayName + "." return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
} }
+1 -1
View File
@@ -94,7 +94,7 @@ func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
} }
on := buildFeedHandler(t, true) on := buildFeedHandler(t, true)
reply, ok = askFeeds(t, on, "что нового в лентах?") reply, ok = askFeeds(t, on, "что нового в лентах?")
if !ok || !strings.Contains(reply, "ничего нового") { if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok) t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
} }
} }
+9 -4
View File
@@ -4,10 +4,12 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"strconv"
"strings" "strings"
"time" "time"
"github.com/kami/maven/internal/config" "github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/smarthome" "github.com/kami/maven/internal/smarthome"
"github.com/kami/maven/internal/store" "github.com/kami/maven/internal/store"
) )
@@ -140,10 +142,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
ents, err := w.client.States(ctx) ents, err := w.client.States(ctx)
if err != nil { if err != nil {
log.Printf("smarthome: summary: %v", err) log.Printf("smarthome: summary: %v", err)
return "не смогла достучаться до дома.", true return phraser.A(phraser.HomeUnreachable, nil), true
} }
if len(ents) == 0 { if len(ents) == 0 {
return "дом ничего не отдаёт.", true return phraser.A(phraser.HomeEmpty, nil), true
} }
var on []string var on []string
var sensors []string var sensors []string
@@ -177,7 +179,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
} }
// Silent truncation on a status read is the same failure as the cap // Silent truncation on a status read is the same failure as the cap
// one layer up: she has to say the list is not the whole list. // one layer up: she has to say the list is not the whole list.
line := "включено: " + strings.Join(shown, ", ") line := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")})
if rest > 0 { if rest > 0 {
line += fmt.Sprintf(" и ещё %d", rest) line += fmt.Sprintf(" и ещё %d", rest)
} }
@@ -185,7 +187,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
case dark > 0 && len(sensors) == 0: case dark > 0 && len(sensors) == 0:
// Nothing is on and everything she can see is unreachable. "всё // Nothing is on and everything she can see is unreachable. "всё
// выключено" would be a claim about the house she cannot make. // выключено" would be a claim about the house she cannot make.
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true return phraser.A(phraser.HomeDark, map[string]string{
"count": strconv.Itoa(dark),
"word": hostWord(dark),
}), true
default: default:
parts = append(parts, "всё выключено") parts = append(parts, "всё выключено")
} }
+8 -3
View File
@@ -8,8 +8,7 @@ import (
"github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/phraser"
) )
// TestFallbackPersona scores every line in fallbacks_ru_v1.json, ack_ru_v1.json and // TestFallbackPersona scores every line in every hand-written line family on the persona checks the nudges already pass. These lines are
// query_ru_v1.json on the persona checks the nudges already pass. These lines are
// heard out loud and they live in a JSON file now, so a reworded variant that // heard out loud and they live in a JSON file now, so a reworded variant that
// says "рад" or "вы" would otherwise reach him with nothing in between. // says "рад" or "вы" would otherwise reach him with nothing in between.
// //
@@ -38,6 +37,11 @@ func TestFallbackPersona(t *testing.T) {
} }
variants := append(fb.Variants(), ack.Variants()...) variants := append(fb.Variants(), ack.Variants()...)
variants = append(variants, qry.Variants()...) variants = append(variants, qry.Variants()...)
act, err := phraser.LoadActs(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadActs: %v", err)
}
variants = append(variants, act.Variants()...)
if len(variants) == 0 { if len(variants) == 0 {
t.Fatal("no variants — the file loaded empty") t.Fatal("no variants — the file loaded empty")
} }
@@ -45,7 +49,8 @@ func TestFallbackPersona(t *testing.T) {
// The placeholders stand for his own words and carry no persona. // The placeholders stand for his own words and carry no persona.
body := v body := v
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}", for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
"{location}", "{temp}", "{condition}", "{tail}"} { "{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
"{entity}", "{count}", "{word}"} {
body = strings.ReplaceAll(body, ph, "вода") body = strings.ReplaceAll(body, ph, "вода")
} }
for _, r := range RunChecks(Case{}, body, "neutral") { for _, r := range RunChecks(Case{}, body, "neutral") {