diff --git a/cmd/mavend/ack.go b/cmd/mavend/ack.go
index 9fcd430..7339a40 100644
--- a/cmd/mavend/ack.go
+++ b/cmd/mavend/ack.go
@@ -16,6 +16,7 @@ import (
"log"
"github.com/kami/maven/internal/loop"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
@@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
}
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
- return "не получилось отметить.", true
+ return phraser.Ack(phraser.FailAck, nil), true
}
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
- return "отлично, отметила.", true
+ return phraser.Ack(phraser.AckNudge, nil), true
}
// ackFromFact — post-action hook, called once the turn's decision has been
diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go
index f6a9956..4145676 100644
--- a/cmd/mavend/actions.go
+++ b/cmd/mavend/actions.go
@@ -40,6 +40,7 @@ import (
"context"
"log"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -58,10 +59,14 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
// Conversational: build history from dialogue session (prior user turns)
// and let the LLM respond from general knowledge + context.
history := h.chatHistory()
+ // The phraser hands back its own fallback text alongside the error, so the
+ // turn survives a dead server and the failure still reaches the log.
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
if err != nil {
log.Printf("voice: chat: %v", err)
- return "поговорили."
+ }
+ if reply == "" {
+ return phraser.ChatFallback()
}
return reply
}
diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go
index 2b96855..b20cb21 100644
--- a/cmd/mavend/actions_act.go
+++ b/cmd/mavend/actions_act.go
@@ -6,6 +6,7 @@ import (
"log"
"github.com/kami/maven/internal/mcp"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/tool"
)
@@ -50,31 +51,37 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
// destructive: park it and ask. The next utterance answers.
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
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.ErrNeedsAuthedSurface):
+ // Irreversible (internal/tool/risk.go). A confirm turn would not
+ // help: everything that proposed this act — the STT, the router,
+ // the fuzzy allowlist match — is a guess, and a spoken "да" checks
+ // none of it. She names the gap instead.
+ return phraser.A(phraser.ActNeedsAuthedSurface, nil)
case errors.Is(err, tool.ErrNotEnabled):
return h.proposeGap(ctx, dec)
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
// for it (the ErrNotEnabled path) would be answering the wrong
// question.
- return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
+ return phraser.A(phraser.ActServerDown, nil)
case errors.Is(err, mcp.ErrToolGone):
- return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
+ return phraser.A(phraser.ActWithdrawn, nil)
case errors.Is(err, mcp.ErrNeedsArgs):
// An MCP tool that wants named arguments a spoken verb cannot
// supply. Guessing them would be a wrong act, so she says so
// instead — the tool is still runnable from the authed surface,
// where a human types them.
- return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
+ return phraser.A(phraser.ActNeedsArgs, nil)
}
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
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 != "" {
- return "готово: " + firstLine(out)
+ return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
}
- return "готово."
+ return phraser.A(phraser.ActDone, nil)
}
diff --git a/cmd/mavend/actions_act_risk_test.go b/cmd/mavend/actions_act_risk_test.go
new file mode 100644
index 0000000..31447f1
--- /dev/null
+++ b/cmd/mavend/actions_act_risk_test.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/kami/maven/internal/router"
+)
+
+// The act path speaks each tier (Vikunja #449): a safe row runs, a destructive
+// one costs a confirm turn, an irreversible one is refused with the reason.
+func TestActPathSpeaksTheTiers(t *testing.T) {
+ h, st, _ := newClarifyHandler(t)
+ ctx := context.Background()
+ now := h.now()
+ for _, tc := range []struct {
+ name string
+ cmd []string
+ destructive bool
+ }{
+ {"status", []string{"true"}, false},
+ {"restart", []string{"true"}, true},
+ {"wipe", []string{"rm", "-rf"}, true},
+ } {
+ if _, err := st.ProposeTool(ctx, tc.name, "test", "homelab", now); err != nil {
+ t.Fatalf("propose %s: %v", tc.name, err)
+ }
+ if err := st.EnableTool(ctx, tc.name, tc.cmd, tc.destructive, "homelab", now); err != nil {
+ t.Fatalf("enable %s: %v", tc.name, err)
+ }
+ }
+
+ act := func(fn string) string {
+ return h.actionAct(ctx, router.Decision{
+ Intent: router.IntentAct,
+ Utterance: fn,
+ Slots: router.Slots{Fn: fn, HasFn: true},
+ })
+ }
+
+ if reply := act("status"); !strings.HasPrefix(reply, "готово") {
+ t.Errorf("safe act replied %q; want it to have run", reply)
+ }
+ // PR 112's review cut «скажи «да» или «нет».» — he knows how to answer a
+ // yes/no question — so the confirm turn is recognised by the question.
+ if reply := act("restart"); !strings.Contains(reply, "да или нет") {
+ t.Errorf("destructive act replied %q; want a confirm turn", reply)
+ }
+ // Clear the confirm the destructive act parked, so what is pending after
+ // the irreversible one is only what the irreversible one parked.
+ h.mu.Lock()
+ h.pending = nil
+ h.mu.Unlock()
+
+ reply := act("wipe")
+ if strings.Contains(reply, "да или нет") {
+ t.Fatalf("irreversible act asked for a confirm: %q", reply)
+ }
+ if !strings.Contains(reply, "не вернуть") {
+ t.Errorf("irreversible act replied %q; want it to name the reason", reply)
+ }
+ // Nothing was parked, so a later "да" cannot pick it up.
+ h.mu.Lock()
+ pending := h.pending
+ h.mu.Unlock()
+ if pending != nil {
+ t.Errorf("an irreversible act parked %+v", pending)
+ }
+ // And it is still an enabled row — refusing to run it from voice is not
+ // the same as taking it off the allowlist.
+ if got, err := st.LookupTool(ctx, "wipe"); err != nil || got.Status != "enabled" {
+ t.Errorf("wipe is %+v, %v; want it still enabled", got, err)
+ }
+}
diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go
index 5645282..d902642 100644
--- a/cmd/mavend/actions_fact.go
+++ b/cmd/mavend/actions_fact.go
@@ -6,6 +6,7 @@ import (
"strconv"
"github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
@@ -14,7 +15,7 @@ import (
// it for recall, and let pattern detection propose a routine.
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
if !dec.Slots.HasKey {
- return "не разобрала, что записать — попробуй иначе."
+ return phraser.Ack(phraser.FailFactUnparsed, nil)
}
// A question is never a fact about him (#470). "какая последняя версия
// языка Go?" used to land here, and the value stored was whatever the
@@ -62,7 +63,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
factID, err := h.api.WriteFact(ctx, req)
if err != nil {
log.Printf("voice: write fact: %v", err)
- return "не получилось сохранить факт."
+ return phraser.Ack(phraser.FailFact, nil)
}
// Index the fact in long-term memory (best-effort, must not fail the fact
// write). Facts aren't in the notes table, so this is the only recall path
diff --git a/cmd/mavend/actions_list.go b/cmd/mavend/actions_list.go
new file mode 100644
index 0000000..e7e7d61
--- /dev/null
+++ b/cmd/mavend/actions_list.go
@@ -0,0 +1,143 @@
+package main
+
+import (
+ "context"
+ "log"
+ "strings"
+
+ "github.com/kami/maven/internal/router"
+ "github.com/kami/maven/internal/store"
+)
+
+// Standing lists on the voice path (Vikunja #453).
+//
+// Three halves, mirroring what task capture already does: an add that runs at
+// the top of actionNote, a read-back query source, and a crossing-off that runs
+// on the same note path because "всё купил" is note-shaped.
+//
+// These read h.dataStore rather than the CoreAPI. A list is local to the core
+// and nothing outside it writes one: the web UI has no list page, no reach
+// files groceries, and the digestion worker does not read the table. When
+// something outside mavend needs to add to a list, the ipc seam is what it
+// grows through — the intake rules that CaptureTaskReq documents are about
+// shared intake, and there is none here yet.
+//
+// Nothing here speaks unprompted. A list is answered when asked about.
+
+// captureListFromNote claims the turn when the utterance adds to, clears, or
+// crosses one item off a list. ("", false) hands the turn back to the note path.
+func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
+ if h.dataStore == nil {
+ return "", false
+ }
+ // Clearing is read before removing on purpose: "всё купил" and "купил
+ // молоко" start with the same word, and only the second one names an item.
+ if list, ok := router.ParseListClear(dec.Utterance); ok {
+ n, err := h.dataStore.ClearList(ctx, list, h.now())
+ if err != nil {
+ log.Printf("voice: clear list: %v", err)
+ return "не получилось обновить список.", true
+ }
+ if n == 0 {
+ return "в списке и так ничего не было.", true
+ }
+ return "вычеркнула всё, список пустой.", true
+ }
+ if cap, ok := router.ParseListRemove(dec.Utterance); ok {
+ if reply, ok := h.removeListItem(ctx, cap); ok {
+ return reply, true
+ }
+ // Nothing on the list by that name. "купил новый ноутбук" is a note and
+ // must stay one, so the turn goes back rather than claiming a removal
+ // that removed nothing.
+ return "", false
+ }
+ cap, ok := router.ParseListCapture(dec.Utterance)
+ if !ok {
+ return "", false
+ }
+ res, err := h.dataStore.AddListItem(ctx, store.ListItem{
+ List: cap.List,
+ Item: cap.Item,
+ Source: "tap:voice",
+ CreatedTs: h.now(),
+ })
+ if err != nil {
+ log.Printf("voice: add list item: %v", err)
+ return "не получилось добавить в список.", true
+ }
+ if !res.Created {
+ return cap.Item + " уже в списке.", true
+ }
+ return "добавила в список: " + cap.Item + ".", true
+}
+
+// removeListItem crosses one named item off. It reports false when the list
+// holds nothing by that name, which is what keeps the marker words from
+// swallowing ordinary notes.
+func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
+ items, err := h.dataStore.ListItems(ctx, cap.List, "")
+ if err != nil {
+ log.Printf("voice: list items: %v", err)
+ return "", false
+ }
+ want := store.NormalizeTaskText(cap.Item)
+ for _, li := range items {
+ if store.NormalizeTaskText(li.Item) != want {
+ continue
+ }
+ if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
+ log.Printf("voice: cross off list item: %v", err)
+ return "не получилось обновить список.", true
+ }
+ return "вычеркнула: " + li.Item + ".", true
+ }
+ return "", false
+}
+
+// queryList — "что в списке покупок?", "что мне купить?".
+//
+// A query source, so it sits in querySources and either claims the turn or
+// passes it on. It is before the recall sources for the reason every specific
+// source is: the notes pass would otherwise answer a list question with
+// whatever note is nearest.
+func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
+ list, ok := router.ParseListQuery(t.dec.Utterance)
+ if !ok || h.dataStore == nil {
+ return "", false
+ }
+ items, err := h.dataStore.ListItems(ctx, list, "")
+ if err != nil {
+ log.Printf("voice: list items: %v", err)
+ return "не получилось посмотреть список.", true
+ }
+ return formatListRU(list, items), true
+}
+
+// formatListRU reads a list aloud. One sentence, comma-separated, because a
+// shopping list is heard in a shop and a numbered recital is unusable there.
+func formatListRU(list string, items []store.ListItem) string {
+ name := "списке " + listGenitive(list)
+ if len(items) == 0 {
+ return "в " + name + " пусто."
+ }
+ names := make([]string, 0, len(items))
+ for _, li := range items {
+ names = append(names, li.Item)
+ }
+ return "в " + name + ": " + strings.Join(names, ", ") + "."
+}
+
+// listGenitive puts a list tag into the case "список <…>" needs. Russian
+// declines the noun and she must not say "в списке покупки".
+func listGenitive(list string) string {
+ switch list {
+ case "покупки":
+ return "покупок"
+ case "аптека":
+ return "аптеки"
+ case "хозяйство":
+ return "хозяйства"
+ }
+ return list
+}
diff --git a/cmd/mavend/actions_list_test.go b/cmd/mavend/actions_list_test.go
new file mode 100644
index 0000000..6e8c288
--- /dev/null
+++ b/cmd/mavend/actions_list_test.go
@@ -0,0 +1,184 @@
+package main
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/kami/maven/internal/router"
+ "github.com/kami/maven/internal/store"
+)
+
+func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
+
+func listHandler(t *testing.T) *reactiveHandler {
+ t.Helper()
+ return &reactiveHandler{dataStore: newTestStore(t), now: listNow}
+}
+
+func askList(t *testing.T, h *reactiveHandler, utterance string) (string, bool) {
+ t.Helper()
+ return h.captureListFromNote(context.Background(), router.Decision{
+ Intent: router.IntentNote, Utterance: utterance,
+ })
+}
+
+func TestListCaptureAddsAndReadsBack(t *testing.T) {
+ h := listHandler(t)
+ for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} {
+ if reply, ok := askList(t, h, u); !ok {
+ t.Fatalf("%q was not claimed (reply %q)", u, reply)
+ }
+ }
+ if reply, ok := askList(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") {
+ t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok)
+ }
+ answer, ok := h.queryList(context.Background(), &queryTurn{
+ dec: router.Decision{Intent: router.IntentQuery, Utterance: "что в списке покупок?"},
+ })
+ if !ok {
+ t.Fatal("the list question was not claimed")
+ }
+ if !strings.Contains(answer, "молоко") || !strings.Contains(answer, "хлеб") {
+ t.Errorf("answer %q; want both items", answer)
+ }
+ if strings.Contains(answer, "списке покупки") {
+ t.Errorf("answer %q declines the list name wrong", answer)
+ }
+}
+
+// An utterance with no list marker is a note and must stay one, whichever half
+// of the parser it brushes against.
+func TestListCapturePassesOrdinaryNotes(t *testing.T) {
+ h := listHandler(t)
+ for _, u := range []string{
+ "молоко закончилось",
+ "надо бы съездить в магазин",
+ "купил новый ноутбук",
+ "добавь в список покупок",
+ } {
+ if reply, ok := askList(t, h, u); ok {
+ t.Errorf("%q was claimed as a list turn: %q", u, reply)
+ }
+ }
+}
+
+func TestListCrossOffOneItemAndThenAll(t *testing.T) {
+ h := listHandler(t)
+ for _, u := range []string{
+ "добавь в список покупок молоко",
+ "добавь в список покупок хлеб",
+ "добавь в список аптеки бинт",
+ } {
+ if _, ok := askList(t, h, u); !ok {
+ t.Fatalf("%q was not claimed", u)
+ }
+ }
+ reply, ok := askList(t, h, "вычеркни молоко")
+ if !ok || !strings.Contains(reply, "молоко") {
+ t.Fatalf("cross off replied %q, %v", reply, ok)
+ }
+ open, err := h.dataStore.ListItems(context.Background(), "покупки", "")
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(open) != 1 || open[0].Item != "хлеб" {
+ t.Fatalf("open list %+v; want only хлеб", open)
+ }
+ if reply, ok := askList(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") {
+ t.Errorf("clear replied %q, %v", reply, ok)
+ }
+ open, err = h.dataStore.ListItems(context.Background(), "покупки", "")
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(open) != 0 {
+ t.Errorf("%d items still open after всё купил", len(open))
+ }
+ // The other list is untouched, and it is read back on its own.
+ answer, ok := h.queryList(context.Background(), &queryTurn{
+ dec: router.Decision{Intent: router.IntentQuery, Utterance: "покажи список аптеки"},
+ })
+ if !ok || !strings.Contains(answer, "бинт") {
+ t.Errorf("аптека answer %q, %v; want бинт", answer, ok)
+ }
+}
+
+func TestQueryListSaysWhenItIsEmpty(t *testing.T) {
+ h := listHandler(t)
+ answer, ok := h.queryList(context.Background(), &queryTurn{
+ dec: router.Decision{Intent: router.IntentQuery, Utterance: "что мне купить?"},
+ })
+ if !ok {
+ t.Fatal("the list question was not claimed")
+ }
+ if !strings.Contains(answer, "пусто") {
+ t.Errorf("empty answer %q; want it to say so", answer)
+ }
+ if _, ok := h.queryList(context.Background(), &queryTurn{
+ dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
+ }); ok {
+ t.Error("the list source claimed a task question")
+ }
+}
+
+// Stage 0 answers a list turn without the model: the grammars route it, and the
+// action handlers re-parse what the grammar matched.
+func TestListGrammarsRouteWithoutTheModel(t *testing.T) {
+ cases := []struct {
+ utterance string
+ want router.Intent
+ }{
+ {"добавь в список покупок молоко", router.IntentNote},
+ {"что в списке покупок?", router.IntentQuery},
+ {"всё купил", router.IntentNote},
+ }
+ for _, c := range cases {
+ var got router.Intent
+ claimed := false
+ for _, g := range router.ListGrammars() {
+ m := g.Pattern.FindStringSubmatch(c.utterance)
+ if m == nil {
+ continue
+ }
+ if dec, ok := g.Build(m); ok {
+ got, claimed = dec.Intent, true
+ break
+ }
+ }
+ if !claimed {
+ t.Errorf("no list grammar claimed %q", c.utterance)
+ continue
+ }
+ if got != c.want {
+ t.Errorf("%q routed to %v; want %v", c.utterance, got, c.want)
+ }
+ }
+ for _, g := range router.ListGrammars() {
+ m := g.Pattern.FindStringSubmatch("напомни купить молоко завтра")
+ if m == nil {
+ continue
+ }
+ if _, ok := g.Build(m); ok {
+ t.Errorf("grammar %s claimed a reminder", g.Name)
+ }
+ }
+}
+
+func TestListStoreSourceIsVoice(t *testing.T) {
+ h := listHandler(t)
+ if _, ok := askList(t, h, "добавь в список покупок молоко"); !ok {
+ t.Fatal("not claimed")
+ }
+ items, err := h.dataStore.ListItems(context.Background(), "покупки", "")
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(items) != 1 || items[0].Source != "tap:voice" {
+ t.Errorf("stored %+v; want one row from tap:voice", items)
+ }
+ if items[0].Status != store.ListItemOpen {
+ t.Errorf("status %q; want open", items[0].Status)
+ }
+}
diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go
index c7c20a5..5d90b12 100644
--- a/cmd/mavend/actions_note.go
+++ b/cmd/mavend/actions_note.go
@@ -5,6 +5,7 @@ import (
"log"
"strconv"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -17,19 +18,25 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
return reply
}
+ // A standing list is neither work nor recall (Vikunja #453). Checked here
+ // for the same reason and at the same cost: before the embedding is paid
+ // for, and it passes the turn straight back when no marker matches.
+ if reply, ok := h.captureListFromNote(ctx, dec); ok {
+ return reply
+ }
// embed the note text with the same model the classifier uses, persist
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
// facts — no predicate reads it (spec's two-memory split).
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
if err != nil {
log.Printf("voice: embed note: %v", err)
- return "не получилось сохранить заметку."
+ return phraser.Ack(phraser.FailNote, nil)
}
noteTs := h.now()
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
if err != nil {
log.Printf("voice: write note: %v", err)
- return "не получилось сохранить заметку."
+ return phraser.Ack(phraser.FailNote, nil)
}
// Insert into long-term memory (best-effort, must not fail the note write).
// text/ts in the meta make a Search hit self-describing (see bestRecall).
diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go
index 9629303..236f3fb 100644
--- a/cmd/mavend/actions_query.go
+++ b/cmd/mavend/actions_query.go
@@ -85,6 +85,11 @@ var querySources = []querySource{
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
+ // Next to "tasks" and for the same reason: "что мне купить?" is a question
+ // about the shopping list, and the recall pass would otherwise answer it
+ // from an old note about the shop. Its matcher needs an explicit list
+ // marker, so "надо бы съездить в магазин" is untouched.
+ {name: "list", answer: (*reactiveHandler).queryList},
{name: "money", answer: (*reactiveHandler).queryMoney},
// Before the recall sources and before general knowledge: "что нового?" is
// a question about the feeds she reads, and general knowledge would answer
@@ -147,9 +152,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
// The previous question cannot be re-asked for another day. Saying so
// beats "не знаю", which reads as "no data for tomorrow" when the
// truth is that she never looked.
- return "про другой день так не отвечу — спроси целиком."
+ return phraser.Q(phraser.QueryOtherDay, nil)
}
- return "не знаю."
+ return phraser.Q(phraser.QueryUnknown, nil)
}
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
@@ -167,11 +172,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
if dec.Slots.HasTime {
// The query asks about timing — the fact's own timestamp is the
// answer it's looking for. Format as a natural reply.
- return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
+ return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
}
// General fact reference: describe what we know.
if dec.Utterance == "" {
- return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
+ return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
}
// The utterance still carries the question; fall through to normal RAG
// with the resolved key in context.
@@ -197,7 +202,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
plan, err := h.api.DayPlan(ctx)
if err != nil {
log.Printf("voice: day plan: %v", err)
- return "не получилось собрать план.", true
+ return phraser.Q(phraser.QueryFailPlan, nil), true
}
if !router.IsRestOfDayQuery(t.dec.Utterance) {
return plan.Spoken, true
@@ -242,7 +247,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
if err != nil {
log.Printf("voice: habits: recent facts: %v", err)
- return "не получилось посмотреть записи.", true
+ return phraser.Q(phraser.QueryFailNotes, nil), true
}
obs := make([]memory.Observation, 0, len(facts))
for _, f := range facts {
@@ -281,7 +286,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
// Claim the turn rather than fall through: "не читаю ленты" is true, and
// letting general knowledge answer "что нового?" would be an invented
// news bulletin.
- return "я пока не читаю ленты — они не настроены.", true
+ return phraser.Q(phraser.QueryFeedsOff, nil), true
}
// By source, not the last 200 notes of any kind: a busy day of voice notes
// used to push the newest headline out of the window, and she answered "в
@@ -289,7 +294,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
if err != nil {
log.Printf("voice: feeds: recent notes: %v", err)
- return "не получилось посмотреть ленты.", true
+ return phraser.Q(phraser.QueryFailFeeds, nil), true
}
var picked []string
for _, n := range notes {
@@ -306,11 +311,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
}
if len(picked) == 0 {
if q.Category != "" {
- return "по этой теме в лентах пока ничего.", true
+ return phraser.Q(phraser.QueryFeedsTopic, nil), true
}
- return "в лентах пока ничего нового.", true
+ return phraser.Q(phraser.QueryFeedsEmpty, nil), true
}
- return "вот что нового: " + strings.Join(picked, "; "), true
+ return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
}
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
@@ -324,7 +329,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
if err != nil {
log.Printf("voice: calendar events: %v", err)
- return "не получилось проверить календарь.", true
+ return phraser.Q(phraser.QueryFailCalendar, nil), true
}
// Provenance travels with each event. A work meeting relayed off a phone
// notification (source ambient:notif, #126) is stored below full confidence
@@ -367,9 +372,12 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
return "", false
}
if h.netscan == nil {
- // Fall through, same as queryHome: an unconfigured scanner must not
- // swallow "сколько устройств в сети?" before recall has looked.
- return "", false
+ // The recogniser already matched, so this is a question about HIS LAN
+ // and there is no scanner to answer it. Falling through sent it to the
+ // search leg, which answered with a paragraph about routers in general
+ // and put his network question on an upstream engine (Vikunja #479).
+ // A missing capability names itself.
+ return phraser.Q(phraser.QueryNetOff, nil), true
}
return h.netscan.scanSummary(ctx)
}
@@ -382,13 +390,13 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
if loc == "" {
// He named no city and voice.weather.default_location is unset. Saying
// so is the only honest answer; picking a city would be inventing one.
- return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
+ return phraser.Q(phraser.QueryWeatherWhere, nil), true
}
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
if errors.Is(err, weather.ErrNotConfigured) {
- return "погода не настроена.", true
+ return phraser.Q(phraser.QueryWeatherOff, nil), true
}
if errors.Is(err, weather.ErrLocationUnknown) {
// He named a place and the geocoder does not have it. Saying so beats
@@ -397,9 +405,14 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
}
if err != nil {
log.Printf("voice: weather: %v", err)
- return "не получилось узнать погоду.", true
+ return phraser.Q(phraser.QueryFailWeather, nil), true
}
- return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
+ return phraser.Q(phraser.QueryWeatherNow, map[string]string{
+ "location": w.Location,
+ "temp": fmt.Sprintf("%.0f", w.Temperature),
+ "word": phraser.Degrees(w.Temperature),
+ "condition": w.Condition,
+ }), true
}
// queryEmbed isn't an answer source — it's the shared cost the two recall
@@ -409,7 +422,7 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
if err != nil {
log.Printf("voice: embed query: %v", err)
- return "не получилось найти ответ.", true
+ return phraser.Q(phraser.QueryFailAnswer, nil), true
}
t.vec = vec
return "", false
@@ -450,7 +463,13 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
// A note is phrased in Maven's voice; a fact is read back as it was
// stored.
if hit.Meta["type"] == "note" {
- if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
+ reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
+ switch {
+ case perr != nil:
+ // Reading the note back verbatim beats the phraser's own fallback,
+ // which only wraps the same text in "вот что я нашла:".
+ log.Printf("voice: recall phrase: %v", perr)
+ case reply != "":
return reply, true
}
}
@@ -472,7 +491,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
if err != nil {
log.Printf("voice: query notes: %v", err)
- return "не получилось найти ответ.", true
+ return phraser.Q(phraser.QueryFailAnswer, nil), true
}
t.notes = notes
noteScores := make([]float64, len(notes))
@@ -497,7 +516,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
log.Printf("voice: phrase query: %v", err)
}
if reply == "" {
- reply = "вот что я нашла: " + texts[0]
+ reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
}
return reply, true
}
@@ -519,25 +538,24 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
return "", false
}
if h.crawler == nil {
- // Fall through. Reading pages is off unless configured, and on a daemon
- // where it was never turned on the older behaviour is right: the model
- // answers the question as if the URL had not been said. Announcing a
- // configuration status is for a capability that exists and failed, not
- // for one he never asked for.
- return "", false
+ // He named a URL, so the question is about that page and nothing else
+ // can answer it. The older comment here argued for falling through and
+ // letting the model answer as if the URL had not been said; that is a
+ // guess dressed as an answer (Vikunja #479).
+ return phraser.Q(phraser.QueryPageOff, nil), true
}
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
page, err := h.crawler.Page(ctxFetch, link)
if err != nil {
if errors.Is(err, crawl.ErrRobots) {
- return "эта страница закрыта для чтения — robots.txt не разрешает.", true
+ return phraser.Q(phraser.QueryPageBlocked, nil), true
}
log.Printf("voice: web: %v", err)
- return "не получилось прочитать страницу.", true
+ return phraser.Q(phraser.QueryFailPage, nil), true
}
if page.Text == "" {
- return "страница открылась, но читать там нечего.", true
+ return phraser.Q(phraser.QueryPageEmpty, nil), true
}
// The page is handed to the phraser the same way a note is: as context for
// the question he actually asked. She answers the question, she does not
@@ -547,7 +565,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
if reply == "" {
// No phraser (or it failed): read back the top of the page rather than
// pretend the fetch did not happen.
- return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
+ return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
}
return reply, true
}
@@ -613,7 +631,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
if reply == "" {
// No phraser, or it failed. Read back the best evidence rather than
// pretend the search did not happen.
- return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
+ return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
}
return reply, true
}
@@ -694,7 +712,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
if reply == "" {
// No phraser, or it failed. Read back the best hit rather than pretend
// the search did not happen.
- return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
+ return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
}
return reply, true
}
@@ -724,7 +742,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri
return "", false
}
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
- return "не знаю — не нашла у тебя такой записи.", true
+ return phraser.Q(phraser.QueryPersonalNone, nil), true
}
// personalMarkers — first-person POSSESSION, not first person generally.
@@ -792,15 +810,15 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
if h.phraser == nil {
// No model of any size. That is not the workstation being asleep, so it
// is not that gap: it is simply not knowing.
- return "не знаю.", true
+ return phraser.Q(phraser.QueryUnknown, nil), true
}
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
if errors.Is(err, phraser.ErrNoWorldModel) {
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
- return worldGap, true
+ return worldGap(), true
}
if err != nil || reply == "" {
- return "не знаю.", true
+ return phraser.Q(phraser.QueryUnknown, nil), true
}
return reply, true
}
diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go
index ce2a632..ab9a544 100644
--- a/cmd/mavend/actions_reminder.go
+++ b/cmd/mavend/actions_reminder.go
@@ -4,6 +4,7 @@ import (
"context"
"log"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -21,13 +22,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
}
}
if !dec.Slots.HasTime {
- return "не получилось разобрать время напоминания."
+ return phraser.Ack(phraser.FailReminderTime, nil)
}
}
payload := `{"text":` + jsonString(dec.Utterance) + `}`
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
log.Printf("voice: create reminder: %v", err)
- return "не получилось поставить напоминание."
+ return phraser.Ack(phraser.FailReminder, nil)
}
return ""
}
diff --git a/cmd/mavend/actions_task.go b/cmd/mavend/actions_task.go
index 99187f0..a2dded3 100644
--- a/cmd/mavend/actions_task.go
+++ b/cmd/mavend/actions_task.go
@@ -5,6 +5,7 @@ import (
"log"
"github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tasks"
@@ -40,18 +41,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
})
if err != nil {
log.Printf("voice: capture task: %v", err)
- return "не получилось записать задачу.", true
+ return phraser.Ack(phraser.FailTask, nil), true
}
if resp.Promoted {
// It was a candidate Maven derived from something she read, and he has
// now said it himself. Saying "уже в списке" here would be answering a
// confirmation with a shrug.
- return "поняла, беру в работу: " + cap.Text, true
+ return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
}
if !resp.Created {
- return "это уже в списке.", true
+ return phraser.Ack(phraser.AckTaskDuplicate, nil), true
}
- return "записала: " + cap.Text, true
+ return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
}
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go
index 628b30d..5f38bba 100644
--- a/cmd/mavend/actions_task_test.go
+++ b/cmd/mavend/actions_task_test.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
if !ok {
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
}
- if !strings.Contains(reply, "не получилось") {
+ if !phraser.IsAck(phraser.FailTask, nil, reply) {
t.Errorf("reply = %q, want an honest failure", reply)
}
}
@@ -137,7 +138,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
if !(openIdx < candIdx) {
t.Errorf("reply = %q, want confirmed work before candidates", reply)
}
- if !strings.Contains(reply, "не подтвердил") {
+ if !strings.Contains(reply, "не подтверждал") {
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
}
}
diff --git a/cmd/mavend/crawls_test.go b/cmd/mavend/crawls_test.go
index 5055880..937d150 100644
--- a/cmd/mavend/crawls_test.go
+++ b/cmd/mavend/crawls_test.go
@@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
}
}
-// A daemon where page reading was never turned on — the default — answers the
-// question the way it did before the capability existed. Claiming the turn to
-// report a configuration status is for something that exists and failed.
-func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
+// A daemon where page reading was never turned on names the gap. He asked
+// about one page, nothing else on the box can read it, and the old behaviour
+// here was to answer as though the URL had not been said (Vikunja #479).
+func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
h := buildWebHandler(nil)
- if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
- t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
+ reply, ok := askWeb(h, "посмотри https://example.org/page")
+ if !ok {
+ t.Fatal("an unconfigured crawler let the page question fall through")
+ }
+ if !phraser.IsQ(phraser.QueryPageOff, nil, reply) {
+ t.Errorf("got %q, want the gap named", reply)
}
}
@@ -156,21 +160,22 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
if !ok {
t.Fatal("the web source did not claim a question with a URL")
}
- if !strings.Contains(reply, "не получилось") {
+ if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
t.Errorf("reply = %q, want the read-failed answer", reply)
}
}
-// robots.txt is honoured on the answer path too, and she says so instead of
-// reporting a generic failure.
+// robots.txt is honoured on the answer path too, and she says the page is
+// closed instead of reporting a generic failure.
func TestQueryWebObeysRobots(t *testing.T) {
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
reply, ok := askWeb(h, "посмотри https://example.org/private")
if !ok {
t.Fatal("the web source did not claim a question with a URL")
}
- if !strings.Contains(reply, "robots.txt") {
- t.Errorf("reply = %q, want the robots answer", reply)
+ // She names the cause without reading a filename out loud.
+ if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") {
+ t.Errorf("reply = %q, want the closed-page answer with no filename", reply)
}
}
diff --git a/cmd/mavend/dayplan_test.go b/cmd/mavend/dayplan_test.go
index 0fd511c..52a0aa9 100644
--- a/cmd/mavend/dayplan_test.go
+++ b/cmd/mavend/dayplan_test.go
@@ -10,6 +10,7 @@ import (
"github.com/kami/maven/internal/calendar"
"github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
@@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
if !ok {
t.Fatal("a failed plan read must still answer, not fall through to RAG")
}
- if reply != "не получилось собрать план." {
- t.Errorf("reply = %q", reply)
+ if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
+ t.Errorf("reply = %q, want the honest failure", reply)
}
}
diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go
index 900ae06..700867d 100644
--- a/cmd/mavend/ecosystem_acts.go
+++ b/cmd/mavend/ecosystem_acts.go
@@ -9,10 +9,25 @@ import (
"time"
hexisclient "github.com/kami/hexis/pkg/client"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
+ "github.com/kami/maven/internal/tool"
)
+// The three services, spelled the way she says them out loud. A service that is
+// down or refusing has to be named: they degrade independently, so "не
+// отвечает" on its own tells him nothing he can act on, and each call site
+// already knows which one it was talking to — it records the same name in the
+// trace (Vikunja #521).
+const (
+ serviceNexus = "Nexus"
+ serviceHexis = "Hexis"
+)
+
+// serviceVars — the one-key map the eco_down and eco_denied lines take.
+func serviceVars(name string) map[string]string { return map[string]string{"name": name} }
+
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
// rather than a map[string]func because each arm carries its own state: the
// verb aliases it answers to, the trace name it records, and its own reply
@@ -144,10 +159,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
log.Printf("ecosystem: praxis attention: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
started, traceErrorFields(err))
- return "не могу сейчас узнать, что требует внимания."
+ return phraser.A(phraser.AttentionFail, nil)
}
if len(items) == 0 {
- return "ничего не требует внимания."
+ return phraser.A(phraser.AttentionNone, nil)
}
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
var parts []string
@@ -157,6 +172,11 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
importance, _ := item["importance"].(float64)
rule, _ := item["rule"].(string)
s := title
+ if s == "" {
+ // An item Praxis returned without a title is not an item she can
+ // read out. Counting it would put an empty slot in the list.
+ continue
+ }
if importance > 0 {
s += fmt.Sprintf(" (важность %d", int(importance))
if rule != "" {
@@ -175,7 +195,13 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
}
}
}
- return "требует внимания: " + strings.Join(parts, "; ")
+ if len(parts) == 0 {
+ // Praxis returned items and not one of them could be said. "ничего не
+ // требует внимания" is the honest answer; the list line would render as
+ // its own label and a colon (Vikunja #521).
+ return phraser.A(phraser.AttentionNone, nil)
+ }
+ return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
}
// listChangesCapability reads the recent-changes feed.
@@ -192,19 +218,29 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
log.Printf("ecosystem: praxis changes: %v", err)
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
started, traceErrorFields(err))
- return "не могу сейчас узнать об изменениях."
+ return phraser.A(phraser.ChangesFail, nil)
}
if len(changes) == 0 {
- return "нет изменений."
+ return phraser.A(phraser.ChangesNone, nil)
}
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
var parts []string
for _, c := range changes {
title, _ := c["title"].(string)
+ if title == "" {
+ continue
+ }
typ, _ := c["change_type"].(string)
+ if typ == "" {
+ parts = append(parts, title)
+ continue
+ }
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
}
- return "изменения: " + strings.Join(parts, "; ")
+ if len(parts) == 0 {
+ return phraser.A(phraser.ChangesNone, nil)
+ }
+ return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
}
// entityAttentionCapability answers "what's going on with X" by resolving X to
@@ -230,12 +266,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
subject = dec.Slots.Text
}
if subject == "" {
- return "про что именно спросить?"
+ return phraser.A(phraser.EcoAboutWhat, nil)
}
if h.ecosystem == nil || h.ecosystem.nexus == nil {
// Without Nexus there is no canonical ref to scope by. Say so rather
// than quietly answering about something else.
- return "не могу связать это с сущностью — Nexus не настроен."
+ return phraser.A(phraser.EcoNoNexus, nil)
}
started := h.now()
@@ -248,15 +284,15 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
if unauthorizedEcosystemError(err) {
- return "экосистема отклоняет доступ, проверь токен."
+ return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
}
- return "экосистема недоступна, попробуй ещё раз."
+ return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
}
if len(ambiguous) > 0 {
- return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
+ return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
}
if entityID == "" {
- return "не знаю такой сущности."
+ return phraser.A(phraser.EcoUnknownEntity, nil)
}
if displayName == "" {
displayName = subject
@@ -268,7 +304,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
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)
if !scoped {
@@ -279,7 +315,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
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{
"entity_id": entityID, "count": len(items),
@@ -303,9 +339,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
parts = append(parts, known)
}
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
@@ -370,7 +406,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
if len(parts) == 0 {
return ""
}
- out := "я помню: " + strings.Join(parts, ", ")
+ out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
if more {
out += ", и это не всё"
}
@@ -497,6 +533,64 @@ func traceErrorFields(err error) map[string]any {
return fields
}
+// entityResolution — what asking Nexus about a turn's candidate names came to.
+// One shape rather than five return values, because the caller needs the
+// reference that answered as well as the answer: it goes in the trace.
+type entityResolution struct {
+ subject string // the reference Nexus answered about
+ entityID string // set when exactly one name resolved
+ displayName string // that entity's name as Nexus spells it
+ ambiguous []string // candidate display names to ask between
+ err error // a dependency failure, not a miss
+}
+
+// resolveEntityCandidates asks Nexus about each name the turn offered and
+// reports what it knows, stopping early where the answer is already decided.
+//
+// The rules, in the order they apply:
+//
+// - A dependency failure ends it. Nexus being down is not "no such entity",
+// and asking about the next name would report the outage as a miss.
+// - Nexus calling one name ambiguous ends it. It has the candidates and it is
+// telling us to ask.
+// - Two names resolving to different entities is a clarify too, this time ours:
+// "перезапусти nginx на muzick-indexer" names both a service and its host,
+// and picking either would be inventing an intent he did not state.
+// - Nothing resolving returns the first name as the subject, so the trace says
+// what was actually looked for.
+func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []string) entityResolution {
+ var out entityResolution
+ for _, ref := range refs {
+ entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, ref, nil)
+ if err != nil {
+ return entityResolution{subject: ref, err: err}
+ }
+ if len(ambiguous) > 0 {
+ return entityResolution{subject: ref, ambiguous: ambiguous}
+ }
+ if entityID == "" {
+ continue
+ }
+ if out.entityID == "" {
+ out = entityResolution{subject: ref, entityID: entityID, displayName: displayName}
+ continue
+ }
+ if entityID == out.entityID {
+ continue
+ }
+ // Both are real and they are not the same thing. Hand back the names
+ // Nexus spells, not the words he happened to say.
+ return entityResolution{
+ subject: out.subject,
+ ambiguous: []string{out.displayName, displayName},
+ }
+ }
+ if out.entityID == "" && len(refs) > 0 {
+ out.subject = refs[0]
+ }
+ return out
+}
+
// handleHexisAct — resolves entity references through Nexus and executes
// matching capabilities through Hexis. Returns a reply string when handled,
// or "" to fall through to the system command executor.
@@ -514,27 +608,27 @@ 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)
+ // The names come from entityReferences, not straight from the Text slot: the
+ // model transliterates Latin names as it routes (Vikunja #476, #524).
started := h.now()
- entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
+ res := h.resolveEntityCandidates(ctx, entityReferences(dec))
+ subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err
if err != nil {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
if unauthorizedEcosystemError(err) {
- return "экосистема отклоняет доступ, проверь токен."
+ return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
}
// A genuine Nexus dependency failure, not "no such entity" — stop here
// and report degradation rather than silently falling through to the
// local command executor (ECOSYSTEM-SPEC.md: services degrade
// independently, never a silent all-clear).
- return "экосистема недоступна, попробуй ещё раз."
+ return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
}
if len(ambiguous) > 0 {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
map[string]any{"candidates": len(ambiguous)})
- return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
+ return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
}
if entityID == "" {
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
@@ -553,9 +647,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
if unauthorizedEcosystemError(err) {
- return "экосистема отклоняет доступ, проверь токен."
+ return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
}
- return "экосистема недоступна, попробуй ещё раз."
+ return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
}
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
map[string]any{"entity_id": entityID, "count": len(caps)})
@@ -598,13 +692,30 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
for _, m := range matches {
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]
// Read-only capabilities run immediately; mutating ones are parked for an
// explicit spoken confirm bound to this capability + target.
- if !matched.ReadOnly {
+ // The tier decides, and Hexis owns the tier (Vikunja #523). read_only alone
+ // used to decide it here, which flattened three answers into two: a
+ // capability that wipes the thing it names got the same single spoken "да"
+ // as one that restarts a service, and requires_confirmation — which the
+ // Hexis contract calls server-derived and not settable by a caller — was
+ // read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk.
+ tier := tool.RiskOfCapability(matched.Risk, matched.ReadOnly, matched.RequiresConfirmation)
+ policy := tool.PolicyFor(tier)
+ if !policy.VoiceMayRun {
+ // Irreversible. A confirm turn would not help, for the same reason it
+ // does not help a local row: the STT heard it, the model routed it and
+ // a substring matched the capability, and a spoken "да" checks none of
+ // those. She names the gap and he runs it himself.
+ h.recordEcosystemTrace(ctx, "hexis", "confirmation", traceRefused, started,
+ map[string]any{"entity_id": entityID, "capability": matched.Name, "risk": string(tier)})
+ return phraser.A(phraser.ActNeedsAuthedSurface, nil)
+ }
+ if policy.Confirm {
h.mu.Lock()
h.pendingHexis = &pendingHexisExec{
capabilityID: matched.ID,
@@ -616,7 +727,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.mu.Unlock()
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
map[string]any{"entity_id": entityID, "capability": matched.Name})
- return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
+ return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName})
}
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
@@ -636,7 +747,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
mergeFields(traceErrorFields(err), map[string]any{
"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
// thing under a different key, in a different shape.
@@ -644,7 +755,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
"entity_id": entityID, "entity_name": displayName,
"capability": capName, "causation_id": causationID,
})
- return "команда выполнена для " + displayName + "."
+ return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
}
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
diff --git a/cmd/mavend/ecosystem_degraded_test.go b/cmd/mavend/ecosystem_degraded_test.go
index 383959e..ac9b9fc 100644
--- a/cmd/mavend/ecosystem_degraded_test.go
+++ b/cmd/mavend/ecosystem_degraded_test.go
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
// A Nexus outage during a Hexis act writes a failure trace, and a shared
// store is the one thing the Praxis path could inherit it through.
nexus.SetFault(503)
- if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
+ if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) {
t.Fatalf("nexus outage must not report success, got %q", reply)
}
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
@@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
t.Fatalf("praxis outage must not serve content, got %q", reply)
}
- if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
+ if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
}
}
@@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
if reply == "" {
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
}
- if strings.Contains(reply, "выполнена") {
+ if actRan(reply) {
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
}
if hexis.Count("", "/api/v1") != 0 {
@@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
nexus.SetBody(`{"status":"resolved","entity":`)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
- if reply == "" || strings.Contains(reply, "выполнена") {
+ if reply == "" || actRan(reply) {
t.Fatalf("malformed nexus body must degrade, got %q", reply)
}
if hexis.Count("", "/api/v1") != 0 {
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
nexus := newFakeNexus(t, body)
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
- if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
+ if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
}
})
@@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
- if reply == "" || strings.Contains(reply, "выполнена") {
+ if reply == "" || actRan(reply) {
t.Fatalf("cancelled resolve must degrade, got %q", reply)
}
if hexis.Count("", "/api/v1") != 0 {
@@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
h := ecoHandler(t, nexus, nil, hexis)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
- if strings.Contains(reply, "выполнена") {
+ if actRan(reply) {
t.Fatalf("failed execution must not read as success, got %q", reply)
}
if reply == "" {
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
- if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
+ if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("setup: expected success, got %q", reply)
}
exec := tracesFor(t, h, "hexis", "execute")
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
- if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
+ if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("setup: expected success, got %q", reply)
}
if len(traces(t, h)) == 0 {
@@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
if reply == "" {
t.Errorf("%s: total outage must not answer with silence", name)
}
- if strings.Contains(reply, "выполнена") {
+ if actRan(reply) {
t.Errorf("%s: total outage must not claim success: %q", name, reply)
}
}
diff --git a/cmd/mavend/ecosystem_harness_test.go b/cmd/mavend/ecosystem_harness_test.go
index 521d220..8b8ce5d 100644
--- a/cmd/mavend/ecosystem_harness_test.go
+++ b/cmd/mavend/ecosystem_harness_test.go
@@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
nexus.SetFault(503)
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
- if strings.Contains(reply, "выполнена") {
+ if actRan(reply) {
t.Fatalf("nexus outage must not report success, got %q", reply)
}
nexus.SetFault(0)
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
- if !strings.Contains(reply, "выполнена") {
+ if !actRan(reply) {
t.Fatalf("expected success once nexus recovers, got %q", reply)
}
}
diff --git a/cmd/mavend/ecosystem_test.go b/cmd/mavend/ecosystem_test.go
index 4fc557f..6e7133c 100644
--- a/cmd/mavend/ecosystem_test.go
+++ b/cmd/mavend/ecosystem_test.go
@@ -11,6 +11,7 @@ import (
hexisclient "github.com/kami/hexis/pkg/client"
"github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
// The follow-up "да" turn executes exactly the parked capability.
confirmReply, handled := h.resolveConfirm(ctx, "да")
- if !handled || !strings.Contains(confirmReply, "выполнена") {
+ if !handled || !actRan(confirmReply) {
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
}
if !*executed {
@@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
if h.pendingHexis != nil {
t.Fatal("read-only cap should not park a confirmation")
}
- if !strings.Contains(reply, "выполнена") {
+ if !actRan(reply) {
t.Fatalf("unexpected reply %q", reply)
}
}
@@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
if reply == "" {
t.Fatal("nexus dependency failure must not fall through with an empty reply")
}
- if strings.Contains(reply, "выполнена") {
+ if actRan(reply) {
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
}
}
@@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
if reply == "" {
t.Fatal("hexis dependency failure must not fall through with an empty reply")
}
- if strings.Contains(reply, "выполнена") {
+ if actRan(reply) {
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
}
}
@@ -241,3 +242,73 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
t.Fatal("not_found resolution must never execute a hexis capability")
}
}
+
+// actRan — the reply is the line she says when a capability ran against an
+// entity. The tests used to look for the substring "выполнена", which was a
+// literal out of the act file: the review reworded that line to "готово: {name}"
+// and seventeen assertions went with it (Vikunja #521).
+func actRan(reply string) bool {
+ return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply)
+}
+
+// muzickIndexer — the display name every ecosystem fixture resolves to.
+const muzickIndexer = "Muzick indexer"
+
+// read_only used to be the whole decision on this path, which meant a
+// capability that destroys what it names got the same single spoken "да" as one
+// that restarts a service. Hexis declares the tier and the voice path is not an
+// authorised surface for the top one (Vikunja #523).
+func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) {
+ ctx := context.Background()
+ resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
+ caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]`
+ h, executed := newHexisTestHandler(t, resolved, caps)
+
+ reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
+ if *executed {
+ t.Fatal("an irreversible capability ran from the voice path")
+ }
+ if h.pendingHexis != nil {
+ t.Fatal("an irreversible capability parked a confirm; a spoken да is not enough authority")
+ }
+ if !strings.Contains(reply, "не вернуть") {
+ t.Errorf("reply = %q; want it to name why she will not run it", reply)
+ }
+}
+
+// The other half: Hexis calling a capability safe is enough to run it, even
+// though read_only is the field that used to decide. Nothing here re-derives.
+func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) {
+ ctx := context.Background()
+ resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
+ caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]`
+ h, executed := newHexisTestHandler(t, resolved, caps)
+
+ reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
+ if !*executed {
+ t.Fatal("a capability Hexis calls safe should run")
+ }
+ if !actRan(reply) {
+ t.Fatalf("unexpected reply %q", reply)
+ }
+}
+
+// A mutating capability with no declared tier keeps the confirm turn it has
+// always had, so the split does not quietly loosen an existing box.
+func TestHexisUndeclaredTierStillConfirms(t *testing.T) {
+ ctx := context.Background()
+ resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
+ caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
+ h, executed := newHexisTestHandler(t, resolved, caps)
+
+ reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
+ if *executed {
+ t.Fatal("a mutating capability ran without a confirm")
+ }
+ if h.pendingHexis == nil {
+ t.Fatal("a mutating capability did not park a confirm")
+ }
+ if !strings.Contains(reply, "да или нет") {
+ t.Errorf("reply = %q; want the confirm question", reply)
+ }
+}
diff --git a/cmd/mavend/ecosystem_trace_test.go b/cmd/mavend/ecosystem_trace_test.go
index 86d3b1c..7345481 100644
--- a/cmd/mavend/ecosystem_trace_test.go
+++ b/cmd/mavend/ecosystem_trace_test.go
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
h := ecoHandler(t, nexus, nil, hexis)
- if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
+ if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
t.Fatalf("setup: expected success, got %q", reply)
}
diff --git a/cmd/mavend/entityname.go b/cmd/mavend/entityname.go
index bc42b75..9a9d544 100644
--- a/cmd/mavend/entityname.go
+++ b/cmd/mavend/entityname.go
@@ -13,6 +13,11 @@ import (
// 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._-]*)*`)
+// maxEntityReferences caps how many names one utterance may send to Nexus. The
+// cap is not about correctness, it is about one turn not fanning out into a
+// dozen HTTP calls when the utterance is a paragraph of English.
+const maxEntityReferences = 4
+
// hasLatin reports whether s carries a Latin letter.
func hasLatin(s string) bool {
for _, r := range s {
@@ -23,37 +28,54 @@ func hasLatin(s string) bool {
return false
}
-// entityReferenceText is the name Nexus is asked to resolve.
+// entityReferences returns the names Nexus is asked to resolve, in the order
+// they were said.
//
-// 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
+// Normally there is one, and it is the router's Text slot — 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.
+// 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
+// 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. 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 {
+//
+// What this does NOT do is pick. It used to return the longest run, and length
+// is a guess: "перезапусти nginx на muzick-indexer" has two names in it and the
+// longer one is not reliably the target. Nexus owns which names it knows
+// (docs/ecosystem.md — ambiguous resolution asks the owner, it does not pick),
+// so every run goes over and Nexus answers. Two runs that both resolve are a
+// clarify, not a coin toss.
+func entityReferences(dec router.Decision) []string {
text := dec.Slots.Text
if hasLatin(text) || !hasLatin(dec.Utterance) {
- return text
+ return []string{text}
}
- longest := ""
+ var refs []string
+ seen := map[string]bool{}
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
- if len(m) > len(longest) {
- longest = m
+ m = strings.TrimSpace(m)
+ // A single stray letter is not a name.
+ if len(m) < 2 {
+ continue
+ }
+ key := strings.ToLower(m)
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ refs = append(refs, m)
+ if len(refs) == maxEntityReferences {
+ break
}
}
- longest = strings.TrimSpace(longest)
- // A single stray letter is not a name.
- if len(longest) < 2 {
- return text
+ if len(refs) == 0 {
+ return []string{text}
}
- return longest
+ return refs
}
diff --git a/cmd/mavend/entityname_test.go b/cmd/mavend/entityname_test.go
index 05e7700..85fdc5b 100644
--- a/cmd/mavend/entityname_test.go
+++ b/cmd/mavend/entityname_test.go
@@ -2,61 +2,76 @@ package main
import (
"context"
+ "net/http"
"strings"
+ "sync"
"testing"
"github.com/kami/maven/internal/router"
)
-// TestEntityReferenceText pins when his own words win over the model's.
-func TestEntityReferenceText(t *testing.T) {
+// TestEntityReferences pins when his own words win over the model's, and that
+// every name he said goes over rather than one of them being picked.
+func TestEntityReferences(t *testing.T) {
for _, tc := range []struct {
name string
utterance string
text string
- want string
+ want []string
}{
{
name: "the model transliterated the name",
utterance: "перезапусти muzick indexer",
text: "перезагрузить музик индексер",
- want: "muzick indexer",
+ want: []string{"muzick indexer"},
},
{
name: "it kept the name, so nothing to repair",
utterance: "перезапусти muzick indexer",
text: "перезагрузить muzick indexer",
- want: "перезагрузить muzick indexer",
+ want: []string{"перезагрузить muzick indexer"},
},
{
name: "an all-Russian entity name is not a rewrite",
utterance: "перезапусти домашний сервер",
text: "перезагрузить домашний сервер",
- want: "перезагрузить домашний сервер",
+ want: []string{"перезагрузить домашний сервер"},
},
{
name: "an English turn never enters the recovery",
utterance: "restart muzick indexer",
text: "restart muzick indexer",
- want: "restart muzick indexer",
+ want: []string{"restart muzick indexer"},
},
{
- name: "the longest Latin run is the name",
+ name: "both names go over, in the order he said them",
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
text: "перезагрузить нгинкс",
- want: "muzick-indexer",
+ want: []string{"nginx", "muzick-indexer"},
},
{
name: "one stray letter is not a name",
utterance: "перезапусти сервер a",
text: "перезагрузить сервер",
- want: "перезагрузить сервер",
+ want: []string{"перезагрузить сервер"},
+ },
+ {
+ name: "the same name twice is one question",
+ utterance: "перезапусти nginx, ну правда, nginx",
+ text: "перезагрузить нгинкс",
+ want: []string{"nginx"},
},
} {
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)
+ got := entityReferences(dec)
+ if len(got) != len(tc.want) {
+ t.Fatalf("entityReferences = %q, want %q", got, tc.want)
+ }
+ for i := range got {
+ if got[i] != tc.want[i] {
+ t.Fatalf("entityReferences = %q, want %q", got, tc.want)
+ }
}
})
}
@@ -131,3 +146,77 @@ func TestClarifyStillAsksWithoutHexis(t *testing.T) {
t.Fatal("she must still ask what to do")
}
}
+
+// nexusInOrder serves one resolve answer per call, in order, so a test can say
+// what Nexus knows about the first name and what it knows about the second. The
+// last body repeats once the list runs out.
+func nexusInOrder(t *testing.T, bodies ...string) *fakeServer {
+ t.Helper()
+ var mu sync.Mutex
+ n := 0
+ return newFakeServer(t, map[string]http.HandlerFunc{
+ "POST /api/v1/resolve": func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ body := bodies[min(n, len(bodies)-1)]
+ n++
+ mu.Unlock()
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(body))
+ },
+ })
+}
+
+// TestTwoResolvedNamesAsk — «перезапусти nginx на muzick-indexer» names a
+// service and the host it runs on. Both are real, and which one he meant is not
+// in the utterance, so she asks. Picking one by length was the old behaviour and
+// length is not evidence (Vikunja #524).
+func TestTwoResolvedNamesAsk(t *testing.T) {
+ ctx := context.Background()
+ nexus := nexusInOrder(t,
+ fixtureNexusResolved("ent_nginx", "nginx", "service"),
+ fixtureNexusResolved("ent_host", "Muzick indexer", "device"),
+ )
+ hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
+ h := ecoHandler(t, nexus, nil, hexis)
+
+ dec := router.Decision{
+ Utterance: "перезапусти nginx на muzick-indexer",
+ Intent: router.IntentAct,
+ Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
+ }
+ reply := h.handleHexisAct(ctx, dec)
+ if !strings.Contains(reply, "nginx") || !strings.Contains(reply, "Muzick indexer") {
+ t.Fatalf("reply = %q, want both names she found", reply)
+ }
+ if hexis.Count("POST", "/api/v1/execute") != 0 {
+ t.Fatal("she must not execute against a target she is still asking about")
+ }
+}
+
+// TestTheNameNexusKnowsWins — the other half. Two names go over and only one is
+// an entity, so there is nothing to ask about and the act runs.
+func TestTheNameNexusKnowsWins(t *testing.T) {
+ ctx := context.Background()
+ nexus := nexusInOrder(t,
+ fixtureNexusNotFound(),
+ fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"),
+ )
+ hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
+ h := ecoHandler(t, nexus, nil, hexis)
+
+ dec := router.Decision{
+ Utterance: "перезапусти nginx на muzick-indexer",
+ Intent: router.IntentAct,
+ Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
+ }
+ reply := h.handleHexisAct(ctx, dec)
+ if reply == "" {
+ t.Fatal("the resolvable name must carry the act")
+ }
+ if len(nexus.Requests()) != 2 {
+ t.Fatalf("nexus asked %d times, want both names", len(nexus.Requests()))
+ }
+ if hexis.Count("POST", "/api/v1/execute") == 0 {
+ t.Fatal("hexis was never asked to run it")
+ }
+}
diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go
index 0759c50..4db05da 100644
--- a/cmd/mavend/feeds_test.go
+++ b/cmd/mavend/feeds_test.go
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
t.Fatalf("reply = %q, want only the технологии item", reply)
}
reply, _ = askFeeds(t, h, "что нового по спорту?")
- if !strings.Contains(reply, "ничего") {
+ if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
}
}
@@ -87,16 +87,22 @@ func TestQueryFeedsByCategory(t *testing.T) {
// "не настроены" and "ничего нового" are different truths, and neither may be
// answered by the model inventing a bulletin.
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
+ // Against the entries, not against a substring: both of these have several
+ // wordings, so "ничего нового" passed only on the turns the picker happened
+ // to choose the first one.
off := buildFeedHandler(t, false)
reply, ok := askFeeds(t, off, "что нового в лентах?")
- if !ok || !strings.Contains(reply, "не настроены") {
+ if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
}
on := buildFeedHandler(t, true)
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)
}
+ if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
+ t.Fatalf("an empty feed answered as an unconfigured one: %q", reply)
+ }
}
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
diff --git a/cmd/mavend/netscan.go b/cmd/mavend/netscan.go
index 83c1c35..88d28f1 100644
--- a/cmd/mavend/netscan.go
+++ b/cmd/mavend/netscan.go
@@ -11,6 +11,7 @@ import (
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/netscan"
+ "github.com/kami/maven/internal/phraser"
)
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
@@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
res, err := w.scan(ctx)
if err != nil {
log.Printf("netscan: scan: %v", err)
- return "не получилось просканировать сеть.", true
+ return phraser.Q(phraser.QueryFailNetscan, nil), true
}
// A truncated run is not a statement about the LAN. Saying "нашла 6
// устройств" after stopping two thirds of the way through the range is a
@@ -116,9 +117,9 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
tail = ", но успела посмотреть не всю сеть"
}
if len(res.Hosts) == 0 {
- return "в сети никого не нашла" + tail + ".", true
+ return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
}
- out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
+ out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
if shape := scanShape(res.Hosts); shape != "" {
out += ", " + shape
}
@@ -179,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
if w.api == nil {
return
}
- head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
+ head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
if res.Truncated {
head += " (не вся сеть)"
}
@@ -210,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
}
}
-// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
-// 5+ устройств, and the teens are all the last form.
-func hostWord(n int) string {
- if n%100 >= 11 && n%100 <= 14 {
- return "устройств"
- }
- switch n % 10 {
- case 1:
- return "устройство"
- case 2, 3, 4:
- return "устройства"
- default:
- return "устройств"
- }
-}
-
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
// network word AND an ask: "интернет не работает" is a complaint, not a request
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
diff --git a/cmd/mavend/netscan_test.go b/cmd/mavend/netscan_test.go
index df87124..0adc2ce 100644
--- a/cmd/mavend/netscan_test.go
+++ b/cmd/mavend/netscan_test.go
@@ -10,6 +10,8 @@ import (
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/phraser"
+ "github.com/kami/maven/internal/router"
)
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
@@ -74,18 +76,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
}
}
-func TestHostWordAgreesWithTheCount(t *testing.T) {
- for n, want := range map[int]string{
- 1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
- 11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
- 25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
- } {
- if got := hostWord(n); got != want {
- t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
- }
- }
-}
-
func TestIsNetworkQuery(t *testing.T) {
yes := []string{
"какие устройства в сети?",
@@ -169,3 +159,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
}
}
+
+// An unconfigured scanner names the gap instead of declining the turn.
+//
+// Falling through sent "какие устройства в сети?" to the search leg, which
+// answered with a paragraph about routers in general — and put a question about
+// his own LAN on an upstream engine, which the personal boundary exists to
+// prevent (Vikunja #479).
+func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
+ h := &reactiveHandler{}
+ reply, ok := h.queryNetwork(context.Background(), &queryTurn{
+ dec: router.Decision{Utterance: "какие устройства в сети?"},
+ })
+ if !ok {
+ t.Fatal("an unconfigured scanner let the question fall through to search")
+ }
+ if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
+ t.Errorf("got %q, want the gap named", reply)
+ }
+}
diff --git a/cmd/mavend/personaguard.go b/cmd/mavend/personaguard.go
new file mode 100644
index 0000000..eaa5d52
--- /dev/null
+++ b/cmd/mavend/personaguard.go
@@ -0,0 +1,134 @@
+package main
+
+import (
+ "context"
+ "log"
+ "regexp"
+ "strings"
+ "sync"
+
+ "github.com/kami/maven/internal/delivery"
+ "github.com/kami/maven/internal/loop"
+ "github.com/kami/maven/internal/phraser"
+ "github.com/kami/maven/internal/phraser/eval"
+)
+
+// The persona checks, run before she speaks (Vikunja #399).
+//
+// RunChecks and RunTalkChecks only ever ran from the eval package, so
+// everything the fixtures measured was offline knowledge: we could say "about
+// one reply in three is broken" and still ship every one of them. This runs the
+// cheap half of that on the live path, and replaces a failing message with the
+// deterministic floor.
+//
+// Which checks: the unambiguous string tests only — feminine self-reference,
+// how she addresses him, and a leaked-reasoning test. Not length, which is
+// path-specific, and not ontopic, which compares against fragments the fixture
+// supplies and runtime does not have. Not hisgender either — see guardSpoken.
+//
+// No retry. A retry doubles the latency on the exact turn that is already going
+// badly, and on the nudge path the moment has passed.
+//
+// The known cost, written down because it is real: a wrongly flagged good reply
+// is replaced by a flatter stub one. That is the right trade — a stub sentence
+// is dull, a leaked reasoning trace is broken — but it means these checks can
+// no longer be tuned for sensitivity alone.
+
+// checkLeak — the name reported when the model's scaffolding reaches the text.
+const checkLeak = "leak"
+
+// leakPatterns — reasoning and protocol that belongs to the model, not to him.
+// The resident model is a Thinking variant, so an unclosed reasoning block is
+// the failure mode, not a hypothetical (Vikunja #398).
+var leakPatterns = []*regexp.Regexp{
+ regexp.MustCompile(`(?i)<\s*/?\s*think`),
+ regexp.MustCompile(`(?i)thinking\s*(process|:)`),
+ regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
+ // Raw contract JSON: the parser already unwraps a good one, so a body that
+ // still carries the keys is one it could not read.
+ regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
+ // The persona block quoted back at him.
+ regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
+}
+
+// checkPersonaLeak reports whether the model's own scaffolding is in the text.
+func checkPersonaLeak(body string) (string, bool) {
+ for _, re := range leakPatterns {
+ if m := re.FindString(body); m != "" {
+ return "leaked " + strings.TrimSpace(m), false
+ }
+ }
+ return "", true
+}
+
+// personaRejects counts what the guard caught, by check name, so the real
+// production rate is knowable rather than inferred from the fixture.
+var personaRejects = struct {
+ mu sync.Mutex
+ by map[string]int
+}{by: map[string]int{}}
+
+func personaRejectCounts() map[string]int {
+ personaRejects.mu.Lock()
+ defer personaRejects.mu.Unlock()
+ out := make(map[string]int, len(personaRejects.by))
+ for k, v := range personaRejects.by {
+ out[k] = v
+ }
+ return out
+}
+
+// guardSpoken checks a phrased message. It returns the failed check and false
+// when the message must not be said; path names the caller, for the log.
+//
+// An empty message passes: the caller already treats that as a failure and
+// falls back on its own, and reporting it as a persona breach would put a
+// misleading line in the count.
+func guardSpoken(path, body string) (string, bool) {
+ if strings.TrimSpace(body) == "" {
+ return "", true
+ }
+ if detail, ok := checkPersonaLeak(body); !ok {
+ return rejectSpoken(path, checkLeak, detail, body), false
+ }
+ // Feminine and address only. HisGender is not run here: it reads a
+ // sentence-initial feminine verb with no pronoun — "записала, что ты выпил
+ // воды" — as a woman being addressed, when it is her own correct
+ // self-reference. Offline that is a point of score; on this path it would
+ // replace a good reply with a stub one on every fact she confirms.
+ for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
+ if !r.Pass {
+ return rejectSpoken(path, r.Name, r.Detail, body), false
+ }
+ }
+ return "", true
+}
+
+// rejectSpoken logs what she nearly said and counts it. The whole text, not a
+// prefix: the point of the log line is that the failure can be read back later
+// and argued with.
+func rejectSpoken(path, check, detail, body string) string {
+ personaRejects.mu.Lock()
+ personaRejects.by[check]++
+ personaRejects.mu.Unlock()
+ log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
+ return check
+}
+
+// guardNudge checks a phrased nudge and falls back to the deterministic floor
+// when it fails. The nudge path, unlike the reply path, cannot ask again: the
+// tick has already decided she speaks, so the choice is the floor's wording or
+// a broken sentence.
+func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
+ if _, ok := guardSpoken("nudge", pn.Body); ok {
+ return pn
+ }
+ stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
+ if err != nil {
+ // The Stub is templates over the candidate and does not fail. If it
+ // somehow does, the model's text is still what the rule decided to
+ // say, and saying nothing is the worse outcome.
+ return pn
+ }
+ return stub
+}
diff --git a/cmd/mavend/personaguard_test.go b/cmd/mavend/personaguard_test.go
new file mode 100644
index 0000000..26c599a
--- /dev/null
+++ b/cmd/mavend/personaguard_test.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/kami/maven/internal/delivery"
+ "github.com/kami/maven/internal/loop"
+)
+
+func TestGuardPassesWhatSheShouldSay(t *testing.T) {
+ good := []string{
+ "записала: купить хлеб.",
+ "поняла, напомню в 11:00.",
+ "ты не пил воду с утра.",
+ "я рада, что получилось.",
+ "",
+ }
+ for _, body := range good {
+ if check, ok := guardSpoken("test", body); !ok {
+ t.Errorf("guardSpoken(%q) rejected on %s", body, check)
+ }
+ }
+}
+
+func TestGuardStopsWhatSheShouldNot(t *testing.T) {
+ bad := []struct {
+ body string
+ want string
+ }{
+ {"он просил воду попей воды.", checkLeak},
+ {"Thinking Process: он давно не пил.", checkLeak},
+ {`{"response": "попей воды", "mood": "neutral"}`, checkLeak},
+ {"я напомнил тебе про воду.", "feminine"},
+ {"вы давно не пили воду.", "address"},
+ }
+ for _, c := range bad {
+ check, ok := guardSpoken("test", c.body)
+ if ok {
+ t.Errorf("guardSpoken(%q) let it through", c.body)
+ continue
+ }
+ if check != c.want {
+ t.Errorf("guardSpoken(%q) failed on %s; want %s", c.body, check, c.want)
+ }
+ }
+}
+
+func TestGuardCountsWhatItCaught(t *testing.T) {
+ before := personaRejectCounts()[checkLeak]
+ if _, ok := guardSpoken("test", "…"); ok {
+ t.Fatal("a leaked reasoning block was let through")
+ }
+ if after := personaRejectCounts()[checkLeak]; after != before+1 {
+ t.Errorf("leak count %d; want %d", after, before+1)
+ }
+}
+
+// TestGuardNudgeFallsBackToTheFloor — a broken nudge is replaced by the
+// deterministic wording, not dropped and not retried.
+func TestGuardNudgeFallsBackToTheFloor(t *testing.T) {
+ cand := loop.Candidate{Rule: loop.Rule{Name: "water"}}
+ bad := delivery.PhrasedNudge{Candidate: cand, Body: "Thinking Process: он не пил.", Mood: "neutral"}
+ got := guardNudge(bad, cand)
+ if got.Body == bad.Body {
+ t.Fatal("the broken nudge was delivered unchanged")
+ }
+ if strings.TrimSpace(got.Body) == "" {
+ t.Fatal("the nudge was dropped rather than re-worded")
+ }
+ good := delivery.PhrasedNudge{Candidate: cand, Body: "попей воды.", Mood: "neutral"}
+ if guardNudge(good, cand).Body != good.Body {
+ t.Error("a good nudge was replaced")
+ }
+}
diff --git a/cmd/mavend/query_recall_test.go b/cmd/mavend/query_recall_test.go
index b97c436..431f333 100644
--- a/cmd/mavend/query_recall_test.go
+++ b/cmd/mavend/query_recall_test.go
@@ -121,8 +121,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
})
reply := askQuery(t, h, q)
- if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
- t.Errorf("reply %q, want %q", reply, want)
+ if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
+ t.Errorf("reply %q, want the note read back", reply)
}
// One text, the winning memory's — the answer came from the memory
// pass, not from handing the phraser every note in the table.
@@ -151,7 +151,7 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
{text: "молоко закончилось", score: 0.858, kind: "note"},
})
- if reply := askQuery(t, h, q); reply != "не знаю." {
+ if reply := askQuery(t, h, q); !phraser.IsUnknownFallback(reply) {
t.Errorf("reply %q, want silence", reply)
}
})
diff --git a/cmd/mavend/quiet_toggle.go b/cmd/mavend/quiet_toggle.go
index 1bbd922..02f6e10 100644
--- a/cmd/mavend/quiet_toggle.go
+++ b/cmd/mavend/quiet_toggle.go
@@ -11,6 +11,7 @@ import (
"unicode"
"github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/phraser"
)
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
@@ -32,10 +33,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
return "", false
}
val := "false"
- reply := "тихий режим выключен."
+ reply := phraser.Ack(phraser.AckQuietOff, nil)
if on {
val = "true"
- reply = "тихий режим включён. буду реже напоминать."
+ reply = phraser.Ack(phraser.AckQuietOn, nil)
}
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: h.now(),
@@ -46,7 +47,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
Confidence: 1.0,
}); err != nil {
log.Printf("voice: write quiet_hours: %v", err)
- return "не получилось переключить тихий режим.", true
+ return phraser.Ack(phraser.FailQuiet, nil), true
}
return reply, true
}
diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go
index 20967dd..496afdb 100644
--- a/cmd/mavend/replier_llm.go
+++ b/cmd/mavend/replier_llm.go
@@ -30,5 +30,10 @@ func (r *llmReplier) Reply(d router.Decision) string {
if err != nil || out == "" {
return r.stub.Reply(d)
}
+ // The persona checks, on the live path (personaguard.go). A reply that
+ // leaks reasoning or calls him "вы" is worse than a flat one.
+ if _, ok := guardSpoken("reply", out); !ok {
+ return r.stub.Reply(d)
+ }
return out
}
diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go
index fae6d39..075716a 100644
--- a/cmd/mavend/replier_llm_test.go
+++ b/cmd/mavend/replier_llm_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/kami/maven/internal/llm"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/voice"
)
@@ -29,12 +30,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
- assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
+ assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
}
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
r := newLLMReplier(stubCompleter{out: ""}, nil)
- assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
+ assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
}
func TestLLMReplierClarifyUsesStub(t *testing.T) {
@@ -42,6 +43,16 @@ func TestLLMReplierClarifyUsesStub(t *testing.T) {
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
}
+// assertAck — the stub picks between variants now, so two calls to it are not
+// expected to match. What must hold is that the reply is a line that entry can
+// produce, which is the same claim without pinning one wording.
+func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
+ t.Helper()
+ if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
+ t.Errorf("on %s: got %q, want a %q line", what, got, key)
+ }
+}
+
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
t.Helper()
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
diff --git a/cmd/mavend/ruwords.go b/cmd/mavend/ruwords.go
index f14513f..aba97aa 100644
--- a/cmd/mavend/ruwords.go
+++ b/cmd/mavend/ruwords.go
@@ -4,6 +4,10 @@
// plural agreement, clock/date rendering, and the "do I actually know this
// place/day" guards that pick an honest reply over a confidently wrong one.
// Extend this file rather than voice.go for anything in that shape.
+//
+// Count agreement is not here. It is say.CountWord, because there were four
+// copies of the same three-way rule and two of the sites that needed it were
+// spelling one form out (Vikunja #521).
package main
import (
@@ -11,6 +15,8 @@ import (
"strconv"
"strings"
"time"
+
+ "github.com/kami/maven/internal/say"
)
var ruWeekdays = []string{
@@ -94,11 +100,11 @@ func mentionsUnknownDay(u string) bool {
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
func ruClock(t time.Time) string {
h, m := t.Hour(), t.Minute()
- hourWord := ruPlural(h, "час", "часа", "часов")
+ hourWord := say.CountWord(h, "час", "часа", "часов")
if m == 0 {
return fmt.Sprintf("%d %s ровно", h, hourWord)
}
- return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут"))
+ return fmt.Sprintf("%d %s %d %s", h, hourWord, m, say.CountWord(m, "минута", "минуты", "минут"))
}
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
@@ -118,22 +124,6 @@ func dayPrefix(now, day time.Time) string {
return "это"
}
-func ruPlural(n int, one, two, many string) string {
- n = n % 100
- if n > 10 && n < 20 {
- return many
- }
- n = n % 10
- switch n {
- case 1:
- return one
- case 2, 3, 4:
- return two
- default:
- return many
- }
-}
-
// hasDurationWords checks whether u is asking about elapsed/remaining time
// rather than the current clock — guards replySystem from replying "сейчас
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
@@ -169,11 +159,13 @@ func formatTime(t time.Time) string {
case diff < 10*time.Minute:
return "несколько минут назад"
case diff < 60*time.Minute:
- return fmt.Sprintf("%d минут назад", int(diff.Minutes()))
+ n := int(diff.Minutes())
+ return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "минуту", "минуты", "минут"))
case diff < 2*time.Hour:
return "час назад"
case diff < 24*time.Hour:
- return fmt.Sprintf("%d часа назад", int(diff.Hours()))
+ n := int(diff.Hours())
+ return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
default:
return t.Format("2 января 15:04")
}
diff --git a/cmd/mavend/smarthome.go b/cmd/mavend/smarthome.go
index 03946b2..8b73a22 100644
--- a/cmd/mavend/smarthome.go
+++ b/cmd/mavend/smarthome.go
@@ -4,10 +4,12 @@ import (
"context"
"fmt"
"log"
+ "strconv"
"strings"
"time"
"github.com/kami/maven/internal/config"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/smarthome"
"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)
if err != nil {
log.Printf("smarthome: summary: %v", err)
- return "не смогла достучаться до дома.", true
+ return phraser.A(phraser.HomeUnreachable, nil), true
}
if len(ents) == 0 {
- return "дом ничего не отдаёт.", true
+ return phraser.A(phraser.HomeEmpty, nil), true
}
var on []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
// 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 {
line += fmt.Sprintf(" и ещё %d", rest)
}
@@ -185,7 +187,10 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
case dark > 0 && len(sensors) == 0:
// Nothing is on and everything she can see is unreachable. "всё
// выключено" 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": phraser.Devices(dark),
+ }), true
default:
parts = append(parts, "всё выключено")
}
@@ -193,7 +198,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
parts = append(parts, strings.Join(sensors, ", "))
}
if dark > 0 {
- parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
+ parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark)))
}
return strings.Join(parts, "; ") + ".", true
}
diff --git a/cmd/mavend/snooze.go b/cmd/mavend/snooze.go
index 88fb235..f92c981 100644
--- a/cmd/mavend/snooze.go
+++ b/cmd/mavend/snooze.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
@@ -51,10 +52,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu
}
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
- return "не получилось отложить.", true
+ return phraser.Ack(phraser.FailSnooze, nil), true
}
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
- return "хорошо, вернусь к этому позже.", true
+ return phraser.Ack(phraser.AckSnooze, nil), true
}
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go
index 52c4732..4266a0e 100644
--- a/cmd/mavend/tick.go
+++ b/cmd/mavend/tick.go
@@ -28,6 +28,7 @@ import (
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/routine"
+ "github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store"
)
@@ -176,6 +177,9 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
t.queueNudge(ctx, cand, state, now)
} else {
pn, err := t.phraser.PhraseNudge(ctx, *cand)
+ if err == nil {
+ pn = guardNudge(pn, *cand)
+ }
if err != nil {
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
} else {
@@ -627,7 +631,11 @@ func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now t
fmt.Fprintf(&b, " · и ещё %d", extra)
}
body := b.String()
- summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
+ // The adjective declines with the noun, so the count picks the whole
+ // phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5
+ // отложенных уведомлений.
+ summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries),
+ "отложенное уведомление", "отложенных уведомления", "отложенных уведомлений"))
cand := loop.Candidate{
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go
index 9c47646..7658f15 100644
--- a/cmd/mavend/voicewire.go
+++ b/cmd/mavend/voicewire.go
@@ -382,6 +382,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...)
+ // After the agenda rules: "расскажи, что у меня сегодня" is an agenda
+ // question first and a narrative request second (Vikunja #498).
+ grammars = append(grammars, router.NarrativeQueryGrammars()...)
+ grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
// Last, and it matches any utterance shape — its Build is the filter. An
// explicit capture marker beats the model, which called it an act and
diff --git a/cmd/mavend/worldmodel.go b/cmd/mavend/worldmodel.go
index c6c712f..bf7072c 100644
--- a/cmd/mavend/worldmodel.go
+++ b/cmd/mavend/worldmodel.go
@@ -23,7 +23,11 @@ type worldPhraser interface {
// question about his meeting came back as a swimming competition in Nottingham.
// Naming the gap is the rule CLAUDE.md already applies to a sibling service
// being down.
-const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
+//
+// The wording lives in fallbacks_ru_v1.json and is fixed there, not picked from
+// variants: this sentence names one specific gap and must not drift into a
+// general "I don't know".
+func worldGap() string { return phraser.WorldGap() }
// phraseWorld asks the world model, or reports the gap.
//
@@ -54,7 +58,11 @@ func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance stri
log.Printf("voice: %s: no world model, reading the source back instead", name)
return ""
case err != nil:
+ // The resident phraser answers this call with its fallback text and the
+ // error together. Drop the text: these callers hold the passage itself
+ // and read it back better than "вот что я нашла: " does.
log.Printf("voice: %s: phrase: %v", name, err)
+ return ""
}
return reply
}
diff --git a/cmd/mavend/worldmodel_test.go b/cmd/mavend/worldmodel_test.go
index f8d0ad1..2d7b9d7 100644
--- a/cmd/mavend/worldmodel_test.go
+++ b/cmd/mavend/worldmodel_test.go
@@ -35,7 +35,7 @@ func TestQueryGeneralNamesTheGap(t *testing.T) {
if !ok {
t.Fatal("queryGeneral passed on the last source in the chain")
}
- if reply != worldGap {
+ if reply != worldGap() {
t.Fatalf("reply = %q, want the named gap", reply)
}
if g.worldCalls != 1 {
@@ -51,7 +51,7 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
if !ok {
t.Fatal("queryGeneral passed on the last source in the chain")
}
- if reply != "не знаю." {
+ if !phraser.IsUnknownFallback(reply) {
t.Fatalf("reply = %q, want the Stub's answer", reply)
}
}
@@ -61,12 +61,12 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
// English in it.
func TestWorldGapIsInPersona(t *testing.T) {
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
- if strings.Contains(worldGap, bad) {
- t.Errorf("the gap phrase contains %q: %s", bad, worldGap)
+ if strings.Contains(worldGap(), bad) {
+ t.Errorf("the gap phrase contains %q: %s", bad, worldGap())
}
}
- if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") {
- t.Errorf("the gap phrase has Latin letters in it: %s", worldGap)
+ if strings.ContainsAny(worldGap(), "abcdefghijklmnopqrstuvwxyz") {
+ t.Errorf("the gap phrase has Latin letters in it: %s", worldGap())
}
}
diff --git a/docs/design.md b/docs/design.md
index 9c9e01d..f5d84ed 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -317,6 +317,40 @@ don't improvise.** Destructive ones still gate behind confirm.
Misroute correction is append-only and grows the router's examples with use —
same shape as `nudges.outcome` tuning cooldowns, no retrain.
+#### Risk tiers, not one boolean
+
+`Destructive` on a tool row is one bit set by whoever ticked the checkbox on
+`/tools`. It is a mechanism, and it never said which acts are destructive,
+whether a confirmed act stays confirmed, or what a new tool domain inherits.
+`internal/tool/risk.go` is the policy (Vikunja #449). The tier is DERIVED from
+the row, not stored, so it can be argued with in one place instead of being
+whatever the last person to enable the tool believed.
+
+| Tier | What it is | What it costs |
+|---|---|---|
+| `safe` | a read, or a change he can undo by saying the opposite | runs on first hearing |
+| `destructive` | it changes something real and undoing it takes work | one confirm turn, every time |
+| `irreversible` | the thing does not come back: a wipe, a format, a delete with no bin | voice may not authorise it at all |
+
+Three rules fall out, and they are the part that was missing:
+
+- **Which acts are destructive is not only the checkbox.** A house row always
+ is, because there is no read-only way to turn the heating off. A row whose
+ argv names one of the irreversible verbs always is, whatever the row says.
+- **A confirmed act never stays confirmed.** At any tier. A confirmation binds
+ one capability, one target and one argument list, and it dies with the parked
+ turn (90s). "The same act again" is a new act. A sticky confirm is a standing
+ grant and nothing on the voice path may hold one.
+- **A new domain inherits `destructive`, not `safe`.** A dispatch shape the
+ policy does not recognise gets the confirm turn. A domain argues its way down
+ to running freely; it never has to argue its way up to being gated.
+
+The irreversible tier is refused rather than asked about, because a confirm
+turn would be theatre: everything that proposed the act — an STT guess, a
+router guess, a fuzzy allowlist match — is a guess, and a spoken "да" checks
+none of it. She names the gap and he runs it himself. The row stays enabled;
+refusing to run it from voice is not the same as taking it off the allowlist.
+
---
## Voice pipeline (STT / TTS)
@@ -654,6 +688,31 @@ add a new principle; it applied the existing one at smaller and smaller scope.
---
+## A list is the fourth shape
+
+Facts, notes and tasks were the three append-only shapes. `list_items` is the
+fourth (Vikunja #453): an item, a status, and a list tag.
+
+It is not a task. Milk is not work, nothing prioritises it, and the ranker must
+not start counting groceries as outstanding errands. It is not a fact either,
+because it claims nothing about the world. What it is, is a set that grows and
+shrinks.
+
+The property that makes the separate table worth it: no predicate reads a list.
+Nothing ranks it, nothing nudges about it, the digestion worker ignores it. So
+two people adding to the same list at once cost nothing — there is no order to
+disagree about and no lifecycle past crossed-off.
+
+The unique index is the tasks one, per list, and live rows only. Saying "молоко"
+twice before the shop is one line; saying it again next week, after the last one
+was crossed off, is a new line.
+
+Spoken, it is four turns: add, read back, cross one item off, cross the lot off.
+All four are matched deterministically in `internal/router/list.go` and all four
+run at stage 0, because an add and a read-back are cheap and should not depend on
+the resident model having a good turn. Crossing one item off claims the turn only
+when the list holds that item, which is what keeps "купил новый ноутбук" a note.
+
## Calendar
Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is
diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go
index 8ed3260..e926671 100644
--- a/internal/memory/behavior.go
+++ b/internal/memory/behavior.go
@@ -3,8 +3,11 @@ package memory
import (
"fmt"
"sort"
+ "strconv"
"strings"
"time"
+
+ "github.com/kami/maven/internal/say"
)
// Behavioural memory — "what do I usually do?" (Vikunja #254).
@@ -337,13 +340,14 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
day := weekdayRU[int(wd)%7]
acts := p.Weekly[wd]
if len(acts) > 0 {
- return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
+ return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
}
if len(p.Everyday) > 0 {
- return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
- day, joinActivities(p.Everyday))
+ return say.S(say.HabitWeekdaySame, map[string]string{
+ "day": day, "items": joinActivities(p.Everyday),
+ })
}
- return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
+ return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
}
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
@@ -355,19 +359,17 @@ func (p Profile) FormatWeekendRU() string {
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
switch {
case len(sat) > 0 && len(sun) > 0:
- return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
- joinActivities(sat), joinActivities(sun))
+ return say.S(say.HabitWeekendBoth, map[string]string{
+ "items_sat": joinActivities(sat), "items_sun": joinActivities(sun),
+ })
case len(sat) > 0:
- return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
- joinActivities(sat))
+ return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
case len(sun) > 0:
- return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
- joinActivities(sun))
+ return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
case len(p.Everyday) > 0:
- return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
- joinActivities(p.Everyday))
+ return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
}
- return "по выходным я пока не вижу у тебя ничего постоянного."
+ return say.S(say.HabitWeekendNone, nil)
}
// FormatOverallRU reads back the habits that hold across the whole week, and
@@ -378,33 +380,23 @@ func (p Profile) FormatWeekendRU() string {
// a year of them, and only one of those is worth believing.
func (p Profile) FormatOverallRU() string {
if len(p.All) == 0 {
- return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
+ return say.S(say.HabitOverallNone, nil)
}
- return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU())
+ return say.S(say.HabitOverall, map[string]string{
+ "items": joinActivities(p.All), "span": p.spanRU(),
+ })
}
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
// is too short to name in days.
func (p Profile) spanRU() string {
if p.Since.IsZero() || !p.Until.After(p.Since) {
- return "по записям за сегодня"
+ return say.S(say.HabitSpanToday, nil)
}
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
- return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
-}
-
-// pluralDaysRU — the Russian count form of "день" for n.
-func pluralDaysRU(n int) string {
- switch {
- case n%100 >= 11 && n%100 <= 14:
- return "дней"
- case n%10 == 1:
- return "день"
- case n%10 >= 2 && n%10 <= 4:
- return "дня"
- default:
- return "дней"
- }
+ return say.S(say.HabitSpanDays, map[string]string{
+ "n": strconv.Itoa(days), "word": say.Days(days),
+ })
}
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
@@ -423,14 +415,18 @@ func joinActivities(acts []Activity) string {
// come from the model, so an unglossed one is as likely to be
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
// not a sentence.
- gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
+ gloss = say.S(say.HabitUnglossed, map[string]string{
+ "key": strings.ReplaceAll(a.Key, "_", " "),
+ })
}
if !a.HasTypical {
parts[i] = gloss
continue
}
- parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
- int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
+ parts[i] = say.S(say.HabitAt, map[string]string{
+ "gloss": gloss,
+ "time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
+ })
}
if len(parts) == 1 {
return parts[0]
diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go
index e2ee61c..5ad342e 100644
--- a/internal/memory/behavior_test.go
+++ b/internal/memory/behavior_test.go
@@ -5,6 +5,8 @@ import (
"testing"
"time"
"unicode"
+
+ "github.com/kami/maven/internal/say"
)
// habitHistory — n weeks of the same weekday, at the given local time.
@@ -70,7 +72,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
if len(p.All) != 0 || len(p.Weekly) != 0 {
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
}
- if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
+ if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) {
t.Errorf("empty profile reads %q", got)
}
}
@@ -124,7 +126,7 @@ func TestProfileFormatRUPersona(t *testing.T) {
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
- if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
+ if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") {
t.Errorf("an unknown weekday reads %q", empty)
}
// Persona: she addresses him informally, never in the masculine about
@@ -203,7 +205,9 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
// A day with nothing of its own says so rather than reciting water as if
// Wednesday were the reason for it.
wed := p.FormatWeekdayRU(time.Wednesday)
- if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
+ if !say.IsS(say.HabitWeekdaySame, map[string]string{
+ "day": "средам", "items": "пьёшь воду около 13:30",
+ }, wed) {
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
}
}
@@ -313,16 +317,19 @@ func TestOverallNamesThePeriod(t *testing.T) {
}
}
-// The no-data weekday answer is about him, not about her. "у меня пока нет
-// ничего постоянного" answers a question nobody asked.
-func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
+// The no-data weekday answer is about his week, not about her. "у меня пока нет
+// ничего постоянного" answers a question nobody asked. It does say "я не вижу",
+// because what she is reporting is the state of her records — and it names the
+// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя"
+// came out as filler, the day carries it).
+func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) {
p := BuildProfile(nil, behaviorNow())
got := p.FormatWeekdayRU(time.Wednesday)
if strings.Contains(got, "у меня") {
t.Errorf("got %q", got)
}
- if !strings.Contains(got, "у тебя") {
- t.Errorf("got %q, want an answer about him", got)
+ if !strings.Contains(got, "средам") {
+ t.Errorf("got %q, want the day he asked about", got)
}
}
@@ -340,17 +347,6 @@ func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
}
}
-func TestPluralDaysRU(t *testing.T) {
- for _, c := range []struct {
- n int
- want string
- }{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
- if got := pluralDaysRU(c.n); got != c.want {
- t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want)
- }
- }
-}
-
// "по выходным" is a question about two days, answered as two days.
func TestFormatWeekendRU(t *testing.T) {
now := behaviorNow()
diff --git a/internal/morning/plan.go b/internal/morning/plan.go
index ba2c699..5129187 100644
--- a/internal/morning/plan.go
+++ b/internal/morning/plan.go
@@ -6,6 +6,7 @@ import (
"strings"
"time"
+ "github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store"
)
@@ -164,17 +165,20 @@ func (p Plan) FormatRU() string {
// it is over, and saying it was empty is a false statement about a day
// he just lived.
if p.Rest {
- return "на сегодня больше ничего не запланировано."
+ return say.S(say.PlanRestEmpty, nil)
}
- return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
+ return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")})
}
parts := make([]string, len(p.Items))
for i, it := range p.Items {
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
if it.Uncertain {
- line = "похоже, " + line
+ line = say.S(say.PlanUncertain, map[string]string{"line": line})
}
parts[i] = line
}
- return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
+ return say.S(say.PlanDay, map[string]string{
+ "date": p.Date.Format("02.01.2006"),
+ "items": strings.Join(parts, "; "),
+ })
}
diff --git a/internal/morning/plan_test.go b/internal/morning/plan_test.go
index c7b282e..3448412 100644
--- a/internal/morning/plan_test.go
+++ b/internal/morning/plan_test.go
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"time"
+ "unicode"
"github.com/kami/maven/internal/store"
)
@@ -137,7 +138,7 @@ func TestPlanFormatRU(t *testing.T) {
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
"10:30 — утро — осталось: витамины; " +
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
- "18:30 — позвонить маме."
+ "18:30 — позвонить маме"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
@@ -215,3 +216,26 @@ func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) {
t.Fatalf("got %+v", p.Items)
}
}
+
+// plan_uncertain nests one rendered line inside another sentence: «похоже, » in
+// front of what this loop already built. That reads as one sentence only while
+// what arrives starts lowercase, and it does here because every line starts with
+// the clock time. A capital after the hedge would be «похоже, Планёрка».
+func TestTheUncertainHedgeRunsIntoLowercase(t *testing.T) {
+ now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
+ p := Plan{Date: now, Items: []PlanEntry{
+ {At: planAt(now, 14, 0), Text: "Планёрка", Kind: PlanEvent, Uncertain: true},
+ }}
+ got := p.FormatRU()
+ const hedge = "похоже, "
+ i := strings.Index(got, hedge)
+ if i < 0 {
+ t.Fatalf("%q does not hedge an uncertain item", got)
+ }
+ for _, r := range got[i+len(hedge):] {
+ if unicode.IsUpper(r) {
+ t.Fatalf("the hedge runs into a capital: %q", got)
+ }
+ break
+ }
+}
diff --git a/internal/phraser/ack_ru_v1.json b/internal/phraser/ack_ru_v1.json
new file mode 100644
index 0000000..01f5254
--- /dev/null
+++ b/internal/phraser/ack_ru_v1.json
@@ -0,0 +1,84 @@
+{
+ "schema_version": 1,
+ "name": "russian capture acknowledgements v1",
+ "notes": [
+ "What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.",
+ "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
+ "He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.",
+ "Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.",
+ "An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored."
+ ],
+ "entries": {
+ "ack_fact": {
+ "variants": ["записала факт.", "записала.", "запомнила."]
+ },
+ "ack_fact_key": {
+ "variants": ["отметила: {key}", "записала: {key}", "запомнила: {key}"]
+ },
+ "ack_fact_kv": {
+ "variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"]
+ },
+ "ack_note": {
+ "variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
+ },
+ "ack_reminder": {
+ "variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
+ },
+ "ack_act": {
+ "variants": ["ок, записала действие: {fn}", "приняла действие: {fn}"]
+ },
+ "ack_task": {
+ "variants": ["записала: {text}", "добавила в задачи: {text}", "внесла в список: {text}"]
+ },
+ "ack_task_urgent": {
+ "variants": ["поняла, беру в работу: {text}", "поняла, это срочно: {text}"]
+ },
+ "ack_task_duplicate": {
+ "variants": ["это уже в списке.", "такое уже есть в задачах."]
+ },
+ "ack_nudge": {
+ "variants": ["отлично, отметила.", "отметила.", "хорошо, отметила."]
+ },
+ "ack_snooze": {
+ "variants": ["хорошо, вернусь к этому позже.", "ладно, напомню попозже.", "хорошо, отложила."]
+ },
+ "ack_generic": {
+ "variants": ["приняла.", "поняла."]
+ },
+ "quiet_on": {
+ "fixed": true,
+ "variants": ["тихий режим включён. буду реже напоминать."]
+ },
+ "quiet_off": {
+ "fixed": true,
+ "variants": ["тихий режим выключен."]
+ },
+ "fail_fact": {
+ "variants": ["не получилось сохранить факт.", "факт не сохранился."]
+ },
+ "fail_note": {
+ "variants": ["не получилось сохранить заметку.", "заметка не сохранилась."]
+ },
+ "fail_reminder": {
+ "variants": ["не получилось поставить напоминание.", "напоминание не поставилось."]
+ },
+ "fail_reminder_time": {
+ "variants": ["не получилось разобрать время напоминания.", "не поняла, на когда напомнить."]
+ },
+ "fail_task": {
+ "variants": ["не получилось записать задачу.", "задача не записалась."]
+ },
+ "fail_ack": {
+ "variants": ["не получилось отметить.", "не смогла отметить."]
+ },
+ "fail_snooze": {
+ "variants": ["не получилось отложить.", "не смогла отложить."]
+ },
+ "fail_quiet": {
+ "variants": ["не получилось переключить тихий режим.", "тихий режим не переключился."]
+ },
+ "fail_fact_unparsed": {
+ "variants": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"]
+ }
+ }
+}
diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go
new file mode 100644
index 0000000..c860663
--- /dev/null
+++ b/internal/phraser/acks.go
@@ -0,0 +1,164 @@
+package phraser
+
+// The capture acknowledgements — what she says after storing something he said,
+// and what she says when storing it failed.
+//
+// They were string literals in eight files under cmd/mavend plus the stub
+// replier in internal/voice. He hears them many times a day, which is exactly
+// why they need variants and exactly why rewording one must not be a rebuild.
+// Same shape as fallbacks_ru_v1.json, on the shared deck (deck.go).
+//
+// His data stays Go-side. The file holds "отметила: {key} = {value}"; the key
+// and the value are interpolated here, so nothing he said lives in the copy.
+
+import (
+ _ "embed"
+ "log"
+ "math/rand"
+ "sync"
+
+ "github.com/kami/maven/internal/say"
+)
+
+//go:embed ack_ru_v1.json
+var ackJSON []byte
+
+// AckSchemaVersion — this family's own version. A file that changes on a
+// different day than the fallbacks cannot share their number (Vikunja #397).
+const AckSchemaVersion = 1
+
+// The entry keys. Namespaced by family, because the floor map behind a nil deck
+// is process-wide.
+const (
+ AckFact = "ack_fact"
+ AckFactKey = "ack_fact_key"
+ AckFactValue = "ack_fact_kv"
+ AckNote = "ack_note"
+ AckReminder = "ack_reminder"
+ AckAct = "ack_act"
+ AckTask = "ack_task"
+ AckTaskUrgent = "ack_task_urgent"
+ AckTaskDuplicate = "ack_task_duplicate"
+ AckNudge = "ack_nudge"
+ AckSnooze = "ack_snooze"
+ AckGeneric = "ack_generic"
+ AckQuietOn = "quiet_on"
+ AckQuietOff = "quiet_off"
+
+ FailFact = "fail_fact"
+ FailFactUnparsed = "fail_fact_unparsed"
+ FailNote = "fail_note"
+ FailReminder = "fail_reminder"
+ FailReminderTime = "fail_reminder_time"
+ FailTask = "fail_task"
+ FailAck = "fail_ack"
+ FailSnooze = "fail_snooze"
+ FailQuiet = "fail_quiet"
+)
+
+// ackKeys — every key the code requires the file to define.
+var ackKeys = []string{
+ AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct,
+ AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
+ AckQuietOn, AckQuietOff,
+ FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
+ FailTask, FailAck, FailSnooze, FailQuiet,
+}
+
+// ackFloor — the literal each key falls back to when the file is unusable.
+// These are the exact strings that lived in Go before this file existed.
+var ackFloor = map[string]string{
+ AckFact: "записала факт.",
+ AckFactKey: "отметила: {key}",
+ AckFactValue: "отметила: {key} = {value}",
+ AckNote: "сохранила заметку.",
+ AckReminder: "напомню.",
+ AckAct: "ок, записала действие: {fn}",
+ AckTask: "записала: {text}",
+ AckTaskUrgent: "поняла, беру в работу: {text}",
+ AckTaskDuplicate: "это уже в списке.",
+ AckNudge: "отлично, отметила.",
+ AckSnooze: "хорошо, вернусь к этому позже.",
+ AckGeneric: "приняла.",
+ AckQuietOn: "тихий режим включён. буду реже напоминать.",
+ AckQuietOff: "тихий режим выключен.",
+
+ FailFact: "не получилось сохранить факт.",
+ FailFactUnparsed: "не разобрала, что записать — попробуй иначе.",
+ FailNote: "не получилось сохранить заметку.",
+ FailReminder: "не получилось поставить напоминание.",
+ FailReminderTime: "не получилось разобрать время напоминания.",
+ FailTask: "не получилось записать задачу.",
+ FailAck: "не получилось отметить.",
+ FailSnooze: "не получилось отложить.",
+ FailQuiet: "не получилось переключить тихий режим.",
+}
+
+// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
+type Acks struct{ d *say.Deck }
+
+// LoadAcks reads the embedded file. Pass a source to make the picking
+// reproducible in tests; nil seeds from the clock.
+func LoadAcks(src rand.Source) (*Acks, error) {
+ d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
+ if err != nil {
+ return nil, err
+ }
+ // The three entries that exist to read his own words back. A variant
+ // without the placeholder would confirm the capture and drop what was
+ // captured, which reads as a successful save of nothing.
+ for _, req := range []struct{ key, ph string }{
+ {AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
+ {AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
+ } {
+ if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
+ return nil, err
+ }
+ }
+ return &Acks{d: d}, nil
+}
+
+// deck reads through a nil *Acks, which is the unloadable-file case.
+func (a *Acks) deck() *say.Deck {
+ if a == nil {
+ return say.FloorDeck(ackFloor)
+ }
+ return a.d
+}
+
+// Say returns one line for key, with his data filled into the frame. Pass nil
+// when the entry takes none.
+func (a *Acks) Say(key string, vars map[string]string) string {
+ return a.deck().Text(key, vars)
+}
+
+// Variants returns every line the file can produce, for the persona scorer.
+func (a *Acks) Variants() []string { return a.deck().Variants() }
+
+var (
+ ackOnce sync.Once
+ acks *Acks
+)
+
+// DefaultAcks returns the shared instance, loading it on first use. A broken
+// file logs once and leaves a nil *Acks, which still answers from ackFloor.
+func DefaultAcks() *Acks {
+ ackOnce.Do(func() {
+ a, err := LoadAcks(nil)
+ if err != nil {
+ log.Printf("phraser: acknowledgements unavailable, using the built-in lines: %v", err)
+ return
+ }
+ acks = a
+ })
+ return acks
+}
+
+// Ack — one acknowledgement line, the way every caller says it.
+func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) }
+
+// IsAck reports whether text is a line key could have produced. For the daemon
+// tests, which can no longer compare against one literal.
+func IsAck(key string, vars map[string]string, text string) bool {
+ return DefaultAcks().deck().Matches(key, vars, text)
+}
diff --git a/internal/phraser/act_lines_test.go b/internal/phraser/act_lines_test.go
new file mode 100644
index 0000000..9d48054
--- /dev/null
+++ b/internal/phraser/act_lines_test.go
@@ -0,0 +1,85 @@
+package phraser
+
+import (
+ "math/rand"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func loadTestActs(t *testing.T) *Acts {
+ t.Helper()
+ a, err := LoadActs(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadActs: %v", err)
+ }
+ return a
+}
+
+// She talks about a lamp or a server, never about a row in a schema. «сущность»
+// and «экосистема» are the same defect as saying a capability id out loud.
+func TestNoActLineSaysASchemaWord(t *testing.T) {
+ a := loadTestActs(t)
+ for _, v := range a.Variants() {
+ for _, word := range []string{"сущност", "экосистем"} {
+ if strings.Contains(v, word) {
+ t.Errorf("variant %q says %q out loud", v, word)
+ }
+ }
+ }
+}
+
+// Nexus, Praxis and Hexis fail independently, so "не отвечает" with no subject
+// is not an answer he can act on.
+func TestAServiceFailureNamesTheService(t *testing.T) {
+ a := loadTestActs(t)
+ for _, key := range []string{EcoDown, EcoDenied} {
+ got := a.Say(key, map[string]string{"name": "Praxis"})
+ if !strings.HasPrefix(got, "Praxis ") {
+ t.Errorf("%s = %q, want it to name the service", key, got)
+ }
+ }
+}
+
+// A confirmation prompt for a destructive act is the worst place for an unfilled
+// placeholder, so the two names it interpolates are distinct keys and both are
+// declared.
+func TestConfirmEntityFillsBothNames(t *testing.T) {
+ a := loadTestActs(t)
+ got := a.Say(ActConfirmEntity, map[string]string{
+ "name": "restart", "name_entity": "Muzick indexer",
+ })
+ if strings.ContainsAny(got, "{}") {
+ t.Fatalf("act_confirm_entity = %q, want no placeholder left", got)
+ }
+ if !strings.Contains(got, "restart") || !strings.Contains(got, "Muzick indexer") {
+ t.Fatalf("act_confirm_entity = %q, want both names", got)
+ }
+}
+
+// home_dark counts unreachable devices, and Russian inflects the noun after the
+// number: the count goes in {count} and the noun comes from the helper.
+func TestHomeDarkCountsWithTheHelper(t *testing.T) {
+ a := loadTestActs(t)
+ for n, want := range map[int]string{1: "1 устройство", 2: "2 устройства", 5: "5 устройств"} {
+ got := a.Say(HomeDark, map[string]string{"count": strconv.Itoa(n), "word": Devices(n)})
+ if !strings.Contains(got, want) {
+ t.Errorf("home_dark for %d = %q, want %q in it", n, got, want)
+ }
+ }
+}
+
+// Four truths, four entries: a failure must not be able to report itself as a
+// success, and an empty result must not read as a failure.
+func TestActOutcomesStayDistinct(t *testing.T) {
+ a := loadTestActs(t)
+ seen := map[string]string{}
+ for _, key := range actKeys {
+ for _, v := range a.d.VariantsOf(key) {
+ if prev, dup := seen[v]; dup {
+ t.Errorf("%s and %s both say %q", prev, key, v)
+ }
+ seen[v] = key
+ }
+ }
+}
diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go
new file mode 100644
index 0000000..fd20b46
--- /dev/null
+++ b/internal/phraser/acts.go
@@ -0,0 +1,197 @@
+package phraser
+
+// The act and smart-home replies — what she says when a capability ran, refused,
+// or could not be reached.
+//
+// Fourth family on the shared deck (deck.go). They were literals in
+// ecosystem_acts.go, actions_act.go and smarthome.go, where a reworded line was
+// a rebuild of the daemon that executes his house.
+//
+// The four outcomes stay four entries. Reporting a refusal with the wording of
+// a success is the one failure mode this family can have, and a shared variant
+// set is how it would happen.
+
+import (
+ _ "embed"
+ "log"
+ "math/rand"
+ "sync"
+
+ "github.com/kami/maven/internal/say"
+)
+
+//go:embed acts_ru_v1.json
+var actJSON []byte
+
+// ActSchemaVersion — this family's own version.
+const ActSchemaVersion = 1
+
+// The entry keys.
+const (
+ ActDone = "act_done"
+ ActDoneOut = "act_done_out"
+ ActDoneEntity = "act_done_entity"
+ ActConfirm = "act_confirm"
+ ActConfirmEntity = "act_confirm_entity"
+ ActWhich = "act_which"
+ ActFail = "act_fail"
+ ActFailOut = "act_fail_out"
+ ActFailEntity = "act_fail_entity"
+ ActServerDown = "act_server_down"
+ ActWithdrawn = "act_withdrawn"
+ ActNeedsArgs = "act_needs_args"
+ // ActNeedsAuthedSurface — the irreversible tier, local row or Hexis
+ // capability alike. Not a failure and not a refusal to help: a spoken "да"
+ // is the only authority the voice path can offer, and this is the one act
+ // it is not enough for (Vikunja #449, #523).
+ ActNeedsAuthedSurface = "act_needs_authed_surface"
+
+ EcoDenied = "eco_denied"
+ EcoDown = "eco_down"
+ EcoAmbiguous = "eco_ambiguous"
+ EcoUnknownEntity = "eco_unknown_entity"
+ EcoNoNexus = "eco_no_nexus"
+ EcoAboutWhat = "eco_about_what"
+ EcoRecall = "eco_recall"
+
+ AttentionNone = "attention_none"
+ AttentionList = "attention_list"
+ AttentionFail = "attention_fail"
+ AttentionNoneEntity = "attention_none_entity"
+ AttentionListEntity = "attention_list_entity"
+ AttentionFailEntity = "attention_fail_entity"
+ ChangesNone = "changes_none"
+ ChangesList = "changes_list"
+ ChangesFail = "changes_fail"
+ HomeUnreachable = "home_unreachable"
+ HomeEmpty = "home_empty"
+ HomeOn = "home_on"
+ HomeDark = "home_dark"
+)
+
+var actKeys = []string{
+ ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich,
+ ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs,
+ ActNeedsAuthedSurface,
+ EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
+ AttentionNone, AttentionList, AttentionFail,
+ AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
+ ChangesNone, ChangesList, ChangesFail,
+ HomeUnreachable, HomeEmpty, HomeOn, HomeDark,
+}
+
+// actFloor — the literal each key falls back to when the file is unusable. It
+// started as the exact strings that lived in Go before this file existed and now
+// tracks the file's first variant instead, because a floor that keeps the
+// wording review threw out would say it back on the one turn nobody is watching.
+var actFloor = map[string]string{
+ ActDone: "готово.",
+ ActDoneOut: "готово: {out}",
+ ActDoneEntity: "готово: {name}.",
+ ActConfirm: "выполнить «{name}»? да или нет.",
+ ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.",
+ ActWhich: "какую команду для {name}: {items}?",
+ ActFail: "не получилось выполнить команду.",
+ ActFailOut: "не получилось выполнить команду: {out}",
+ ActFailEntity: "не получилось выполнить команду для {name}.",
+ ActServerDown: "инструмент есть, но сервер не подключён.",
+ ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
+ ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
+ ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.",
+
+ EcoDenied: "{name} отклоняет доступ, проверь токен.",
+ EcoDown: "{name} не отвечает, попробуй ещё раз.",
+ EcoAmbiguous: "что именно: {items}?",
+ EcoUnknownEntity: "не знаю, что это.",
+ EcoNoNexus: "не с чем связать — Nexus не настроен.",
+ EcoAboutWhat: "про что именно?",
+ EcoRecall: "я помню: {items}",
+
+ AttentionNone: "ничего не требует внимания.",
+ AttentionList: "требует внимания: {items}",
+ AttentionFail: "не могу сейчас узнать, что требует внимания.",
+ AttentionNoneEntity: "по «{name}» ничего нет.",
+ AttentionListEntity: "по «{name}»: {items}",
+ AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».",
+ ChangesNone: "изменений нет.",
+ ChangesList: "изменения: {items}",
+ ChangesFail: "не могу сейчас узнать об изменениях.",
+ HomeUnreachable: "дом не отвечает.",
+ HomeEmpty: "дом ничего не отдаёт.",
+ HomeOn: "включено: {items}",
+ HomeDark: "не отвечают: {count} {word}.",
+}
+
+// Acts picks a hand-written Russian act reply. Safe for concurrent use.
+type Acts struct{ d *say.Deck }
+
+// LoadActs reads the embedded file. Pass a source to make the picking
+// reproducible in tests; nil seeds from the clock.
+func LoadActs(src rand.Source) (*Acts, error) {
+ d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src)
+ if err != nil {
+ return nil, err
+ }
+ // The entries that name what ran or what he has to choose between. A
+ // variant that dropped the name would confirm an act without saying which.
+ for _, req := range []struct{ key, ph string }{
+ {ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"},
+ {ActFailEntity, "{name}"}, {ActConfirm, "{name}"},
+ {ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{name_entity}"},
+ {ActWhich, "{name}"}, {ActWhich, "{items}"},
+ {EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"},
+ {AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"},
+ {EcoDenied, "{name}"}, {EcoDown, "{name}"},
+ {HomeDark, "{count}"}, {HomeDark, "{word}"},
+ {AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"},
+ {AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"},
+ } {
+ if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
+ return nil, err
+ }
+ }
+ return &Acts{d: d}, nil
+}
+
+// deck reads through a nil *Acts, which is the unloadable-file case.
+func (a *Acts) deck() *say.Deck {
+ if a == nil {
+ return say.FloorDeck(actFloor)
+ }
+ return a.d
+}
+
+// Say returns one line for key, with the names filled into the frame.
+func (a *Acts) Say(key string, vars map[string]string) string {
+ return a.deck().Text(key, vars)
+}
+
+// Variants returns every line the file can produce, for the persona scorer.
+func (a *Acts) Variants() []string { return a.deck().Variants() }
+
+var (
+ actOnce sync.Once
+ actsDeck *Acts
+)
+
+// DefaultActs returns the shared instance, loading it on first use. A broken
+// file logs once and leaves a nil *Acts, which still answers from actFloor.
+func DefaultActs() *Acts {
+ actOnce.Do(func() {
+ a, err := LoadActs(nil)
+ if err != nil {
+ log.Printf("phraser: act replies unavailable, using the built-in lines: %v", err)
+ return
+ }
+ actsDeck = a
+ })
+ return actsDeck
+}
+
+// A — one act reply, the way every caller says it.
+func A(key string, vars map[string]string) string { return DefaultActs().Say(key, vars) }
+
+// IsA reports whether text is a line key could have produced, for the tests.
+func IsA(key string, vars map[string]string, text string) bool {
+ return DefaultActs().deck().Matches(key, vars, text)
+}
diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json
new file mode 100644
index 0000000..932b5e5
--- /dev/null
+++ b/internal/phraser/acts_ru_v1.json
@@ -0,0 +1,145 @@
+{
+ "schema_version": 1,
+ "name": "russian act and smart-home replies v1",
+ "notes": [
+ "What she says when a capability ran, refused, or could not be reached. Edit the wording here, no Go changes needed.",
+ "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
+ "\"it ran\", \"it was refused\", \"a service is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.",
+ "She says what he would say. No schema words out loud: not «сущность», not «экосистема», not a capability id, not a config key. She is talking about a lamp or a server.",
+ "A service that is down or refusing is named. \"не отвечает\" with no subject tells him nothing he can act on, and Nexus, Praxis and Hexis fail independently.",
+ "Placeholders: {name} an entity or capability the caller resolved, {name_entity} the entity an act runs against when {name} is already the capability, {out} the command's own output, {items} a joined list, {count} a number, {word} the counted noun in the form {count} needs. Entity names and capability ids are interpolated Go-side.",
+ "A count never carries a hardcoded noun. Russian inflects it — 1 устройство, 2 устройства, 5 устройств — so the number goes in {count} and the noun comes from the Go helper through {word}.",
+ "An entry that only exists to read a list back must never be reached with an empty list. The caller routes an empty list to the matching _none entry, because a single-variant placeholder-only line has no shorter wording to fall back to.",
+ "fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test."
+ ],
+ "entries": {
+ "act_done": {
+ "fixed": true,
+ "variants": ["готово."]
+ },
+ "act_done_out": {
+ "variants": ["готово: {out}", "сделала: {out}"]
+ },
+ "act_done_entity": {
+ "fixed": true,
+ "variants": ["готово: {name}."]
+ },
+ "act_confirm": {
+ "fixed": true,
+ "variants": ["выполнить «{name}»? да или нет."]
+ },
+ "act_confirm_entity": {
+ "fixed": true,
+ "variants": ["выполнить «{name}» для {name_entity}? да или нет."]
+ },
+ "act_which": {
+ "fixed": true,
+ "variants": ["какую команду для {name}: {items}?"]
+ },
+ "act_fail": {
+ "fixed": true,
+ "variants": ["не получилось выполнить команду."]
+ },
+ "act_fail_out": {
+ "fixed": true,
+ "variants": ["не получилось выполнить команду: {out}"]
+ },
+ "act_fail_entity": {
+ "fixed": true,
+ "variants": ["не получилось выполнить команду для {name}."]
+ },
+ "act_server_down": {
+ "fixed": true,
+ "variants": ["инструмент есть, но сервер не подключён."]
+ },
+ "act_withdrawn": {
+ "fixed": true,
+ "variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."]
+ },
+ "act_needs_args": {
+ "fixed": true,
+ "variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
+ },
+ "act_needs_authed_surface": {
+ "fixed": true,
+ "variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."]
+ },
+ "eco_denied": {
+ "fixed": true,
+ "variants": ["{name} отклоняет доступ, проверь токен."]
+ },
+ "eco_down": {
+ "fixed": true,
+ "variants": ["{name} не отвечает, попробуй ещё раз."]
+ },
+ "eco_ambiguous": {
+ "fixed": true,
+ "variants": ["что именно: {items}?"]
+ },
+ "eco_unknown_entity": {
+ "variants": ["не знаю, что это.", "такого у меня нет."]
+ },
+ "eco_no_nexus": {
+ "fixed": true,
+ "variants": ["не с чем связать — Nexus не настроен."]
+ },
+ "eco_about_what": {
+ "fixed": true,
+ "variants": ["про что именно?"]
+ },
+ "eco_recall": {
+ "fixed": true,
+ "variants": ["я помню: {items}"]
+ },
+ "attention_none": {
+ "fixed": true,
+ "variants": ["ничего не требует внимания."]
+ },
+ "attention_list": {
+ "fixed": true,
+ "variants": ["требует внимания: {items}"]
+ },
+ "attention_fail": {
+ "fixed": true,
+ "variants": ["не могу сейчас узнать, что требует внимания."]
+ },
+ "attention_none_entity": {
+ "variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
+ },
+ "attention_list_entity": {
+ "fixed": true,
+ "variants": ["по «{name}»: {items}"]
+ },
+ "attention_fail_entity": {
+ "fixed": true,
+ "variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
+ },
+ "changes_none": {
+ "fixed": true,
+ "variants": ["изменений нет."]
+ },
+ "changes_list": {
+ "fixed": true,
+ "variants": ["изменения: {items}"]
+ },
+ "changes_fail": {
+ "fixed": true,
+ "variants": ["не могу сейчас узнать об изменениях."]
+ },
+ "home_unreachable": {
+ "variants": ["дом не отвечает.", "не достучалась до дома."]
+ },
+ "home_empty": {
+ "fixed": true,
+ "variants": ["дом ничего не отдаёт."]
+ },
+ "home_on": {
+ "fixed": true,
+ "variants": ["включено: {items}"]
+ },
+ "home_dark": {
+ "fixed": true,
+ "variants": ["не отвечают: {count} {word}."]
+ }
+ }
+}
diff --git a/internal/phraser/eval/checks.go b/internal/phraser/eval/checks.go
index 250593b..586928a 100644
--- a/internal/phraser/eval/checks.go
+++ b/internal/phraser/eval/checks.go
@@ -67,6 +67,19 @@ func RunChecks(c Case, body, mood string) []Result {
}
}
+// Feminine, HisGender and Address expose three checks one at a time, so the
+// daemon can run them on a phrased message before he hears it (Vikunja #399).
+// Only these three: they are unambiguous string tests with nothing to compare
+// against, while length is path-specific and ontopic needs the fixture's
+// expected fragments, which do not exist at runtime.
+func Feminine(body string) Result { return checkFeminine(body) }
+
+// HisGender — see checkHisGender.
+func HisGender(body string) Result { return checkHisGender(body) }
+
+// Address — see checkAddress.
+func Address(body string) Result { return checkAddress(body) }
+
func checkMood(mood string) Result {
if Moods[mood] {
return Result{CheckMood, true, ""}
diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go
new file mode 100644
index 0000000..eb2f617
--- /dev/null
+++ b/internal/phraser/eval/fallbacks_test.go
@@ -0,0 +1,70 @@
+package eval
+
+import (
+ "math/rand"
+ "strings"
+ "testing"
+
+ "github.com/kami/maven/internal/phraser"
+ "github.com/kami/maven/internal/say"
+)
+
+// TestFallbackPersona scores every line in every hand-written family 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 says "рад" or "вы"
+// would otherwise reach him with nothing in between.
+//
+// Only the persona checks run. Mood and topic belong to a nudge, and these are
+// not nudges.
+func TestFallbackPersona(t *testing.T) {
+ fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
+ if err != nil {
+ t.Fatalf("LoadFallbacks: %v", err)
+ }
+ // No CheckHisGender. It reads a feminine verb near a second-person pronoun
+ // as addressing him as a woman, which is right for a nudge and wrong here:
+ // "не знаю — не нашла у тебя такой записи" is her own verb in her own
+ // sentence. CheckFeminine still holds her side of the rule.
+ persona := map[string]bool{
+ CheckLang: true, CheckFeminine: true,
+ CheckAddress: true, CheckCringe: true, CheckLength: true,
+ }
+ ack, err := phraser.LoadAcks(rand.NewSource(20260804))
+ if err != nil {
+ t.Fatalf("LoadAcks: %v", err)
+ }
+ qry, err := phraser.LoadQueries(rand.NewSource(20260804))
+ if err != nil {
+ t.Fatalf("LoadQueries: %v", err)
+ }
+ variants := append(fb.Variants(), ack.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()...)
+ sum, err := say.LoadSummaries(rand.NewSource(20260804))
+ if err != nil {
+ t.Fatalf("LoadSummaries: %v", err)
+ }
+ variants = append(variants, sum.Variants()...)
+ if len(variants) == 0 {
+ t.Fatal("no variants — the file loaded empty")
+ }
+ for _, v := range variants {
+ // The placeholders stand for his own words and carry no persona.
+ body := v
+ for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
+ "{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
+ "{entity}", "{count}", "{word}",
+ "{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} {
+ body = strings.ReplaceAll(body, ph, "вода")
+ }
+ for _, r := range RunChecks(Case{}, body, "neutral") {
+ if persona[r.Name] && !r.Pass {
+ t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
+ }
+ }
+ }
+}
diff --git a/internal/phraser/eval/talk.go b/internal/phraser/eval/talk.go
index 6d239f8..08a4681 100644
--- a/internal/phraser/eval/talk.go
+++ b/internal/phraser/eval/talk.go
@@ -78,6 +78,12 @@ type TalkCase struct {
Note string `json:"note,omitempty"`
}
+// TalkSchemaVersion — the version this loader understands. Separate from the
+// nudge fixture's SchemaVersion: the two fixtures have different shapes and
+// change on different days, and one shared constant would force a bump on the
+// fixture that did not move.
+const TalkSchemaVersion = 1
+
// TalkFixture — the versioned envelope, same gating as Fixture.
type TalkFixture struct {
SchemaVersion int `json:"schema_version"`
@@ -92,8 +98,8 @@ func LoadTalk() (TalkFixture, error) {
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
}
- if f.SchemaVersion != SchemaVersion {
- return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
+ if f.SchemaVersion != TalkSchemaVersion {
+ return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
}
if len(f.Cases) == 0 {
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
diff --git a/internal/phraser/eval/talk_test.go b/internal/phraser/eval/talk_test.go
index 7b993c9..72b4810 100644
--- a/internal/phraser/eval/talk_test.go
+++ b/internal/phraser/eval/talk_test.go
@@ -142,19 +142,13 @@ func TestLLMTalkBaseline(t *testing.T) {
p := phraser.NewLLMPhraserAt(base, cfg)
defer p.Close()
- // Unreachable server is fatal here, not a logged warning, and that differs
- // from the nudge test on purpose. PhraseNudge returns its errors, so a dead
- // server there shows up honestly in the Errors column. PhraseChat and
- // PhraseQuery do NOT: they swallow every failure and return a canned string
- // ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths
- // a dead server produces a full report with 0 errors and a terrible score —
- // a number that looks like bad phrasing and is really no phrasing at all.
- // Refusing to score without a confirmed model is the only guard available
- // until the phraser reports its failures (Vikunja #397).
+ // The model id names the run in the report. Since Vikunja #397 every path
+ // returns its errors, so a server that dies mid-run shows up in the Errors
+ // column instead of scoring as bad phrasing — the before-and-after probe that
+ // used to stand in for that is gone.
model, err := llm.ModelID(ctx, base)
if err != nil {
- t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+
- "and would report a plausible-looking result off a dead server", base, err)
+ t.Fatalf("no model at %s: %v", base, err)
}
t.Logf("scoring model %s at %s", model, base)
@@ -169,10 +163,11 @@ func TestLLMTalkBaseline(t *testing.T) {
}
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
- // And again afterwards: the run takes minutes, and a server that died or got
- // OOM-killed halfway through would leave the first cases scored and the rest
- // silently canned. Checking only at the start would not catch that.
- if _, err := llm.ModelID(ctx, base); err != nil {
- t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err)
+ // A run where nothing was phrased is not a low score, it is no measurement.
+ if rep.Errors == rep.Total {
+ t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result")
+ }
+ if rep.Errors > 0 {
+ t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total)
}
}
diff --git a/internal/phraser/failure_test.go b/internal/phraser/failure_test.go
new file mode 100644
index 0000000..28636fe
--- /dev/null
+++ b/internal/phraser/failure_test.go
@@ -0,0 +1,80 @@
+package phraser
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// isFallback — the text she says is picked from that entry's variants, so a test
+// pins the entry rather than the wording. Pinning one line would make editing
+// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
+func isFallback(t *testing.T, key, sources, got string) bool {
+ t.Helper()
+ return DefaultFallbacks().deck().Matches(key, map[string]string{"sources": sources}, got)
+}
+
+// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
+// PhraseQuery keep the turn alive with canned text — and every one of those
+// lines is also a legitimate reply, so the text alone cannot say which happened.
+// The error is the only signal, and before Vikunja #397 it was dropped: the talk
+// scorer reported a full run with zero errors off a server that answered nothing.
+func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.Error(w, "model not loaded", http.StatusServiceUnavailable)
+ }))
+ t.Cleanup(srv.Close)
+ p := NewLLMPhraserAt(srv.URL, Config{})
+
+ cases := []struct {
+ name string
+ call func() (string, error)
+ key string
+ sources string
+ }{
+ {"chat", func() (string, error) {
+ return p.PhraseChat(context.Background(), "как дела", nil)
+ }, fbChat, ""},
+ {"knowledge", func() (string, error) {
+ return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
+ }, fbQueryUnknown, ""},
+ {"evidence", func() (string, error) {
+ return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
+ }, fbQuerySources, "два литра"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, err := c.call()
+ if err == nil {
+ t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing")
+ }
+ if !isFallback(t, c.key, c.sources, got) {
+ t.Errorf("fallback text = %q, want a %q variant — the daemon still has to say something", got, c.key)
+ }
+ })
+ }
+}
+
+// An empty answer is a failure too: the server is up and produced no tokens,
+// which is not an answer and must not score as one.
+func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
+ }))
+ t.Cleanup(srv.Close)
+ p := NewLLMPhraserAt(srv.URL, Config{})
+
+ got, err := p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
+ if err == nil {
+ t.Fatal("an empty response scored as an answer")
+ }
+ if !isFallback(t, fbQueryUnknown, "", got) {
+ t.Errorf("fallback text = %q, want a %q variant", got, fbQueryUnknown)
+ }
+ if !strings.Contains(err.Error(), "empty") {
+ t.Errorf("error = %v; want it to name the empty response", err)
+ }
+}
diff --git a/internal/phraser/fallbacks.go b/internal/phraser/fallbacks.go
new file mode 100644
index 0000000..6876fcf
--- /dev/null
+++ b/internal/phraser/fallbacks.go
@@ -0,0 +1,140 @@
+package phraser
+
+// The phrasing fallbacks — what she says when the model gave her nothing usable.
+//
+// They were four string literals spread across phraser.go, llmphraser.go and
+// cmd/mavend/worldmodel.go. Every one of them is a line he hears out loud, so
+// rewording one was a Go edit, a rebuild and a redeploy for what is product copy.
+//
+// The floor under the floor is deliberate. These strings exist because something
+// already failed, so a broken template file must not be able to take the last
+// words she has: every accessor falls back to the literal it replaced.
+
+import (
+ _ "embed"
+ "log"
+ "math/rand"
+ "sync"
+
+ "github.com/kami/maven/internal/say"
+)
+
+//go:embed fallbacks_ru_v1.json
+var fallbackJSON []byte
+
+// FallbackSchemaVersion — the version this code understands. Its own constant,
+// not shared with the nudge templates or the eval fixtures: two files that change
+// on different days cannot be versioned by one number (Vikunja #397).
+const FallbackSchemaVersion = 1
+
+// The entry keys. Every one of them is read by a method below, so a typo in the
+// file is caught at load rather than at the moment she needs the words.
+const (
+ fbChat = "chat"
+ fbQueryUnknown = "query_unknown"
+ fbQuerySources = "query_sources"
+ fbWorldGap = "world_gap"
+)
+
+// fbKeys — every key the code requires the file to define.
+var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
+
+// hardFloor — the literal each key falls back to when the file is unusable.
+// These are the exact strings that lived in Go before this file existed.
+var hardFloor = map[string]string{
+ fbChat: "даже не знаю, что сказать.",
+ fbQueryUnknown: "не знаю.",
+ fbQuerySources: "вот что я нашла: {sources}",
+ fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
+}
+
+// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
+type Fallbacks struct{ d *say.Deck }
+
+// LoadFallbacks reads the embedded file. Pass a source to make the picking
+// reproducible in tests; nil seeds from the clock.
+func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
+ d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
+ if err != nil {
+ return nil, err
+ }
+ // query_sources is the one entry whose whole job is to read something back.
+ if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil {
+ return nil, err
+ }
+ return &Fallbacks{d: d}, nil
+}
+
+// deck reads through a nil *Fallbacks, which is the unloadable-file case.
+func (f *Fallbacks) deck() *say.Deck {
+ if f == nil {
+ return say.FloorDeck(hardFloor)
+ }
+ return f.d
+}
+
+// Chat — nothing usable came back on the chat path.
+func (f *Fallbacks) Chat() string { return f.deck().Text(fbChat, nil) }
+
+// Unknown — a question she cannot answer and will not guess at.
+func (f *Fallbacks) Unknown() string { return f.deck().Text(fbQueryUnknown, nil) }
+
+// FromSources — read back what she was handed, because phrasing it failed.
+func (f *Fallbacks) FromSources(sources string) string {
+ return f.deck().Text(fbQuerySources, map[string]string{"sources": sources})
+}
+
+// WorldGap — the world model is the one configured to answer and it is not
+// answering. Fixed wording: it names a specific gap, and a variant set here
+// would let "the big model is asleep" drift into "I don't know".
+func (f *Fallbacks) WorldGap() string { return f.deck().Text(fbWorldGap, nil) }
+
+// Variants returns every line the file can produce, for the persona scorer.
+func (f *Fallbacks) Variants() []string { return f.deck().Variants() }
+
+// The process-wide instance. Package-level because these lines are needed on
+// paths that have no phraser to hand — cmd/mavend names the world gap without
+// one — and because a template file that is embedded and validated at load has
+// nothing per-instance to configure.
+var (
+ fallbackOnce sync.Once
+ fallbacks *Fallbacks
+)
+
+// DefaultFallbacks returns the shared instance, loading it on first use. A
+// broken file logs once and leaves a nil *Fallbacks, which still answers from
+// hardFloor — a daemon must not fail to boot over its own copy deck.
+func DefaultFallbacks() *Fallbacks {
+ fallbackOnce.Do(func() {
+ fb, err := LoadFallbacks(nil)
+ if err != nil {
+ log.Printf("phraser: fallbacks unavailable, using the built-in lines: %v", err)
+ return
+ }
+ fallbacks = fb
+ })
+ return fallbacks
+}
+
+// ChatFallback — what she says when the chat path produced nothing.
+func ChatFallback() string { return DefaultFallbacks().Chat() }
+
+// UnknownFallback — what she says when she has no answer and will not invent one.
+func UnknownFallback() string { return DefaultFallbacks().Unknown() }
+
+// SourcesFallback — read the sources back rather than ship a broken fragment.
+func SourcesFallback(sources string) string { return DefaultFallbacks().FromSources(sources) }
+
+// WorldGap — what he hears when the world model is configured and unreachable.
+func WorldGap() string { return DefaultFallbacks().WorldGap() }
+
+// IsUnknownFallback reports whether text is one of her "I do not know" lines.
+// The daemon tests read it to tell an answer from a shrug.
+func IsUnknownFallback(text string) bool {
+ return DefaultFallbacks().deck().Matches(fbQueryUnknown, nil, text)
+}
+
+// IsSourcesFallback reports whether text is sources read back verbatim.
+func IsSourcesFallback(text, sources string) bool {
+ return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text)
+}
diff --git a/internal/phraser/fallbacks_ru_v1.json b/internal/phraser/fallbacks_ru_v1.json
new file mode 100644
index 0000000..e225889
--- /dev/null
+++ b/internal/phraser/fallbacks_ru_v1.json
@@ -0,0 +1,42 @@
+{
+ "schema_version": 1,
+ "name": "russian phrasing fallbacks v1",
+ "notes": [
+ "What she says when the model gave her nothing usable. Edit the wording here, no Go changes needed.",
+ "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives, never он/его about him. No pet names.",
+ "These are heard after a failure, so they stay short and admit the gap. None of them may claim knowledge she does not have.",
+ "Placeholders: {sources} the notes or passages she was handed. A variant whose placeholder has no value is skipped, so every entry needs at least one variant with no placeholder — except query_sources, which exists only to read sources back.",
+ "fixed: true means exactly one variant and no picking. Used where the wording is load-bearing and must not drift between turns."
+ ],
+ "entries": {
+ "chat": {
+ "variants": [
+ "даже не знаю, что сказать.",
+ "не могу найти слов.",
+ "мысль ускользнула, повтори?",
+ "у меня сейчас пусто в голове."
+ ]
+ },
+ "query_unknown": {
+ "variants": [
+ "не знаю.",
+ "не знаю, честно.",
+ "тут я пас.",
+ "не скажу, не знаю."
+ ]
+ },
+ "query_sources": {
+ "variants": [
+ "вот что я нашла: {sources}",
+ "нашла вот это: {sources}",
+ "есть только это: {sources}"
+ ]
+ },
+ "world_gap": {
+ "fixed": true,
+ "variants": [
+ "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
+ ]
+ }
+ }
+}
diff --git a/internal/phraser/fallbacks_test.go b/internal/phraser/fallbacks_test.go
new file mode 100644
index 0000000..789badc
--- /dev/null
+++ b/internal/phraser/fallbacks_test.go
@@ -0,0 +1,74 @@
+package phraser
+
+import (
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+// The embedded file must load, or the daemon speaks from hardFloor and nobody
+// finds out until he hears the wrong words.
+func TestFallbacksLoad(t *testing.T) {
+ fb, err := LoadFallbacks(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadFallbacks: %v", err)
+ }
+ if got := fb.FromSources("два литра"); !strings.Contains(got, "два литра") {
+ t.Errorf("FromSources = %q, want the sources in it", got)
+ }
+ if fb.WorldGap() != hardFloor[fbWorldGap] {
+ t.Errorf("WorldGap = %q, want the fixed wording %q", fb.WorldGap(), hardFloor[fbWorldGap])
+ }
+}
+
+// A broken or missing file must not take her last words away: every accessor
+// answers from the literal it replaced.
+func TestNilFallbacksAnswerFromTheHardFloor(t *testing.T) {
+ var fb *Fallbacks
+ if got := fb.Chat(); got != hardFloor[fbChat] {
+ t.Errorf("Chat = %q, want %q", got, hardFloor[fbChat])
+ }
+ if got := fb.Unknown(); got != hardFloor[fbQueryUnknown] {
+ t.Errorf("Unknown = %q, want %q", got, hardFloor[fbQueryUnknown])
+ }
+ if got := fb.FromSources("два литра"); got != "вот что я нашла: два литра" {
+ t.Errorf("FromSources = %q", got)
+ }
+ if got := fb.WorldGap(); got != hardFloor[fbWorldGap] {
+ t.Errorf("WorldGap = %q", got)
+ }
+}
+
+// Hearing the identical words every time a request fails is how a failure stops
+// registering as one.
+func TestFallbacksDoNotRepeat(t *testing.T) {
+ fb, err := LoadFallbacks(rand.NewSource(7))
+ if err != nil {
+ t.Fatalf("LoadFallbacks: %v", err)
+ }
+ prev := fb.Chat()
+ for i := 0; i < 20; i++ {
+ got := fb.Chat()
+ if got == prev {
+ t.Fatalf("chat repeated %q on turn %d", got, i)
+ }
+ prev = got
+ }
+}
+
+// The acknowledgements load, fill his words into the frame, and answer from the
+// floor when the file is gone.
+func TestAcksLoad(t *testing.T) {
+ a, err := LoadAcks(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadAcks: %v", err)
+ }
+ got := a.Say(AckFactValue, map[string]string{"key": "вода", "value": "2л"})
+ if !strings.Contains(got, "вода") || !strings.Contains(got, "2л") {
+ t.Errorf("Say(%s) = %q, want his key and value in it", AckFactValue, got)
+ }
+ var nilAcks *Acks
+ if got := nilAcks.Say(AckNote, nil); got != ackFloor[AckNote] {
+ t.Errorf("nil Acks said %q, want the floor %q", got, ackFloor[AckNote])
+ }
+}
diff --git a/internal/phraser/fixedness_test.go b/internal/phraser/fixedness_test.go
new file mode 100644
index 0000000..1372798
--- /dev/null
+++ b/internal/phraser/fixedness_test.go
@@ -0,0 +1,38 @@
+package phraser
+
+import (
+ "math/rand"
+ "testing"
+)
+
+// The other four families, held to the rule internal/say holds the fifth to:
+// one variant means fixed. Reported per family, because a failure that names
+// "some file" is a failure nobody acts on.
+func TestEverySingleVariantEntryIsFixed(t *testing.T) {
+ f, err := LoadFallbacks(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadFallbacks: %v", err)
+ }
+ a, err := LoadAcks(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadAcks: %v", err)
+ }
+ q, err := LoadQueries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadQueries: %v", err)
+ }
+ acts, err := LoadActs(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadActs: %v", err)
+ }
+ for name, keys := range map[string][]string{
+ "fallbacks": f.d.UnfixedSingles(),
+ "acks": a.d.UnfixedSingles(),
+ "queries": q.d.UnfixedSingles(),
+ "acts": acts.d.UnfixedSingles(),
+ } {
+ if len(keys) > 0 {
+ t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys)
+ }
+ }
+}
diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go
index 2c5e28d..b06ecac 100644
--- a/internal/phraser/llmphraser.go
+++ b/internal/phraser/llmphraser.go
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"log"
@@ -26,6 +27,11 @@ import (
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
+// errEmptyResponse — the server answered and said nothing. Separate from a
+// transport failure: the model is up and produced no tokens, which is still not
+// an answer and must not score as one.
+var errEmptyResponse = errors.New("phraser: empty response from the model")
+
type LLMPhraser struct {
cfg Config
client *http.Client
@@ -428,8 +434,11 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
}
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
-// compose a natural answer. Falls back to "вот что я нашла: " on any
-// LLM error — better to give the raw data than silence.
+// compose a natural answer. On any LLM error it returns the fallback text —
+// "вот что я нашла: ", or "не знаю." with no notes — and the error
+// together. The daemon uses the text and keeps the turn alive; a caller that is
+// measuring counts the failure. Until Vikunja #397 the error was dropped, so a
+// dead server scored as bad phrasing.
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
// Blank sources are no sources. A caller that hands over one empty string —
// a page that fetched to nothing, a snippet trimmed away — used to take the
@@ -439,13 +448,15 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
if len(notes) == 0 {
sys, prompt := p.knowledgePrompt(utterance)
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
- if err != nil || resp == "" {
- return "не знаю.", nil
+ if err != nil {
+ return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err)
+ }
+ if resp == "" {
+ return UnknownFallback(), errEmptyResponse
}
text, _, perr := parseResponseMood(resp)
if perr != nil {
- log.Printf("phraser: PhraseQuery: %v", perr)
- return "не знаю.", nil
+ return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr)
}
if text != "" {
return text, nil
@@ -457,13 +468,12 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
text, _, perr := parseResponseMood(resp)
if err != nil || perr != nil {
// Read the notes out rather than ship a broken fragment.
- if perr != nil {
- log.Printf("phraser: PhraseQuery: %v", perr)
+ cause := err
+ if cause == nil {
+ cause = perr
}
- if len(notes) == 1 {
- return "вот что я нашла: " + notes[0], nil
- }
- return "вот что я нашла: " + strings.Join(notes, "; "), nil
+ return SourcesFallback(strings.Join(notes, "; ")),
+ fmt.Errorf("phrase query (evidence): %w", cause)
}
if text != "" {
return text, nil
@@ -472,8 +482,9 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
}
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
-// message array from dialogue history + the current user utterance. Falls back
-// to a simple greeting on any LLM error — better to say something than nothing.
+// message array from dialogue history + the current user utterance. On any LLM
+// error it returns both ChatFallback and the error, on the same rule as
+// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
sys := chatSystemPrompt(p.cfg.ContextBlock)
msgs := []chatMsg{
@@ -490,13 +501,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
resp, err := p.chatWithMessages(ctx, msgs, 768)
if err != nil {
- log.Printf("phraser: PhraseChat: %v", err)
- return "поговорили.", nil
+ return ChatFallback(), fmt.Errorf("phrase chat: %w", err)
}
text, _, perr := parseResponseMood(resp)
if perr != nil {
- log.Printf("phraser: PhraseChat: %v", perr)
- return "поговорили.", nil
+ return ChatFallback(), fmt.Errorf("phrase chat: %w", perr)
}
if text != "" {
return text, nil
diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go
index 79488d2..8fb9130 100644
--- a/internal/phraser/phraser.go
+++ b/internal/phraser/phraser.go
@@ -70,18 +70,15 @@ func NewStub() *Stub { return &Stub{} }
// prompted response from the model. The history parameter is accepted but
// ignored at the stub level (the production impl uses it for multi-turn).
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
- return "поговорили.", nil
+ return ChatFallback(), nil
}
// PhraseQuery returns a deterministic summary of the best matching notes.
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
if len(notes) == 0 {
- return "не знаю.", nil
+ return UnknownFallback(), nil
}
- if len(notes) == 1 {
- return "вот что я нашла: " + notes[0], nil
- }
- return "вот что я нашла: " + strings.Join(notes, "; "), nil
+ return SourcesFallback(strings.Join(notes, "; ")), nil
}
// Close implements Phraser.Close (no-op for the stub).
diff --git a/internal/phraser/plural.go b/internal/phraser/plural.go
new file mode 100644
index 0000000..c9c76f7
--- /dev/null
+++ b/internal/phraser/plural.go
@@ -0,0 +1,19 @@
+package phraser
+
+// The counted noun, for callers that already speak through this package.
+//
+// The rule itself lives in internal/say, next to the line files that carry the
+// {word} placeholder, because internal/say sits under phraser in the import
+// graph and internal/memory and internal/tasks need it too. These are the same
+// three functions under the names cmd/mavend already calls.
+
+import "github.com/kami/maven/internal/say"
+
+// CountWord picks between the three forms a Russian count needs.
+func CountWord(n int, one, few, many string) string { return say.CountWord(n, one, few, many) }
+
+// Degrees — the noun for a temperature.
+func Degrees(temp float64) string { return say.Degrees(temp) }
+
+// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
+func Devices(n int) string { return say.Devices(n) }
diff --git a/internal/phraser/plural_test.go b/internal/phraser/plural_test.go
new file mode 100644
index 0000000..cfd4fd2
--- /dev/null
+++ b/internal/phraser/plural_test.go
@@ -0,0 +1,32 @@
+package phraser
+
+import "testing"
+
+// The bug the helper exists for: the weather line said "градусов" for every
+// reading, which is wrong for 1-4 and for every number ending in 1-4.
+func TestDegreesAgreeWithTheReading(t *testing.T) {
+ for temp, want := range map[float64]string{
+ 1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов",
+ 0: "градусов", 11: "градусов", 14: "градусов", 21: "градус",
+ 22: "градуса", 25: "градусов", 101: "градус",
+ // Minus does not change the noun, and a reading rounds to the number
+ // she is about to say: -2.4° is "-2 градуса", not "-2 градусов".
+ -1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов",
+ } {
+ if got := Degrees(temp); got != want {
+ t.Errorf("Degrees(%v) = %q, want %q", temp, got, want)
+ }
+ }
+}
+
+func TestDevicesAgreeWithTheCount(t *testing.T) {
+ for n, want := range map[int]string{
+ 1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
+ 11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
+ 25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
+ } {
+ if got := Devices(n); got != want {
+ t.Errorf("Devices(%d) = %q, want %q", n, got, want)
+ }
+ }
+}
diff --git a/internal/phraser/query.go b/internal/phraser/query.go
new file mode 100644
index 0000000..5a3657b
--- /dev/null
+++ b/internal/phraser/query.go
@@ -0,0 +1,171 @@
+package phraser
+
+// The query answers and gaps — what a query source says when it answers from
+// something other than the model, and what it says when it has nothing.
+//
+// Third family on the shared deck (deck.go), after the fallbacks and the
+// acknowledgements. They were literals spread across actions_query.go and
+// netscan.go, where the largest single site held two dozen of them.
+//
+// QueryUnknown is not the phraser's UnknownFallback, even though the two read
+// the same today. Here she looked and found nothing; there she failed to phrase
+// an answer she had. Two files, two entries, so rewording one leaves the other.
+
+import (
+ _ "embed"
+ "log"
+ "math/rand"
+ "sync"
+
+ "github.com/kami/maven/internal/say"
+)
+
+//go:embed query_ru_v1.json
+var queryJSON []byte
+
+// QuerySchemaVersion — this family's own version.
+const QuerySchemaVersion = 1
+
+// The entry keys.
+const (
+ QueryUnknown = "query_unknown"
+ QueryOtherDay = "other_day"
+ QueryPersonalNone = "personal_none"
+ QueryFactWhen = "fact_when"
+ QueryFactValue = "fact_value"
+ QueryFound = "found"
+ QueryPageText = "page_text"
+ QueryPageBlocked = "page_blocked"
+ QueryPageEmpty = "page_empty"
+ QueryFeedsOff = "feeds_off"
+ QueryFeedsNew = "feeds_new"
+ QueryFeedsEmpty = "feeds_empty"
+ QueryFeedsTopic = "feeds_empty_topic"
+ QueryWeatherNow = "weather_now"
+ QueryWeatherOff = "weather_off"
+ QueryWeatherWhere = "weather_nolocation"
+ QueryNetEmpty = "net_empty"
+ QueryNetOff = "net_off"
+ QueryPageOff = "page_off"
+
+ QueryFailPlan = "fail_plan"
+ QueryFailNotes = "fail_notes"
+ QueryFailFeeds = "fail_feeds"
+ QueryFailCalendar = "fail_calendar"
+ QueryFailWeather = "fail_weather"
+ QueryFailAnswer = "fail_answer"
+ QueryFailPage = "fail_page"
+ QueryFailNetscan = "fail_netscan"
+)
+
+var queryKeys = []string{
+ QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
+ QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
+ QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
+ QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff,
+ QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
+ QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
+}
+
+// queryFloor — the literal each key falls back to when the file is unusable.
+// It started as the exact strings that lived in Go before this file existed and
+// now tracks the file's first variant instead, because a floor that keeps the
+// wording review threw out would say it back on the one turn nobody is watching.
+var queryFloor = map[string]string{
+ QueryUnknown: "ничего не нашла.",
+ QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
+ QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
+ QueryFactWhen: "записала это {when}",
+ QueryFactValue: "у меня записано: {key} — {value}",
+ QueryFound: "вот что я нашла: {text}",
+ QueryPageText: "вот что на странице: {text}",
+ QueryPageBlocked: "эта страница закрыта для чтения — сам сайт это запрещает.",
+ QueryPageEmpty: "страница открылась, но читать там нечего.",
+ QueryFeedsOff: "ленты не настроены.",
+ QueryFeedsNew: "вот что нового: {items}",
+ QueryFeedsEmpty: "в лентах пока ничего нового.",
+ QueryFeedsTopic: "по этой теме в лентах пока ничего.",
+ QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.",
+ QueryWeatherOff: "погода не настроена.",
+ QueryWeatherWhere: "для какого города?",
+ QueryNetEmpty: "в сети никого не нашла.",
+ QueryNetOff: "сканирование сети не настроено.",
+ QueryPageOff: "я не читаю страницы — это не настроено.",
+
+ QueryFailPlan: "не получилось собрать план.",
+ QueryFailNotes: "не получилось посмотреть записи.",
+ QueryFailFeeds: "не получилось посмотреть ленты.",
+ QueryFailCalendar: "не получилось проверить календарь.",
+ QueryFailWeather: "не получилось узнать погоду.",
+ QueryFailAnswer: "не получилось найти ответ.",
+ QueryFailPage: "не получилось прочитать страницу.",
+ QueryFailNetscan: "не получилось просканировать сеть.",
+}
+
+// Queries picks a hand-written Russian query line. Safe for concurrent use.
+type Queries struct{ d *say.Deck }
+
+// LoadQueries reads the embedded file. Pass a source to make the picking
+// reproducible in tests; nil seeds from the clock.
+func LoadQueries(src rand.Source) (*Queries, error) {
+ d, err := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
+ if err != nil {
+ return nil, err
+ }
+ // The entries that exist to read something back. A variant without the
+ // placeholder would answer the question by dropping the answer.
+ for _, req := range []struct{ key, ph string }{
+ {QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"},
+ {QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"},
+ {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"},
+ {QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"},
+ } {
+ if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
+ return nil, err
+ }
+ }
+ return &Queries{d: d}, nil
+}
+
+// deck reads through a nil *Queries, which is the unloadable-file case.
+func (q *Queries) deck() *say.Deck {
+ if q == nil {
+ return say.FloorDeck(queryFloor)
+ }
+ return q.d
+}
+
+// Say returns one line for key, with the values filled into the frame.
+func (q *Queries) Say(key string, vars map[string]string) string {
+ return q.deck().Text(key, vars)
+}
+
+// Variants returns every line the file can produce, for the persona scorer.
+func (q *Queries) Variants() []string { return q.deck().Variants() }
+
+var (
+ queryOnce sync.Once
+ queries *Queries
+)
+
+// DefaultQueries returns the shared instance, loading it on first use. A broken
+// file logs once and leaves a nil *Queries, which still answers from queryFloor.
+func DefaultQueries() *Queries {
+ queryOnce.Do(func() {
+ q, err := LoadQueries(nil)
+ if err != nil {
+ log.Printf("phraser: query lines unavailable, using the built-in ones: %v", err)
+ return
+ }
+ queries = q
+ })
+ return queries
+}
+
+// Q — one query line, the way every caller says it.
+func Q(key string, vars map[string]string) string { return DefaultQueries().Say(key, vars) }
+
+// IsQ reports whether text is a line key could have produced, for the tests.
+func IsQ(key string, vars map[string]string, text string) bool {
+ return DefaultQueries().deck().Matches(key, vars, text)
+}
diff --git a/internal/phraser/query_lines_test.go b/internal/phraser/query_lines_test.go
new file mode 100644
index 0000000..4ccde68
--- /dev/null
+++ b/internal/phraser/query_lines_test.go
@@ -0,0 +1,107 @@
+package phraser
+
+import (
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+func TestQueriesLoad(t *testing.T) {
+ q, err := LoadQueries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadQueries: %v", err)
+ }
+ for _, k := range queryKeys {
+ if got := q.Say(k, nil); got == "" {
+ t.Errorf("%s says nothing", k)
+ }
+ }
+}
+
+// The bug: net_empty carried {tail} in every variant, and a scan that finished
+// the whole range has no caveat to put there. Whatever the file says, an answer
+// he can hear has to come out — never braces, never nothing.
+func TestNetEmptySaysSomethingWithNoTail(t *testing.T) {
+ q, err := LoadQueries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadQueries: %v", err)
+ }
+ for _, vars := range []map[string]string{nil, {"tail": ""}} {
+ for i := 0; i < 20; i++ {
+ got := q.Say(QueryNetEmpty, vars)
+ if got == "" || strings.ContainsAny(got, "{}") {
+ t.Fatalf("net_empty with vars %v said %q", vars, got)
+ }
+ }
+ }
+}
+
+// The other half: a caveat he was given is not dropped for a shorter wording.
+func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) {
+ q, err := LoadQueries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadQueries: %v", err)
+ }
+ const tail = ", но успела посмотреть не всю сеть"
+ for i := 0; i < 20; i++ {
+ if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) {
+ t.Fatalf("net_empty dropped the tail: %q", got)
+ }
+ }
+}
+
+// query_unknown means she looked and found nothing. The phraser's fallback
+// means she failed to phrase an answer she had. Two causes, two sentences, or
+// the distinction the two files exist for is unobservable from the outside.
+func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
+ q, err := LoadQueries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadQueries: %v", err)
+ }
+ f, err := LoadFallbacks(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadFallbacks: %v", err)
+ }
+ failures := map[string]bool{}
+ for _, v := range f.Variants() {
+ failures[v] = true
+ }
+ for _, v := range q.d.VariantsOf(QueryUnknown) {
+ if failures[v] {
+ t.Errorf("query_unknown variant %q is also a phrasing failure line", v)
+ }
+ }
+}
+
+// The weather line splits the count into a number and a noun, so a variant that
+// says the temperature without {word} is the hardcoded "градусов" coming back.
+func TestWeatherLineCountsWithTheHelper(t *testing.T) {
+ q, err := LoadQueries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadQueries: %v", err)
+ }
+ for _, v := range q.d.VariantsOf(QueryWeatherNow) {
+ if strings.Contains(v, "градус") {
+ t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
+ }
+ }
+ got := q.Say(QueryWeatherNow, map[string]string{
+ "location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно",
+ })
+ if !strings.Contains(got, "1 градус,") {
+ t.Errorf("weather_now said %q, want the singular noun", got)
+ }
+}
+
+// No line spoken to him names a config key. She asks instead.
+func TestNoQueryLineRecitesAConfigPath(t *testing.T) {
+ q, err := LoadQueries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("LoadQueries: %v", err)
+ }
+ for _, v := range q.Variants() {
+ if strings.Contains(v, "voice.") || strings.Contains(v, "_location") {
+ t.Errorf("variant %q says a config path out loud", v)
+ }
+ }
+}
diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json
new file mode 100644
index 0000000..463f2be
--- /dev/null
+++ b/internal/phraser/query_ru_v1.json
@@ -0,0 +1,115 @@
+{
+ "schema_version": 1,
+ "name": "russian query answers and gaps v1",
+ "notes": [
+ "What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.",
+ "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
+ "A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.",
+ "query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.",
+ "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.",
+ "A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.",
+ "{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.",
+ "fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day."
+ ],
+ "entries": {
+ "query_unknown": {
+ "variants": ["ничего не нашла.", "искала — не нашла."]
+ },
+ "other_day": {
+ "fixed": true,
+ "variants": ["про другой день так не отвечу — спроси целиком."]
+ },
+ "personal_none": {
+ "fixed": true,
+ "variants": ["не знаю — не нашла у тебя такой записи."]
+ },
+ "fact_when": {
+ "fixed": true,
+ "variants": ["записала это {when}"]
+ },
+ "fact_value": {
+ "variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
+ },
+ "found": {
+ "variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
+ },
+ "page_text": {
+ "variants": ["вот что на странице: {text}", "на странице вот это: {text}"]
+ },
+ "page_blocked": {
+ "fixed": true,
+ "variants": ["эта страница закрыта для чтения — сам сайт это запрещает."]
+ },
+ "page_empty": {
+ "fixed": true,
+ "variants": ["страница открылась, но читать там нечего."]
+ },
+ "feeds_off": {
+ "fixed": true,
+ "variants": ["ленты не настроены."]
+ },
+ "feeds_new": {
+ "variants": ["вот что нового: {items}", "нового вот что: {items}"]
+ },
+ "feeds_empty": {
+ "variants": ["в лентах пока ничего нового.", "в лентах тихо."]
+ },
+ "feeds_empty_topic": {
+ "variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
+ },
+ "weather_now": {
+ "variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
+ },
+ "weather_off": {
+ "fixed": true,
+ "variants": ["погода не настроена."]
+ },
+ "weather_nolocation": {
+ "fixed": true,
+ "variants": ["для какого города?"]
+ },
+ "net_off": {
+ "fixed": true,
+ "variants": ["сканирование сети не настроено."]
+ },
+ "page_off": {
+ "fixed": true,
+ "variants": ["я не читаю страницы — это не настроено."]
+ },
+ "net_empty": {
+ "variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
+ },
+ "fail_plan": {
+ "fixed": true,
+ "variants": ["не получилось собрать план."]
+ },
+ "fail_notes": {
+ "fixed": true,
+ "variants": ["не получилось посмотреть записи."]
+ },
+ "fail_feeds": {
+ "fixed": true,
+ "variants": ["не получилось посмотреть ленты."]
+ },
+ "fail_calendar": {
+ "fixed": true,
+ "variants": ["не получилось проверить календарь."]
+ },
+ "fail_weather": {
+ "fixed": true,
+ "variants": ["не получилось узнать погоду."]
+ },
+ "fail_answer": {
+ "fixed": true,
+ "variants": ["не получилось найти ответ."]
+ },
+ "fail_page": {
+ "fixed": true,
+ "variants": ["не получилось прочитать страницу."]
+ },
+ "fail_netscan": {
+ "fixed": true,
+ "variants": ["не получилось просканировать сеть."]
+ }
+ }
+}
diff --git a/internal/phraser/swap_test.go b/internal/phraser/swap_test.go
index aea6f39..01aa8e0 100644
--- a/internal/phraser/swap_test.go
+++ b/internal/phraser/swap_test.go
@@ -215,10 +215,12 @@ func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) {
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
t.Errorf("acquire error = %v; want ErrNoBackend", aerr)
}
- // Phrasing degrades to its fallback instead of failing the turn.
+ // Phrasing degrades to its fallback instead of failing the turn, and since
+ // Vikunja #397 it reports the error next to that fallback so a measuring
+ // caller can tell "no model" from "bad phrasing".
got, err := p.PhraseChat(context.Background(), "привет", nil)
- if err != nil {
- t.Fatalf("PhraseChat after a total failure returned an error: %v", err)
+ if !errors.Is(err, ErrNoBackend) {
+ t.Errorf("PhraseChat error = %v; want ErrNoBackend alongside the fallback", err)
}
if got == "" {
t.Error("PhraseChat returned empty; the fallback must still say something")
diff --git a/internal/router/agenda_test.go b/internal/router/agenda_test.go
index 9789c90..19ad16f 100644
--- a/internal/router/agenda_test.go
+++ b/internal/router/agenda_test.go
@@ -76,6 +76,29 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
}
}
+// The two shapes that carried no question mark and no interrogative, so the
+// model saw them first and called them facts (Vikunja #498).
+func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
+ r := agendaRouter(t)
+ r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
+ for _, u := range []string{
+ "что дальше?",
+ "и что там дальше",
+ "what's next?",
+ "расскажи про битву при Ватерлоо",
+ "объясни как работает дизель",
+ "опиши Ватерлоо",
+ } {
+ d, err := r.Route(context.Background(), u, refNow())
+ if err != nil {
+ t.Fatalf("%q: %v", u, err)
+ }
+ if d.Intent != IntentQuery || d.Stage != 0 {
+ t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage)
+ }
+ }
+}
+
// The tomorrow form and the bare event noun. Both were measured answering
// "пока не умею" on the deployed daemon, 02-08-2026, while the same question
// about today worked — the first rule set needed "у меня" or a calendar noun
@@ -101,6 +124,20 @@ func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) {
}
}
+// A narrative verb next to a capture verb is him asking for a note. Stage 0
+// declines and the extractor gets its turn.
+func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
+ r := agendaRouter(t)
+ r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
+ d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if d.Stage == 0 && d.Intent == IntentQuery {
+ t.Errorf("stage 0 claimed a capture: %+v", d)
+ }
+}
+
// The two new rules are narrow on purpose. A world question that opens with
// "когда" is not an agenda question, and telling her about a plan is not
// asking about one.
diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go
index 9adf2c2..89dd17c 100644
--- a/internal/router/eval/eval_test.go
+++ b/internal/router/eval/eval_test.go
@@ -236,6 +236,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
// Same order as buildRouter (voicewire.go). The fixture is only worth
// anything while its grammar set is the daemon's grammar set.
grammars = append(grammars, router.AgendaQueryGrammars()...)
+ grammars = append(grammars, router.NarrativeQueryGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
return router.New(router.Config{
Grammars: grammars,
diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json
index 44b90e8..823fe37 100644
--- a/internal/router/eval/ru_routing_v1.json
+++ b/internal/router/eval/ru_routing_v1.json
@@ -30,6 +30,8 @@
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
+ { "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
+ { "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
diff --git a/internal/router/list.go b/internal/router/list.go
new file mode 100644
index 0000000..499775a
--- /dev/null
+++ b/internal/router/list.go
@@ -0,0 +1,247 @@
+package router
+
+import (
+ "regexp"
+ "strings"
+)
+
+// Standing lists, matched deterministically (Vikunja #453).
+//
+// Same posture as task capture in task.go and for the same reason: the intent
+// enum is a contract shared with the relabelling prompt, so a list is not an
+// eighth intent. It is a note-shaped or query-shaped utterance carrying an
+// explicit marker, and the marker is a lookup.
+//
+// The markers are deliberately explicit. "молоко закончилось" is an
+// observation about the world and belongs in a note; only an instruction to
+// put something on a list puts it there.
+
+// listStems — the lists he can name, by the stem every case form shares.
+// Russian declines the tag ("список покупок", "в покупки", "в покупках"), so
+// matching a stem is what makes those the same list.
+var listStems = []struct{ stem, list string }{
+ {"покуп", "покупки"},
+ {"продукт", "покупки"},
+ {"магазин", "покупки"},
+ {"аптек", "аптека"},
+ {"хозяйств", "хозяйство"},
+ {"shopping", "покупки"},
+ {"groceries", "покупки"},
+ {"pharmacy", "аптека"},
+}
+
+// listCapturePrefixes — an instruction to add to a list. Longest match wins.
+var listCapturePrefixes = []string{
+ "добавь в список",
+ "добавь в покупки",
+ "добавь к покупкам",
+ "запиши в список",
+ "внеси в список",
+ "положи в список",
+ "в список покупок",
+ "add to the list",
+ "add to my list",
+ "add to the shopping list",
+ "put on the list",
+}
+
+// listQueryPrefixes — an ask to read a list back.
+var listQueryPrefixes = []string{
+ "что в списке",
+ "что в покупках",
+ "что мне купить",
+ "что нужно купить",
+ "что надо купить",
+ "покажи список",
+ "прочитай список",
+ "список покупок",
+ "мой список",
+ "what is on the list",
+ "what's on the list",
+ "read me the list",
+ "show me the list",
+ "shopping list",
+}
+
+// listClearPhrases — the whole list is got. One sentence, one turn.
+var listClearPhrases = []string{
+ "всё купил",
+ "все купил",
+ "всё взял",
+ "все взял",
+ "очисти список",
+ "очисти покупки",
+ "список пустой",
+ "got everything",
+ "clear the list",
+}
+
+// listRemovePrefixes — one item off the list.
+var listRemovePrefixes = []string{
+ "вычеркни",
+ "убери из списка",
+ "убери со списка",
+ "купил",
+ "взял",
+ "cross off",
+ "remove from the list",
+}
+
+// listTrimCut — punctuation and connectives to strip off a parsed remainder.
+const listTrimCut = " .,;:!?—-"
+
+// ListCapture — a parsed list instruction: which list, and the item.
+type ListCapture struct {
+ List string
+ Item string
+}
+
+// ParseListCapture reports whether an utterance puts something on a list, and
+// returns the list tag and the item. A marker with nothing usable after it is
+// not a capture: there is no item in "добавь в список покупок".
+func ParseListCapture(text string) (ListCapture, bool) {
+ rest, ok := afterLongestPrefix(text, listCapturePrefixes)
+ if !ok {
+ return ListCapture{}, false
+ }
+ list, rest := takeListTag(rest)
+ rest = strings.Trim(rest, listTrimCut)
+ if rest == "" {
+ return ListCapture{}, false
+ }
+ return ListCapture{List: list, Item: rest}, true
+}
+
+// ParseListQuery reports whether an utterance asks for a list, and which one.
+func ParseListQuery(text string) (string, bool) {
+ rest, ok := afterLongestPrefix(text, listQueryPrefixes)
+ if !ok {
+ return "", false
+ }
+ list, _ := takeListTag(rest)
+ return list, true
+}
+
+// ParseListClear reports whether an utterance crosses off a whole list.
+func ParseListClear(text string) (string, bool) {
+ lower := strings.ToLower(strings.Trim(strings.TrimSpace(text), listTrimCut))
+ for _, p := range listClearPhrases {
+ if lower == p || strings.HasPrefix(lower, p+" ") {
+ list, _ := takeListTag(strings.TrimSpace(lower[len(p):]))
+ return list, true
+ }
+ }
+ return "", false
+}
+
+// ParseListRemove reports whether an utterance takes one named item off a
+// list, and returns the list and the item.
+//
+// The item is required. "купил" on its own is him reporting he shopped, which
+// ParseListClear reads first, and it must not fall through to here and remove
+// nothing while sounding like it did.
+func ParseListRemove(text string) (ListCapture, bool) {
+ rest, ok := afterLongestPrefix(text, listRemovePrefixes)
+ if !ok {
+ return ListCapture{}, false
+ }
+ list, rest := takeListTag(rest)
+ rest = strings.Trim(rest, listTrimCut)
+ for _, lead := range []string{"из списка ", "со списка ", "из ", "from the list "} {
+ rest = strings.TrimPrefix(rest, lead)
+ }
+ rest = strings.Trim(rest, listTrimCut)
+ if rest == "" {
+ return ListCapture{}, false
+ }
+ return ListCapture{List: list, Item: rest}, true
+}
+
+// afterLongestPrefix matches the longest prefix in the table and returns what
+// follows it, trimmed. Lowercasing does not change the byte length of Russian
+// or English letters, so the index carries over to the original text.
+func afterLongestPrefix(text string, prefixes []string) (string, bool) {
+ trimmed := strings.TrimSpace(text)
+ lower := strings.ToLower(trimmed)
+ best := ""
+ for _, p := range prefixes {
+ if strings.HasPrefix(lower, p) && len(p) > len(best) {
+ best = p
+ }
+ }
+ if best == "" {
+ return "", false
+ }
+ return strings.Trim(trimmed[len(best):], listTrimCut), true
+}
+
+// takeListTag reads a list name off the front of the remainder and returns the
+// list plus what is left. A remainder naming no list is the default list, and
+// nothing is consumed — "добавь в список молоко" names no list and the item is
+// молоко.
+func takeListTag(rest string) (string, string) {
+ fields := strings.Fields(rest)
+ if len(fields) == 0 {
+ return "покупки", ""
+ }
+ head := strings.ToLower(strings.Trim(fields[0], listTrimCut))
+ // "в список покупок" leaves "покупок"; "в списке" leaves nothing.
+ if head == "список" || head == "списке" || head == "списка" || head == "list" {
+ fields = fields[1:]
+ if len(fields) == 0 {
+ return "покупки", ""
+ }
+ head = strings.ToLower(strings.Trim(fields[0], listTrimCut))
+ }
+ for _, s := range listStems {
+ if strings.HasPrefix(head, s.stem) {
+ return s.list, strings.Join(fields[1:], " ")
+ }
+ }
+ return "покупки", strings.Join(fields, " ")
+}
+
+// ListGrammars — stage 0 for the list (Vikunja #453).
+//
+// Both patterns match everything and the Build functions are the real filter,
+// the shape the wake-word act grammar already uses: the parsers above are the
+// definition of a list utterance and duplicating them as regexps would give
+// two answers to one question.
+//
+// Why stage 0 at all: an add and a read-back are deterministic and cheap, and
+// leaving them to the model means "добавь в список покупок молоко" lands as an
+// act or a fact on the turns the model has a bad day. The action handlers still
+// re-parse, so a list turn that arrives by any other route still works.
+func ListGrammars() []Grammar {
+ anything := regexp.MustCompile(`(?s)^(.*)$`)
+ return []Grammar{
+ {
+ Name: "list-query",
+ Pattern: anything,
+ Build: func(m []string) (Decision, bool) {
+ if _, ok := ParseListQuery(m[1]); !ok {
+ return Decision{}, false
+ }
+ return Decision{Stage: 0, Intent: IntentQuery, Confidence: 1.0}, true
+ },
+ },
+ {
+ Name: "list-capture",
+ Pattern: anything,
+ Build: func(m []string) (Decision, bool) {
+ text := m[1]
+ _, add := ParseListCapture(text)
+ _, clear := ParseListClear(text)
+ if !add && !clear {
+ return Decision{}, false
+ }
+ return Decision{
+ Stage: 0,
+ Intent: IntentNote,
+ Confidence: 1.0,
+ Slots: Slots{Text: strings.TrimSpace(text)},
+ }, true
+ },
+ },
+ }
+}
diff --git a/internal/router/list_test.go b/internal/router/list_test.go
new file mode 100644
index 0000000..5e0bb40
--- /dev/null
+++ b/internal/router/list_test.go
@@ -0,0 +1,89 @@
+package router
+
+import "testing"
+
+func TestParseListCaptureReadsListAndItem(t *testing.T) {
+ cases := []struct {
+ utterance string
+ list string
+ item string
+ }{
+ {"добавь в список покупок молоко", "покупки", "молоко"},
+ {"добавь в список молоко", "покупки", "молоко"},
+ {"Добавь в покупки хлеб и яйца", "покупки", "хлеб и яйца"},
+ {"запиши в список аптеки бинт", "аптека", "бинт"},
+ {"добавь в список хозяйства лампочки.", "хозяйство", "лампочки"},
+ {"add to the shopping list milk", "покупки", "milk"},
+ }
+ for _, c := range cases {
+ got, ok := ParseListCapture(c.utterance)
+ if !ok {
+ t.Errorf("ParseListCapture(%q) did not claim it", c.utterance)
+ continue
+ }
+ if got.List != c.list || got.Item != c.item {
+ t.Errorf("ParseListCapture(%q) = %+v; want list %q item %q", c.utterance, got, c.list, c.item)
+ }
+ }
+}
+
+// A marker with no item is not a capture, and an utterance that only mentions
+// shopping is not one either.
+func TestParseListCapturePasses(t *testing.T) {
+ for _, u := range []string{
+ "добавь в список покупок",
+ "добавь в список",
+ "молоко закончилось",
+ "надо бы съездить в магазин",
+ "добавь в задачи купить молоко",
+ } {
+ if got, ok := ParseListCapture(u); ok {
+ t.Errorf("ParseListCapture(%q) claimed it as %+v", u, got)
+ }
+ }
+}
+
+func TestParseListQueryNamesTheList(t *testing.T) {
+ cases := []struct{ utterance, list string }{
+ {"что в списке покупок?", "покупки"},
+ {"что в списке", "покупки"},
+ {"что мне купить", "покупки"},
+ {"покажи список аптеки", "аптека"},
+ {"what's on the list", "покупки"},
+ }
+ for _, c := range cases {
+ list, ok := ParseListQuery(c.utterance)
+ if !ok {
+ t.Errorf("ParseListQuery(%q) did not claim it", c.utterance)
+ continue
+ }
+ if list != c.list {
+ t.Errorf("ParseListQuery(%q) = %q; want %q", c.utterance, list, c.list)
+ }
+ }
+ if _, ok := ParseListQuery("какие у меня задачи"); ok {
+ t.Error("ParseListQuery claimed a task question")
+ }
+}
+
+func TestParseListClearAndRemove(t *testing.T) {
+ if list, ok := ParseListClear("всё купил"); !ok || list != "покупки" {
+ t.Errorf("ParseListClear = %q, %v; want покупки, true", list, ok)
+ }
+ if list, ok := ParseListClear("очисти список аптеки"); !ok || list != "аптека" {
+ t.Errorf("ParseListClear = %q, %v; want аптека, true", list, ok)
+ }
+ if _, ok := ParseListClear("купил молоко"); ok {
+ t.Error("ParseListClear claimed a single item")
+ }
+ got, ok := ParseListRemove("вычеркни молоко")
+ if !ok || got.Item != "молоко" || got.List != "покупки" {
+ t.Errorf("ParseListRemove = %+v, %v; want молоко on покупки", got, ok)
+ }
+ if got, ok := ParseListRemove("убери из списка аптеки бинт"); !ok || got.Item != "бинт" || got.List != "аптека" {
+ t.Errorf("ParseListRemove = %+v, %v; want бинт on аптека", got, ok)
+ }
+ if _, ok := ParseListRemove("вычеркни"); ok {
+ t.Error("ParseListRemove claimed a marker with no item")
+ }
+}
diff --git a/internal/router/stage0.go b/internal/router/stage0.go
index eafc89a..fac9fb6 100644
--- a/internal/router/stage0.go
+++ b/internal/router/stage0.go
@@ -208,6 +208,73 @@ func AgendaQueryGrammars() []Grammar {
}
}
+// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
+// carry no question mark and no interrogative, and so reached the resident
+// model with nothing deterministic in front of them (Vikunja #498).
+//
+// Both were routed IntentFact by the model. The fact gate catches the write and
+// re-runs the turn as a query, so nothing breaks today; what they cost is a full
+// model round trip to reach a decision two patterns can make offline, and a
+// wrong row on the routing fixture.
+//
+// Wired after the agenda grammars, which is where their overlap resolves:
+// "расскажи, что у меня сегодня" is claimed here as a query either way.
+func NarrativeQueryGrammars() []Grammar {
+ return []Grammar{
+ {
+ // "что дальше?" — the rest of the day. IsRestOfDayQuery already
+ // recognises it downstream in the query chain, but that runs after
+ // the routing decision, and the routing decision was fact.
+ Name: "rest-of-day-query",
+ Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`),
+ Build: agendaQueryBuild,
+ },
+ {
+ // "расскажи про X" — a world question phrased as an instruction.
+ // The lexicon is narrativeRequests, already written for the
+ // question-shaped test in question.go.
+ //
+ // Anchored at the start: "запиши что мне рассказали" is a capture,
+ // and a narrative verb buried mid-utterance is not the shape.
+ Name: "narrative-query",
+ Pattern: regexp.MustCompile(`(?i)^\s*(расскажи|объясни|опиши|перечисли|tell|explain|describe)(\s+(.*))?$`),
+ Build: narrativeQueryBuild,
+ },
+ }
+}
+
+// entertainmentNouns — what "расскажи" asks for when it is not asking for
+// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make
+// something up, which is the one case where inventing is the right answer
+// (fixture ru-chat-003).
+var entertainmentNouns = []string{
+ "анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки",
+ "joke", "jokes", "story",
+}
+
+// narrativeQueryBuild — the narrative shape is a query unless he also said one
+// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him
+// asking for a note, and stage 0 must not take either off the cascade.
+func narrativeQueryBuild(m []string) (Decision, bool) {
+ rest := ""
+ if len(m) > 3 {
+ rest = m[3]
+ }
+ for _, t := range planTokens(rest) {
+ for _, v := range captureVerbs {
+ if t == v {
+ return Decision{}, false
+ }
+ }
+ for _, v := range entertainmentNouns {
+ if t == v {
+ return Decision{}, false
+ }
+ }
+ }
+ return agendaQueryBuild(m)
+}
+
// dayWordPattern — the day words an agenda question can name. Weekdays appear
// in the accusative and prepositional forms the questions actually use ("в
// среду", "на среде"), which is why the stems carry an inflection tail rather
diff --git a/internal/say/deck.go b/internal/say/deck.go
new file mode 100644
index 0000000..7d88a67
--- /dev/null
+++ b/internal/say/deck.go
@@ -0,0 +1,265 @@
+package say
+
+// Package say holds the mechanics every family of hand-written Russian lines
+// shares. The families themselves live next to the code that speaks them.
+//
+// Deck — the mechanics every family of hand-written Russian lines shares.
+//
+// A family is one embedded JSON file: schema-versioned, several variants per
+// entry, never the same variant twice running, and a hard floor of Go literals
+// under it so a broken file cannot take her words away. fallbacks.go was the
+// first family (Vikunja #501) and acks.go the second, at which point copying
+// eighty lines of loader per family stopped being defensible.
+//
+// What stays per family: the file, the keys, the floor literals, the accessor
+// names, and any validation only that family can state.
+
+import (
+ "encoding/json"
+ "fmt"
+ "math/rand"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Entry — one line she can say, in as many wordings as the file gives.
+type Entry struct {
+ // Fixed — one variant, never picked between. For wording that must not
+ // drift from turn to turn, like a phrase naming one specific gap.
+ Fixed bool `json:"fixed"`
+ Variants []string `json:"variants"`
+}
+
+type file struct {
+ SchemaVersion int `json:"schema_version"`
+ Name string `json:"name"`
+ Notes []string `json:"notes"`
+ Entries map[string]Entry `json:"entries"`
+}
+
+// Deck picks a line. Safe for concurrent use. A Deck with no entries answers
+// from the floor, which is what an unloadable file leaves behind (FloorDeck).
+type Deck struct {
+ mu sync.Mutex
+ rnd *rand.Rand
+ last map[string]string
+ file file
+ keys []string
+ floor map[string]string
+}
+
+// loadDeck parses raw, checks the version and every required key, and seeds the
+// picker. Pass a source to make the picking reproducible in tests; nil seeds
+// from the clock.
+func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) {
+ var f file
+ if err := json.Unmarshal(raw, &f); err != nil {
+ return nil, fmt.Errorf("parse: %w", err)
+ }
+ if f.SchemaVersion != version {
+ return nil, fmt.Errorf("schema_version %d, want %d", f.SchemaVersion, version)
+ }
+ for _, k := range keys {
+ e, ok := f.Entries[k]
+ if !ok || len(e.Variants) == 0 {
+ return nil, fmt.Errorf("entry %q is missing or empty", k)
+ }
+ if e.Fixed && len(e.Variants) != 1 {
+ return nil, fmt.Errorf("entry %q is fixed but has %d variants", k, len(e.Variants))
+ }
+ }
+ if src == nil {
+ src = rand.NewSource(time.Now().UnixNano())
+ }
+ return &Deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
+}
+
+// requirePlaceholder fails the load when a variant of key does not use ph. For
+// an entry whose whole job is to read something back, a variant without the
+// placeholder silently drops it.
+func (d *Deck) RequirePlaceholder(key, ph string) error {
+ for _, v := range d.file.Entries[key].Variants {
+ if !strings.Contains(v, ph) {
+ return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
+ }
+ }
+ return nil
+}
+
+// text returns one variant for key with the placeholders filled in. A nil
+// receiver answers from the floor, so no caller checks whether the file loaded.
+func (d *Deck) Text(key string, vars map[string]string) string {
+ tmpl := ""
+ if d != nil {
+ if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
+ tmpl = d.pick(key, fillable(e.Variants, vars))
+ }
+ }
+ if tmpl == "" {
+ tmpl = floorOf(d, key)
+ }
+ return fill(tmpl, vars)
+}
+
+// matches reports whether text is a line key could have produced. A caller that
+// has to recognise one of these lines cannot compare against a literal any more.
+func (d *Deck) Matches(key string, vars map[string]string, text string) bool {
+ if fill(floorOf(d, key), vars) == text {
+ return true
+ }
+ if d == nil {
+ return false
+ }
+ for _, v := range d.file.Entries[key].Variants {
+ if fill(v, vars) == text {
+ return true
+ }
+ }
+ return false
+}
+
+// variants returns every line the file can produce, in key order, for the
+// persona scorer. Stable order so a failure names the same variant twice.
+func (d *Deck) Variants() []string {
+ if d == nil {
+ return nil
+ }
+ var out []string
+ for _, k := range d.keys {
+ out = append(out, d.file.Entries[k].Variants...)
+ }
+ return out
+}
+
+// VariantsOf returns the wordings the file gives for one key, for a test that
+// has something to say about every one of them.
+func (d *Deck) VariantsOf(key string) []string {
+ if d == nil {
+ return nil
+ }
+ return d.file.Entries[key].Variants
+}
+
+// UnfixedSingles lists the keys with exactly one variant that are not marked
+// fixed. Nothing breaks on one — the picker has nothing to pick either way — but
+// the flag is what a reader goes by, and parallel entries disagreeing about it
+// is how a family stops being readable. Load already rejects the other half of
+// the rule, fixed with more than one variant, so this is the pair to it.
+func (d *Deck) UnfixedSingles() []string {
+ if d == nil {
+ return nil
+ }
+ var out []string
+ for _, k := range d.keys {
+ if e := d.file.Entries[k]; len(e.Variants) == 1 && !e.Fixed {
+ out = append(out, k)
+ }
+ }
+ return out
+}
+
+// fillable narrows variants to the ones this call can actually say, which is
+// the rule an optional placeholder needs: a caller with nothing to put in
+// {tail} must not be handed a variant that has one. Two passes, because both
+// halves matter. The first keeps only variants whose every placeholder has a
+// non-empty value, so an absent optional never reaches him as braces. The
+// second prefers, among those, the variants using the most of what the caller
+// supplied, so a caveat he was given is not dropped for a shorter wording.
+// Nothing fillable leaves the list alone, and the unfilled placeholder shows
+// up in the answer rather than turning it into silence.
+func fillable(variants []string, vars map[string]string) []string {
+ if len(variants) < 2 {
+ return variants
+ }
+ best, bestUsed := make([]string, 0, len(variants)), -1
+ for _, v := range variants {
+ used := 0
+ ok := true
+ for _, ph := range placeholders(v) {
+ if vars[ph] == "" {
+ ok = false
+ break
+ }
+ used++
+ }
+ if !ok || used < bestUsed {
+ continue
+ }
+ if used > bestUsed {
+ best, bestUsed = best[:0], used
+ }
+ best = append(best, v)
+ }
+ if len(best) == 0 {
+ return variants
+ }
+ return best
+}
+
+// placeholders lists the {name}s in tmpl, in order.
+func placeholders(tmpl string) []string {
+ var out []string
+ for {
+ i := strings.IndexByte(tmpl, '{')
+ if i < 0 {
+ return out
+ }
+ j := strings.IndexByte(tmpl[i:], '}')
+ if j < 0 {
+ return out
+ }
+ out = append(out, tmpl[i+1:i+j])
+ tmpl = tmpl[i+j+1:]
+ }
+}
+
+// pick chooses at random, skipping whatever this entry said last time.
+func (d *Deck) pick(key string, variants []string) string {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+
+ choices := variants
+ if len(choices) > 1 {
+ fresh := make([]string, 0, len(choices))
+ for _, v := range choices {
+ if v != d.last[key] {
+ fresh = append(fresh, v)
+ }
+ }
+ if len(fresh) > 0 {
+ choices = fresh
+ }
+ }
+ got := choices[d.rnd.Intn(len(choices))]
+ d.last[key] = got
+ return got
+}
+
+// floorOf reads the Go literal behind key. Every deck carries its own family's
+// map, including the floor-only deck an unloadable file leaves behind, so no
+// lookup ever crosses families. It used to go through one global map keyed by
+// bare entry name, which two families both calling an entry query_unknown
+// silently shared: whichever registered last answered for both (Vikunja #521).
+func floorOf(d *Deck, key string) string {
+ if d == nil {
+ return ""
+ }
+ return d.floor[key]
+}
+
+// FloorDeck — the deck a family falls back to when its file will not load. It
+// has no entries, so every read drops through to the floor literals, and it is
+// a real *Deck so no accessor has to know which case it is in.
+func FloorDeck(floor map[string]string) *Deck {
+ return &Deck{last: map[string]string{}, floor: floor}
+}
+
+// fill substitutes {name} for each var. A placeholder with no value is left
+// alone rather than blanked, so a missing value is visible instead of silent.
+func fill(tmpl string, vars map[string]string) string {
+ for k, v := range vars {
+ tmpl = strings.ReplaceAll(tmpl, "{"+k+"}", v)
+ }
+ return tmpl
+}
diff --git a/internal/say/plural.go b/internal/say/plural.go
new file mode 100644
index 0000000..3014447
--- /dev/null
+++ b/internal/say/plural.go
@@ -0,0 +1,55 @@
+package say
+
+// The counted noun, in the form the number in front of it demands.
+//
+// Russian inflects a noun after a numeral, and the form depends on the last two
+// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
+// A line file cannot spell that out, so a count in a template splits into two
+// placeholders — the number, and {word} filled from here.
+//
+// The rule lived once as hostWord in cmd/mavend/netscan.go and once as
+// pluralDaysRU in internal/memory, which meant the weather line said "градусов"
+// for every temperature and the task list said "дн." — a written abbreviation
+// read aloud. One helper, every count site (Vikunja #521).
+
+import "math"
+
+// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
+// for 0, 5-20 and anything ending in those. A negative count reads its own
+// magnitude, since minus does not change the noun: -2 градуса.
+func CountWord(n int, one, few, many string) string {
+ if n < 0 {
+ n = -n
+ }
+ if n%100 >= 11 && n%100 <= 14 {
+ return many
+ }
+ switch n % 10 {
+ case 1:
+ return one
+ case 2, 3, 4:
+ return few
+ default:
+ return many
+ }
+}
+
+// Degrees — the noun for a temperature. Takes the reading as it arrives from a
+// weather provider and counts by the whole degrees she is about to say, so the
+// noun agrees with the number in the same sentence rather than with the reading
+// behind it.
+func Degrees(temp float64) string {
+ return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
+}
+
+// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
+func Devices(n int) string {
+ return CountWord(n, "устройство", "устройства", "устройств")
+}
+
+// Days — the noun for a stretch of days. This is what replaces «дн.» in the
+// overdue and due-soon reasons: an abbreviation is written shorthand, and every
+// one of these lines is spoken.
+func Days(n int) string {
+ return CountWord(n, "день", "дня", "дней")
+}
diff --git a/internal/say/plural_test.go b/internal/say/plural_test.go
new file mode 100644
index 0000000..00a6f94
--- /dev/null
+++ b/internal/say/plural_test.go
@@ -0,0 +1,16 @@
+package say
+
+import "testing"
+
+// The day forms, kept from internal/memory when the helper moved: 21 takes the
+// singular and 11 does not, which is the whole reason this is not n == 1.
+func TestDays(t *testing.T) {
+ for _, c := range []struct {
+ n int
+ want string
+ }{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
+ if got := Days(c.n); got != c.want {
+ t.Errorf("Days(%d) = %q, want %q", c.n, got, c.want)
+ }
+ }
+}
diff --git a/internal/say/summary.go b/internal/say/summary.go
new file mode 100644
index 0000000..e4f6791
--- /dev/null
+++ b/internal/say/summary.go
@@ -0,0 +1,192 @@
+package say
+
+// The summary sentences — what she says around aggregated data: the morning
+// plan, the ranked task list, and the habits read back out of behaviour records.
+//
+// Fifth family on the deck, and the first one outside internal/phraser. It
+// lives here because its three callers — internal/morning, internal/tasks and
+// internal/memory — sit under phraser in the import graph and cannot reach it.
+//
+// The "I have not seen enough yet" sentences are the load-bearing ones. Three
+// days of taps and a year of them produce the same "обычно ты ...", and only one
+// of those is worth believing, so the empty cases say she has not seen a
+// pattern rather than that he has none.
+
+import (
+ _ "embed"
+ "log"
+ "math/rand"
+ "sync"
+)
+
+//go:embed summary_ru_v1.json
+var summaryJSON []byte
+
+// SummarySchemaVersion — this family's own version.
+const SummarySchemaVersion = 1
+
+// The entry keys.
+const (
+ PlanRestEmpty = "plan_rest_empty"
+ PlanDayEmpty = "plan_day_empty"
+ PlanDay = "plan_day"
+ PlanUncertain = "plan_uncertain"
+
+ TasksNone = "tasks_none"
+ TasksFirst = "tasks_first"
+ TasksCandidates = "tasks_candidates"
+
+ ReasonOverdue = "reason_overdue"
+ ReasonOverdueDays = "reason_overdue_days"
+ ReasonToday = "reason_today"
+ ReasonTomorrow = "reason_tomorrow"
+ ReasonInDays = "reason_in_days"
+ ReasonImportant = "reason_important"
+ ReasonUrgent = "reason_urgent"
+ ReasonStale = "reason_stale"
+
+ HabitWeekday = "habit_weekday"
+ HabitWeekdaySame = "habit_weekday_same"
+ HabitWeekdayNone = "habit_weekday_none"
+ HabitWeekendBoth = "habit_weekend_both"
+ HabitWeekendSat = "habit_weekend_sat"
+ HabitWeekendSun = "habit_weekend_sun"
+ HabitWeekendSame = "habit_weekend_same"
+ HabitWeekendNone = "habit_weekend_none"
+ HabitOverall = "habit_overall"
+ HabitOverallNone = "habit_overall_none"
+ HabitSpanToday = "habit_span_today"
+ HabitSpanDays = "habit_span_days"
+ HabitUnglossed = "habit_unglossed"
+ HabitAt = "habit_at"
+)
+
+var summaryKeys = []string{
+ PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
+ TasksNone, TasksFirst, TasksCandidates,
+ ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
+ ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
+ HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
+ HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone,
+ HabitOverall, HabitOverallNone, HabitSpanToday, HabitSpanDays,
+ HabitUnglossed, HabitAt,
+}
+
+// summaryFloor — the literal each key falls back to when the file is unusable.
+// It started as the exact strings that lived in Go before this file existed and
+// now tracks the file's first variant instead, because a floor that keeps the
+// wording review threw out would say it back on the one turn nobody is watching.
+var summaryFloor = map[string]string{
+ PlanRestEmpty: "на сегодня больше ничего не запланировано.",
+ PlanDayEmpty: "на {date} ничего не запланировано.",
+ PlanDay: "план на {date}: {items}",
+ PlanUncertain: "похоже, {line}",
+
+ TasksNone: "задач нет.",
+ TasksFirst: "сначала: {items}",
+ TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
+
+ ReasonOverdue: "просрочено",
+ ReasonOverdueDays: "просрочено на {n} {word}",
+ ReasonToday: "сегодня",
+ ReasonTomorrow: "завтра",
+ ReasonInDays: "через {n} {word}",
+ ReasonImportant: "важно",
+ ReasonUrgent: "срочно",
+ ReasonStale: "давно в списке",
+
+ HabitWeekday: "по {day} ты обычно {items}.",
+ HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.",
+ HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.",
+ HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}.",
+ HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям постоянного нет.",
+ HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам постоянного нет.",
+ HabitWeekendSame: "по выходным всё как обычно — то же, что и в остальные дни: {items}.",
+ HabitWeekendNone: "по выходным я пока не вижу ничего постоянного.",
+ HabitOverall: "обычно ты {items} — {span}.",
+ HabitOverallNone: "записей пока мало, про привычки не скажу.",
+ HabitSpanToday: "по записям за сегодня",
+ HabitSpanDays: "по записям за последние {n} {word}",
+ HabitUnglossed: "отмечаешь «{key}»",
+ HabitAt: "{gloss} около {time}",
+}
+
+// Summaries picks a hand-written Russian summary sentence. Safe for concurrent
+// use.
+type Summaries struct{ d *Deck }
+
+// LoadSummaries reads the embedded file. Pass a source to make the picking
+// reproducible in tests; nil seeds from the clock.
+func LoadSummaries(src rand.Source) (*Summaries, error) {
+ d, err := Load(summaryJSON, SummarySchemaVersion, summaryKeys, summaryFloor, src)
+ if err != nil {
+ return nil, err
+ }
+ // The entries that exist to read the aggregate back. A variant without the
+ // placeholder would summarise the data by dropping it.
+ for _, req := range []struct{ key, ph string }{
+ {PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
+ {PlanUncertain, "{line}"},
+ {TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
+ {ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
+ {ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
+ {HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
+ {HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
+ {HabitWeekdayNone, "{day}"},
+ {HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_sun}"},
+ {HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"},
+ {HabitWeekendSame, "{items}"},
+ {HabitOverall, "{items}"}, {HabitOverall, "{span}"},
+ {HabitSpanDays, "{n}"}, {HabitSpanDays, "{word}"},
+ {HabitUnglossed, "{key}"}, {HabitAt, "{gloss}"}, {HabitAt, "{time}"},
+ } {
+ if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
+ return nil, err
+ }
+ }
+ return &Summaries{d: d}, nil
+}
+
+// deck reads through a nil *Summaries, which is the unloadable-file case.
+func (s *Summaries) deck() *Deck {
+ if s == nil {
+ return FloorDeck(summaryFloor)
+ }
+ return s.d
+}
+
+// Say returns one line for key, with the values filled into the frame.
+func (s *Summaries) Say(key string, vars map[string]string) string {
+ return s.deck().Text(key, vars)
+}
+
+// Variants returns every line the file can produce, for the persona scorer.
+func (s *Summaries) Variants() []string { return s.deck().Variants() }
+
+var (
+ summaryOnce sync.Once
+ summaries *Summaries
+)
+
+// DefaultSummaries returns the shared instance, loading it on first use. A
+// broken file logs once and leaves a nil *Summaries, which still answers from
+// summaryFloor.
+func DefaultSummaries() *Summaries {
+ summaryOnce.Do(func() {
+ s, err := LoadSummaries(nil)
+ if err != nil {
+ log.Printf("say: summary lines unavailable, using the built-in ones: %v", err)
+ return
+ }
+ summaries = s
+ })
+ return summaries
+}
+
+// S — one summary sentence, the way every caller says it.
+func S(key string, vars map[string]string) string { return DefaultSummaries().Say(key, vars) }
+
+// IsS reports whether text is a line key could have produced, for the tests.
+func IsS(key string, vars map[string]string, text string) bool {
+ return DefaultSummaries().deck().Matches(key, vars, text)
+}
diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json
new file mode 100644
index 0000000..daed24e
--- /dev/null
+++ b/internal/say/summary_ru_v1.json
@@ -0,0 +1,143 @@
+{
+ "schema_version": 1,
+ "name": "russian summary sentences v1",
+ "notes": [
+ "The sentences she builds around aggregated data: the morning plan, the ranked task list, and the habits she reads back out of behaviour records.",
+ "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
+ "\"I have not seen enough yet\" and \"there is nothing there\" are different claims, and the habit entries keep the first. Three days of taps do not license a statement about his life, so habit_*_none says she does not see a pattern, never that he has no habits.",
+ "She reports on the records, not on him. «всё как обычно» is a claim about what she has stored; «у тебя нет ничего особенного» is a verdict on his life, and she does not pass those.",
+ "Placeholders: {date} a formatted date, {items} a joined list, {items_sat} and {items_sun} the same for one weekend day each, {day} a weekday name, {span} the stretch of records a habit claim rests on, {line} one already-rendered plan line, {key} a behaviour key she has no gloss for, {gloss} the readable name of one, {time} a clock time, {n} a count, {word} the Russian count form for {n}, built Go-side.",
+ "Three parallel entries share one scheme: habit_weekend_both names its two lists {items_sat} and {items_sun}, and habit_weekend_sat and habit_weekend_sun each use {items}, because each of them says one list.",
+ "The count forms (день/дня/дней) are morphology, not copy. They stay in Go and arrive here through {word}. An abbreviation is not an option: «дн.» is written shorthand and every line here is spoken, so it reads as garbage or gets spelled out letter by letter.",
+ "{line} in plan_uncertain is another rendered line, so the join reads as one sentence only while what arrives starts lowercase. The caller asserts that.",
+ "A joined list arrives with its own punctuation, so the entries that end on {items} carry no trailing period. The habit entries keep theirs, because there the list sits mid-sentence.",
+ "fixed: true means exactly one variant and no picking. Parallel entries agree on it, and a single-variant entry is fixed — in this file and in every other family. A test holds that."
+ ],
+ "entries": {
+ "plan_rest_empty": {
+ "fixed": true,
+ "variants": ["на сегодня больше ничего не запланировано."]
+ },
+ "plan_day_empty": {
+ "fixed": true,
+ "variants": ["на {date} ничего не запланировано."]
+ },
+ "plan_day": {
+ "fixed": true,
+ "variants": ["план на {date}: {items}"]
+ },
+ "plan_uncertain": {
+ "fixed": true,
+ "variants": ["похоже, {line}"]
+ },
+
+ "tasks_none": {
+ "fixed": true,
+ "variants": ["задач нет."]
+ },
+ "tasks_first": {
+ "fixed": true,
+ "variants": ["сначала: {items}"]
+ },
+ "tasks_candidates": {
+ "fixed": true,
+ "variants": ["нашла ещё, но ты не подтверждал: {items}"]
+ },
+
+ "reason_overdue": {
+ "fixed": true,
+ "variants": ["просрочено"]
+ },
+ "reason_overdue_days": {
+ "fixed": true,
+ "variants": ["просрочено на {n} {word}"]
+ },
+ "reason_today": {
+ "fixed": true,
+ "variants": ["сегодня"]
+ },
+ "reason_tomorrow": {
+ "fixed": true,
+ "variants": ["завтра"]
+ },
+ "reason_in_days": {
+ "fixed": true,
+ "variants": ["через {n} {word}"]
+ },
+ "reason_important": {
+ "fixed": true,
+ "variants": ["важно"]
+ },
+ "reason_urgent": {
+ "fixed": true,
+ "variants": ["срочно"]
+ },
+ "reason_stale": {
+ "fixed": true,
+ "variants": ["давно в списке"]
+ },
+
+ "habit_weekday": {
+ "fixed": true,
+ "variants": ["по {day} ты обычно {items}."]
+ },
+ "habit_weekday_same": {
+ "fixed": true,
+ "variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."]
+ },
+ "habit_weekday_none": {
+ "variants": [
+ "по {day} я пока не вижу ничего постоянного.",
+ "по {day} постоянного пока не вижу — записей мало."
+ ]
+ },
+ "habit_weekend_both": {
+ "fixed": true,
+ "variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."]
+ },
+ "habit_weekend_sat": {
+ "fixed": true,
+ "variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."]
+ },
+ "habit_weekend_sun": {
+ "fixed": true,
+ "variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."]
+ },
+ "habit_weekend_same": {
+ "fixed": true,
+ "variants": ["по выходным всё как обычно — то же, что и в остальные дни: {items}."]
+ },
+ "habit_weekend_none": {
+ "variants": [
+ "по выходным я пока не вижу ничего постоянного.",
+ "по выходным постоянного пока не вижу — записей мало."
+ ]
+ },
+ "habit_overall": {
+ "fixed": true,
+ "variants": ["обычно ты {items} — {span}."]
+ },
+ "habit_overall_none": {
+ "variants": [
+ "записей пока мало, про привычки не скажу.",
+ "пока мало записей, чтобы говорить о привычках."
+ ]
+ },
+ "habit_span_today": {
+ "fixed": true,
+ "variants": ["по записям за сегодня"]
+ },
+ "habit_span_days": {
+ "fixed": true,
+ "variants": ["по записям за последние {n} {word}"]
+ },
+ "habit_unglossed": {
+ "fixed": true,
+ "variants": ["отмечаешь «{key}»"]
+ },
+ "habit_at": {
+ "fixed": true,
+ "variants": ["{gloss} около {time}"]
+ }
+ }
+}
diff --git a/internal/say/summary_test.go b/internal/say/summary_test.go
new file mode 100644
index 0000000..7bce545
--- /dev/null
+++ b/internal/say/summary_test.go
@@ -0,0 +1,62 @@
+package say
+
+import (
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+// The file has to load, and every key the code names has to be in it.
+func TestSummariesLoad(t *testing.T) {
+ s, err := LoadSummaries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, key := range summaryKeys {
+ if got := s.Say(key, nil); got == "" {
+ t.Errorf("%s says nothing", key)
+ }
+ }
+}
+
+// A nil *Summaries is the unloadable-file case, and it must still speak. The
+// habit sentences are the ones that matter here: falling back must not turn
+// "I have not seen enough" into silence.
+func TestNilSummariesAnswerFromTheFloor(t *testing.T) {
+ var s *Summaries
+ if got, want := s.Say(HabitOverallNone, nil), summaryFloor[HabitOverallNone]; got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ if got := s.Say(PlanDay, map[string]string{"date": "03.08.2026", "items": "x"}); !strings.Contains(got, "03.08.2026") {
+ t.Errorf("the floor dropped the date: %q", got)
+ }
+}
+
+// The empty cases claim she has not seen enough, never that he has no habits.
+// Every variant has to hold that line, since the picker treats them as equals.
+func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) {
+ s, err := LoadSummaries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ for _, key := range []string{HabitWeekdayNone, HabitWeekendNone, HabitOverallNone} {
+ for _, v := range s.d.file.Entries[key].Variants {
+ if !strings.Contains(v, "пока") && !strings.Contains(v, "ещё") {
+ t.Errorf("%s variant %q reads as a fact about him, not as a gap in her records", key, v)
+ }
+ }
+ }
+}
+
+// One variant means fixed, in this file and in the four in internal/phraser.
+// Nothing breaks on the flag being absent, but parallel entries disagreeing
+// about it is how the file stops telling a reader which wording is load-bearing.
+func TestEverySingleVariantEntryIsFixed(t *testing.T) {
+ s, err := LoadSummaries(rand.NewSource(1))
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if got := s.d.UnfixedSingles(); len(got) > 0 {
+ t.Errorf("single-variant entries not marked fixed: %v", got)
+ }
+}
diff --git a/internal/store/listitems.go b/internal/store/listitems.go
new file mode 100644
index 0000000..1f8cfd2
--- /dev/null
+++ b/internal/store/listitems.go
@@ -0,0 +1,194 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// List items — the fourth append-only shape (Vikunja #453).
+//
+// A list is a standing set of short strings under a tag: покупки, аптека,
+// хозяйство. It is not work and it is not a claim about the world, which is
+// why it is neither a task nor a fact. Nothing here is prioritised, nothing
+// nudges about it, and the digestion worker does not read it. The only two
+// things a list does are grow and shrink.
+//
+// The consequence that made it worth a table: because no predicate touches a
+// list item, several people adding to the same list at once cost nothing. There
+// is no ranking to disagree about and no lifecycle beyond crossed-off.
+const (
+ // ListItemOpen — on the list.
+ ListItemOpen = "open"
+ // ListItemDone — bought, taken, crossed off.
+ ListItemDone = "done"
+ // ListItemDropped — removed without being got.
+ ListItemDropped = "dropped"
+)
+
+// DefaultList — the list a capture lands on when he names none. Almost every
+// spoken list item is groceries, and asking "в какой список?" for the common
+// case would be a nag.
+const DefaultList = "покупки"
+
+// ListItem — one line on one list.
+type ListItem struct {
+ ID int64
+ CreatedTs time.Time
+ List string
+ Item string
+ Source string
+ Status string
+ ResolvedTs *time.Time
+}
+
+var (
+ ErrListItemNotFound = errors.New("store: list item not found")
+ ErrListItemEmpty = errors.New("store: list item is empty")
+ ErrListItemStatus = errors.New("store: invalid list item status")
+)
+
+// NormalizeListName folds a list tag to its dedupe form. Lists are named out
+// loud, so "Покупки" and "покупки " are the same list.
+func NormalizeListName(s string) string {
+ n := NormalizeTaskText(s)
+ if n == "" {
+ return DefaultList
+ }
+ return n
+}
+
+// AddListItem puts an item on a list, or returns the existing row when the same
+// item is already on it. Created says which happened, so the caller can say
+// "уже есть" instead of pretending it wrote something.
+func (s *Store) AddListItem(ctx context.Context, li ListItem) (CaptureResult, error) {
+ item := strings.TrimSpace(li.Item)
+ if item == "" {
+ return CaptureResult{}, ErrListItemEmpty
+ }
+ list := NormalizeListName(li.List)
+ norm := NormalizeTaskText(item)
+ created := li.CreatedTs
+ if created.IsZero() {
+ created = time.Now()
+ }
+ res, err := s.db.ExecContext(ctx,
+ `INSERT INTO list_items (created_ts, list, item, norm, source, status)
+ VALUES (?,?,?,?,?,?)
+ ON CONFLICT DO NOTHING`,
+ created.UnixMilli(), list, item, norm, li.Source, ListItemOpen)
+ if err != nil {
+ return CaptureResult{}, fmt.Errorf("add list item: %w", err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return CaptureResult{}, fmt.Errorf("add list item: rows affected: %w", err)
+ }
+ if n > 0 {
+ id, err := res.LastInsertId()
+ if err != nil {
+ return CaptureResult{}, fmt.Errorf("add list item: last insert id: %w", err)
+ }
+ return CaptureResult{ID: id, Created: true}, nil
+ }
+ var id int64
+ err = s.db.QueryRowContext(ctx,
+ `SELECT id FROM list_items WHERE list = ? AND norm = ? AND status = ?`,
+ list, norm, ListItemOpen).Scan(&id)
+ if errors.Is(err, sql.ErrNoRows) {
+ return CaptureResult{}, ErrListItemNotFound
+ }
+ if err != nil {
+ return CaptureResult{}, fmt.Errorf("add list item: lookup: %w", err)
+ }
+ return CaptureResult{ID: id}, nil
+}
+
+// ListItems reads one list in the order it was added. An empty status reads the
+// open items, which is what reading the list aloud means.
+func (s *Store) ListItems(ctx context.Context, list, status string) ([]ListItem, error) {
+ if status == "" {
+ status = ListItemOpen
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, created_ts, list, item, source, status, resolved_ts
+ FROM list_items WHERE list = ? AND status = ?
+ ORDER BY created_ts, id`,
+ NormalizeListName(list), status)
+ if err != nil {
+ return nil, fmt.Errorf("list items: %w", err)
+ }
+ defer rows.Close()
+ var out []ListItem
+ for rows.Next() {
+ var (
+ li ListItem
+ created int64
+ resolved sql.NullInt64
+ )
+ if err := rows.Scan(&li.ID, &created, &li.List, &li.Item, &li.Source, &li.Status, &resolved); err != nil {
+ return nil, fmt.Errorf("list items: scan: %w", err)
+ }
+ li.CreatedTs = time.UnixMilli(created)
+ if resolved.Valid {
+ t := time.UnixMilli(resolved.Int64)
+ li.ResolvedTs = &t
+ }
+ out = append(out, li)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("list items: %w", err)
+ }
+ return out, nil
+}
+
+// SetListItemStatus crosses an item off, or removes it. Moving an item that is
+// already resolved is not an error — crossing off twice is the same list.
+func (s *Store) SetListItemStatus(ctx context.Context, id int64, status string, at time.Time) error {
+ if status != ListItemOpen && status != ListItemDone && status != ListItemDropped {
+ return fmt.Errorf("%w: %q", ErrListItemStatus, status)
+ }
+ var resolved sql.NullInt64
+ if status != ListItemOpen {
+ if at.IsZero() {
+ at = time.Now()
+ }
+ resolved = sql.NullInt64{Int64: at.UnixMilli(), Valid: true}
+ }
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE list_items SET status = ?, resolved_ts = ? WHERE id = ?`,
+ status, resolved, id)
+ if err != nil {
+ return fmt.Errorf("set list item status: %w", err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("set list item status: rows affected: %w", err)
+ }
+ if n == 0 {
+ return ErrListItemNotFound
+ }
+ return nil
+}
+
+// ClearList crosses off every open item on a list and reports how many. This is
+// "всё купил", which is one sentence and must not become one turn per item.
+func (s *Store) ClearList(ctx context.Context, list string, at time.Time) (int, error) {
+ if at.IsZero() {
+ at = time.Now()
+ }
+ res, err := s.db.ExecContext(ctx,
+ `UPDATE list_items SET status = ?, resolved_ts = ? WHERE list = ? AND status = ?`,
+ ListItemDone, at.UnixMilli(), NormalizeListName(list), ListItemOpen)
+ if err != nil {
+ return 0, fmt.Errorf("clear list: %w", err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return 0, fmt.Errorf("clear list: rows affected: %w", err)
+ }
+ return int(n), nil
+}
diff --git a/internal/store/listitems_test.go b/internal/store/listitems_test.go
new file mode 100644
index 0000000..eb2a464
--- /dev/null
+++ b/internal/store/listitems_test.go
@@ -0,0 +1,149 @@
+package store
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+var listNow = time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
+
+func TestAddListItemDedupesTheOpenList(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+
+ first, err := s.AddListItem(ctx, ListItem{Item: "молоко", Source: "tap:voice", CreatedTs: listNow})
+ if err != nil {
+ t.Fatalf("add: %v", err)
+ }
+ if !first.Created {
+ t.Fatal("the first молоко did not create a row")
+ }
+ again, err := s.AddListItem(ctx, ListItem{Item: " Молоко ", Source: "tap:voice", CreatedTs: listNow})
+ if err != nil {
+ t.Fatalf("add again: %v", err)
+ }
+ if again.Created {
+ t.Error("молоко was added twice")
+ }
+ if again.ID != first.ID {
+ t.Errorf("second add points at %d; want the existing %d", again.ID, first.ID)
+ }
+ if _, err := s.AddListItem(ctx, ListItem{Item: " "}); !errors.Is(err, ErrListItemEmpty) {
+ t.Errorf("empty item: %v; want ErrListItemEmpty", err)
+ }
+}
+
+// A crossed-off item does not block the next one: buying milk again next week
+// is a new line, the way saying an errand again is a new task.
+func TestCrossedOffItemComesBack(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+
+ first, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow})
+ if err != nil {
+ t.Fatalf("add: %v", err)
+ }
+ if err := s.SetListItemStatus(ctx, first.ID, ListItemDone, listNow); err != nil {
+ t.Fatalf("cross off: %v", err)
+ }
+ next, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow.Add(time.Hour)})
+ if err != nil {
+ t.Fatalf("add after: %v", err)
+ }
+ if !next.Created || next.ID == first.ID {
+ t.Errorf("second молоко reused row %d; want a new one", next.ID)
+ }
+ open, err := s.ListItems(ctx, "", "")
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(open) != 1 || open[0].ID != next.ID {
+ t.Errorf("open list %+v; want only the new row", open)
+ }
+}
+
+// Lists are separate stores under one table: the same word on two lists is two
+// items, and reading one never reads the other.
+func TestListsDoNotSeeEachOther(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+
+ if _, err := s.AddListItem(ctx, ListItem{List: "покупки", Item: "вода", CreatedTs: listNow}); err != nil {
+ t.Fatalf("add: %v", err)
+ }
+ if _, err := s.AddListItem(ctx, ListItem{List: "Аптека", Item: "вода", CreatedTs: listNow}); err != nil {
+ t.Fatalf("add: %v", err)
+ }
+ for _, c := range []struct{ list, want string }{
+ {"покупки", "покупки"},
+ {"аптека", "аптека"},
+ {"", "покупки"},
+ } {
+ got, err := s.ListItems(ctx, c.list, "")
+ if err != nil {
+ t.Fatalf("list %q: %v", c.list, err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("list %q has %d items; want 1", c.list, len(got))
+ }
+ if got[0].List != c.want {
+ t.Errorf("list %q returned tag %q; want %q", c.list, got[0].List, c.want)
+ }
+ }
+}
+
+func TestClearListCrossesOffEverythingOpen(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+
+ for _, item := range []string{"молоко", "хлеб", "яйца"} {
+ if _, err := s.AddListItem(ctx, ListItem{Item: item, CreatedTs: listNow}); err != nil {
+ t.Fatalf("add %s: %v", item, err)
+ }
+ }
+ if _, err := s.AddListItem(ctx, ListItem{List: "аптека", Item: "бинт", CreatedTs: listNow}); err != nil {
+ t.Fatalf("add: %v", err)
+ }
+ n, err := s.ClearList(ctx, "покупки", listNow)
+ if err != nil {
+ t.Fatalf("clear: %v", err)
+ }
+ if n != 3 {
+ t.Errorf("cleared %d; want 3", n)
+ }
+ left, err := s.ListItems(ctx, "покупки", "")
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(left) != 0 {
+ t.Errorf("%d items still open; want none", len(left))
+ }
+ done, err := s.ListItems(ctx, "покупки", ListItemDone)
+ if err != nil {
+ t.Fatalf("list done: %v", err)
+ }
+ if len(done) != 3 || done[0].ResolvedTs == nil {
+ t.Errorf("done list %+v; want 3 rows carrying a resolved time", done)
+ }
+ other, err := s.ListItems(ctx, "аптека", "")
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(other) != 1 {
+ t.Error("clearing покупки touched аптека")
+ }
+}
+
+func TestSetListItemStatusRejectsWhatIsNotAStatus(t *testing.T) {
+ ctx := context.Background()
+ s := newTestStore(t)
+
+ if err := s.SetListItemStatus(ctx, 1, "куплено", listNow); !errors.Is(err, ErrListItemStatus) {
+ t.Errorf("bad status: %v; want ErrListItemStatus", err)
+ }
+ if err := s.SetListItemStatus(ctx, 999, ListItemDone, listNow); !errors.Is(err, ErrListItemNotFound) {
+ t.Errorf("missing row: %v; want ErrListItemNotFound", err)
+ }
+}
diff --git a/internal/store/migrations.go b/internal/store/migrations.go
index 5478090..12081d9 100644
--- a/internal/store/migrations.go
+++ b/internal/store/migrations.go
@@ -219,8 +219,29 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
// event, and the old rows would otherwise be recited as extra meetings.
// The filter is exact — it keeps any key whose summary part still has a
// letter or a digit in it.
+ `CREATE TABLE IF NOT EXISTS list_items (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_ts INTEGER NOT NULL,
+ list TEXT NOT NULL,
+ item TEXT NOT NULL,
+ norm TEXT NOT NULL,
+ source TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','done','dropped')),
+ resolved_ts INTEGER
+ );
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_list_items_live ON list_items (list, norm) WHERE status = 'open';
+ CREATE INDEX IF NOT EXISTS idx_list_items_list ON list_items (list, status, created_ts);`,
+ // #19 — standing lists (Vikunja #453). The fourth append-only shape, after
+ // facts, notes and tasks, and the reason it is its own table rather than a
+ // tag on tasks: milk on the shopping list is not work. Nothing prioritises
+ // it, nothing nudges about it, and the prioritiser must not start counting
+ // groceries as outstanding errands.
+ //
+ // The live-only unique index is the tasks one, per list: saying "молоко"
+ // twice before the shop keeps one row, saying it again next week after the
+ // last one was crossed off writes a new one.
- // #19 — unstick the routines accepted before the fire-forever fix
+ // #20 — unstick the routines accepted before the fire-forever fix
// (Vikunja #377, follow-up to #366). Accepting used to leave accepted_ts
// NULL and a live one-shot reminder behind, and the tick loop skips a row
// with no accepted_ts, so every non-weekly routine accepted before that fix
diff --git a/internal/store/migrations_test.go b/internal/store/migrations_test.go
index 7aa80ec..01910e4 100644
--- a/internal/store/migrations_test.go
+++ b/internal/store/migrations_test.go
@@ -114,8 +114,10 @@ func TestStuckRoutinesAreBackfilled(t *testing.T) {
t.Fatal(err)
}
- if _, err := s.db.ExecContext(ctx, migrations[18]); err != nil {
- t.Fatalf("migration 19: %v", err)
+ // Index 19, version 20: standing lists landed on the same number first
+ // (Vikunja #453), so this one moved down one.
+ if _, err := s.db.ExecContext(ctx, migrations[19]); err != nil {
+ t.Fatalf("migration 20: %v", err)
}
accepted, err := s.ListAcceptedRoutines(ctx)
diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go
index e2f88fd..1d14d98 100644
--- a/internal/tasks/rank.go
+++ b/internal/tasks/rank.go
@@ -19,8 +19,11 @@ package tasks
import (
"fmt"
"sort"
+ "strconv"
"strings"
"time"
+
+ "github.com/kami/maven/internal/say"
)
// Status values, mirroring internal/store so a caller can rank ipc.Task rows
@@ -117,21 +120,25 @@ func score(it Item, now time.Time) (float64, string) {
bonus = scoreOverdueCap
}
total += scoreOverdue + bonus
- reason = "просрочено"
- if late == 1 {
- reason = "просрочено на день"
- } else if late > 1 {
- reason = fmt.Sprintf("просрочено на %d дн.", late)
+ reason = say.S(say.ReasonOverdue, nil)
+ if late > 0 {
+ // One day needs no arm of its own: «просрочено на 1 день»
+ // falls out of the count helper like every other number.
+ reason = say.S(say.ReasonOverdueDays, map[string]string{
+ "n": strconv.Itoa(late), "word": say.Days(late),
+ })
}
case days == 0:
total += scoreDueToday
- reason = "сегодня"
+ reason = say.S(say.ReasonToday, nil)
case days == 1:
total += scoreDueTomorrow
- reason = "завтра"
+ reason = say.S(say.ReasonTomorrow, nil)
case days <= 7:
total += scoreDueWeek
- reason = fmt.Sprintf("через %d дн.", days)
+ reason = say.S(say.ReasonInDays, map[string]string{
+ "n": strconv.Itoa(days), "word": say.Days(days),
+ })
default:
total += scoreDueLater
}
@@ -147,9 +154,9 @@ func score(it Item, now time.Time) (float64, string) {
// The rungs get their own words. The reason string is the one place
// the ranking explains itself, and reading "важно" back at a task
// he flagged "срочно" reports a word he did not say.
- reason = "важно"
+ reason = say.S(say.ReasonImportant, nil)
if w >= MaxWeight {
- reason = "срочно"
+ reason = say.S(say.ReasonUrgent, nil)
}
}
}
@@ -163,7 +170,7 @@ func score(it Item, now time.Time) (float64, string) {
}
total += age
if reason == "" && weeks >= 2 {
- reason = "давно в списке"
+ reason = say.S(say.ReasonStale, nil)
}
}
}
@@ -210,22 +217,28 @@ func FormatRU(ranked []Ranked) string {
}
}
if len(open) == 0 && len(cands) == 0 {
- return "задач нет."
+ return say.S(say.TasksNone, nil)
}
var b strings.Builder
if len(open) > 0 {
- b.WriteString("сначала: ")
- b.WriteString(joinRU(open, SpokenLimit, true))
- b.WriteString(".")
+ b.WriteString(say.S(say.TasksFirst, map[string]string{
+ "items": joinRU(open, SpokenLimit, true),
+ }))
}
if len(cands) > 0 {
+ // Two sentences, and the first one ends on a joined list that carries
+ // whatever punctuation its last task had — usually none. So the break
+ // is the caller's to make, not the line file's (Vikunja #521).
if b.Len() > 0 {
+ if !strings.HasSuffix(b.String(), ".") {
+ b.WriteString(".")
+ }
b.WriteString(" ")
}
- b.WriteString("ещё я нашла, но ты не подтвердил: ")
- b.WriteString(joinRU(cands, SpokenLimit, false))
- b.WriteString(".")
+ b.WriteString(say.S(say.TasksCandidates, map[string]string{
+ "items": joinRU(cands, SpokenLimit, false),
+ }))
}
return b.String()
}
@@ -250,21 +263,7 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
s := strings.Join(parts, "; ")
if rest > 0 {
// With the noun. Spoken, a bare number trails off mid-sentence.
- s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest))
+ s += fmt.Sprintf("; и ещё %d %s", rest, say.CountWord(rest, "задача", "задачи", "задач"))
}
return s
}
-
-// pluralTasksRU — the right form of "задача" for a count. Russian needs three.
-func pluralTasksRU(n int) string {
- if n%100 >= 11 && n%100 <= 14 {
- return "задач"
- }
- switch n % 10 {
- case 1:
- return "задача"
- case 2, 3, 4:
- return "задачи"
- }
- return "задач"
-}
diff --git a/internal/tasks/rank_test.go b/internal/tasks/rank_test.go
index 3364e27..832b7ed 100644
--- a/internal/tasks/rank_test.go
+++ b/internal/tasks/rank_test.go
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/kami/maven/internal/say"
)
func at(y int, m time.Month, d int) *time.Time {
@@ -123,10 +125,12 @@ func TestRankOverdueDaysAreCounted(t *testing.T) {
if got[0].Text != "давно" {
t.Errorf("order = %v, want the later-overdue task first", texts(got))
}
- if got[0].Reason != "просрочено на 12 дн." {
+ if got[0].Reason != "просрочено на 12 дней" {
t.Errorf("reason = %q", got[0].Reason)
}
- if got[1].Reason != "просрочено на день" {
+ // One day goes through the same entry as twelve: «на 1 день» is what the
+ // count helper says, so there is no reason_overdue_day any more.
+ if got[1].Reason != "просрочено на 1 день" {
t.Errorf("reason = %q", got[1].Reason)
}
}
@@ -141,7 +145,7 @@ func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) {
if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") {
t.Errorf("reply = %q", got)
}
- if !strings.Contains(got, "не подтвердил: продлить страховку") {
+ if !strings.Contains(got, "не подтверждал: продлить страховку") {
t.Errorf("candidate not named as unconfirmed: %q", got)
}
// A candidate's due date is Maven's reading of a mail, not his statement.
@@ -178,7 +182,7 @@ func TestFormatRUEmpty(t *testing.T) {
// A due date read back from the store is a UTC instant, so comparing calendar
// days in ITS location put every date a day out east of Greenwich: the row said
-// "сегодня" for a task due tomorrow, and "просрочено на день" on the due date
+// "сегодня" for a task due tomorrow, and "просрочено на 1 день" on the due date
// itself while the due column one cell over said otherwise.
func TestRankComparesDaysInTheCallersLocation(t *testing.T) {
tz := time.FixedZone("UTC+4", 4*3600)
@@ -238,8 +242,8 @@ func TestFormatRUTailCarriesTheNoun(t *testing.T) {
t.Errorf("reply = %q, want the count with its noun", got)
}
for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} {
- if got := pluralTasksRU(n); got != want {
- t.Errorf("pluralTasksRU(%d) = %q, want %q", n, got, want)
+ if got := say.CountWord(n, "задача", "задачи", "задач"); got != want {
+ t.Errorf("CountWord(%d) = %q, want %q", n, got, want)
}
}
}
diff --git a/internal/tool/risk.go b/internal/tool/risk.go
new file mode 100644
index 0000000..edd723d
--- /dev/null
+++ b/internal/tool/risk.go
@@ -0,0 +1,194 @@
+package tool
+
+import (
+ "strings"
+
+ "github.com/kami/maven/internal/ipc"
+ "github.com/kami/maven/internal/mcp"
+ "github.com/kami/maven/internal/smarthome"
+)
+
+// Risk tiers (Vikunja #449).
+//
+// What existed before this file was a mechanism and no policy: one
+// `Destructive` boolean per row, set by whoever ticked the checkbox on /tools.
+// Nothing said which acts are destructive, whether a confirmed act stays
+// confirmed, or what a new tool domain inherits — so every domain answered
+// those questions for itself, and two of them answered differently.
+//
+// The tiers below are the policy. They are derived from the row, not stored:
+// a derivation can be argued with and corrected in one place, while a column
+// is whatever the last person to enable the tool believed.
+//
+// The three questions, answered once:
+//
+// - WHICH ACTS ARE DESTRUCTIVE. A house row always is, because there is no
+// read-only way to turn the heating off. A row whose argv names one of the
+// irreversible verbs always is, whatever the checkbox says. Everything else
+// is what the row was enabled as.
+// - DOES A CONFIRMED ACT STAY CONFIRMED. No. Never, at any tier. A
+// confirmation binds one capability, one target and one argument list, and
+// it expires with the parked turn (confirmTTL, 90s). "Same act again" is a
+// new act and costs a new turn. A sticky confirm is a standing grant, and
+// nothing on the voice path may hold one.
+// - WHAT A NEW DOMAIN INHERITS. The default is TierDestructive, not
+// TierSafe. A dispatch shape this file does not recognise gets the confirm
+// turn — a new domain must argue its way DOWN to running freely, never up
+// to needing a confirm.
+type Risk string
+
+const (
+ // TierSafe — a read, or a mutation the owner can undo by saying the
+ // opposite. Runs on first hearing.
+ TierSafe Risk = "safe"
+ // TierDestructive — it changes something real and undoing it takes work.
+ // One confirm turn, every time, never remembered.
+ TierDestructive Risk = "destructive"
+ // TierIrreversible — the thing it acts on does not come back: a wipe, a
+ // format, a delete with no bin behind it. A confirm turn is not enough,
+ // because the whole chain that proposed it — an STT guess, a router guess,
+ // a fuzzy allowlist match — has a spoken "да" as its only check. She names
+ // the gap and he runs it himself.
+ TierIrreversible Risk = "irreversible"
+)
+
+// Policy — what a tier requires of the act path.
+//
+// There is deliberately no "sticky for" field. Non-stickiness is the policy,
+// and a knob that could turn it off would be the thing to argue with instead
+// of the rule.
+type Policy struct {
+ // Confirm — the act does not run on first hearing.
+ Confirm bool
+ // VoiceMayRun — a spoken confirmation is enough authority to run it.
+ VoiceMayRun bool
+}
+
+// PolicyFor returns the requirements of a tier. An unknown tier is treated as
+// destructive, for the same reason the default derivation is.
+func PolicyFor(r Risk) Policy {
+ switch r {
+ case TierSafe:
+ return Policy{Confirm: false, VoiceMayRun: true}
+ case TierIrreversible:
+ return Policy{Confirm: true, VoiceMayRun: false}
+ default:
+ return Policy{Confirm: true, VoiceMayRun: true}
+ }
+}
+
+// irreversibleVerbs — argv heads and subcommands that destroy the thing they
+// name. Matched as whole argv elements, never as substrings: "rm" must not
+// fire on "/usr/bin/rmdir-report" and "drop" must not fire on "dropbox".
+//
+// The list is short on purpose. It is not a sandbox and it does not try to be
+// one — an enabled row can already run anything the daemon's user can run.
+// What it is, is the set of words that mean "and then it is gone", so that the
+// one act nobody can walk back is the one act a spoken "да" cannot authorise.
+var irreversibleVerbs = map[string]bool{
+ "rm": true, "rmdir": true, "shred": true, "srm": true,
+ "mkfs": true, "fdisk": true, "parted": true, "wipefs": true,
+ "dd": true, "format": true,
+ "drop": true, "drop-database": true, "destroy": true, "purge": true,
+ "prune": true, "truncate": true,
+}
+
+// RiskOfCapability — the tier of a Hexis capability, which Hexis decides.
+//
+// Everything below this comment in RiskOf is a derivation, and a derivation is
+// only honest where nobody else holds the answer. Hexis does hold it: the
+// capability carries risk, read_only and requires_confirmation, and its own
+// contract says requires_confirmation is server-derived from the tier and never
+// settable by a caller. Deriving a second opinion here is the same defect as
+// inventing a local fact key for something Nexus resolves — two answers, one of
+// them stale, and the wrong one authorising an act (Vikunja #523).
+//
+// So this reads rather than decides. The three arguments are Capability.Risk,
+// Capability.ReadOnly and Capability.RequiresConfirmation, passed as plain
+// values so internal/tool keeps no dependency on the Hexis client.
+//
+// The one judgement left is what to do with an answer we cannot read. It goes
+// up, never down: an unrecognised tier gets the confirm turn, the same default
+// a dispatch shape RiskOf does not know gets. And requires_confirmation may
+// only raise — a capability that calls itself safe and then asks for a confirm
+// is telling us two things, and the cautious one wins.
+func RiskOfCapability(risk string, readOnly, requiresConfirmation bool) Risk {
+ switch Risk(strings.ToLower(strings.TrimSpace(risk))) {
+ case TierIrreversible:
+ return TierIrreversible
+ case TierDestructive:
+ return TierDestructive
+ case TierSafe:
+ // Safe and mutating is a contradiction, and so is safe with a confirm
+ // required. Either way the act changes something.
+ if requiresConfirmation || !readOnly {
+ return TierDestructive
+ }
+ return TierSafe
+ case "":
+ // No tier declared. Fall back to the shape Hexis did give us: a
+ // read-only capability that wants no confirm is a read, and anything
+ // else takes the confirm turn.
+ if readOnly && !requiresConfirmation {
+ return TierSafe
+ }
+ return TierDestructive
+ default:
+ // A word this file has never seen. It is not safe by default.
+ return TierDestructive
+ }
+}
+
+// RiskOf derives the tier of a locally enabled tool row — a shell command, an
+// MCP call or a house service. Nothing here is a Hexis capability, and nothing
+// upstream has an opinion about a row the owner ticked on /tools, which is why
+// this one derives and RiskOfCapability reads.
+func RiskOf(t ipc.Tool) Risk {
+ if isIrreversible(t.Cmd) {
+ return TierIrreversible
+ }
+ // A house row is a physical change to the flat, and the confirm turn on it
+ // is structural rather than a column: /tools writes the checkbox straight
+ // through on enable, so unticking it once turned an unlock into a row that
+ // ran on first hearing. Nothing any surface writes removes the second turn
+ // from a physical device.
+ if _, _, ok := smarthome.ParseCmd(t.Cmd); ok {
+ return TierDestructive
+ }
+ // An MCP row is a call to somebody else's server. It is enabled with a
+ // fingerprint of what it declared at approval time (Vikunja #251), and the
+ // tier tracks the same flag every other row uses — the point of this branch
+ // is that it is NOT special-cased into running freely.
+ if _, _, ok := mcp.ParseCmd(t.Cmd); ok {
+ if t.Destructive {
+ return TierDestructive
+ }
+ return TierSafe
+ }
+ if t.Destructive {
+ return TierDestructive
+ }
+ if len(t.Cmd) == 0 {
+ // Not a shape this file knows how to read. The default is the confirm
+ // turn: a new domain argues its way down, not up.
+ return TierDestructive
+ }
+ return TierSafe
+}
+
+// isIrreversible reports whether any argv element is one of the verbs that
+// destroys what it names. Every element, not just the head: "sudo rm" and
+// "docker volume prune" both hide the verb behind a wrapper.
+func isIrreversible(cmd []string) bool {
+ for _, arg := range cmd {
+ word := strings.ToLower(strings.TrimSpace(arg))
+ // Take the last path element, so /bin/rm reads as rm.
+ if i := strings.LastIndex(word, "/"); i >= 0 {
+ word = word[i+1:]
+ }
+ if irreversibleVerbs[word] {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/tool/risk_test.go b/internal/tool/risk_test.go
new file mode 100644
index 0000000..47942b7
--- /dev/null
+++ b/internal/tool/risk_test.go
@@ -0,0 +1,111 @@
+package tool
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/kami/maven/internal/ipc"
+)
+
+func TestRiskOfReadsTheRow(t *testing.T) {
+ cases := []struct {
+ name string
+ tool ipc.Tool
+ want Risk
+ }{
+ {"a plain read", ipc.Tool{Cmd: []string{"systemctl", "status"}}, TierSafe},
+ {"the checkbox", ipc.Tool{Cmd: []string{"systemctl", "restart"}, Destructive: true}, TierDestructive},
+ {"a wipe", ipc.Tool{Cmd: []string{"rm", "-rf"}}, TierIrreversible},
+ {"a wipe behind a wrapper", ipc.Tool{Cmd: []string{"sudo", "/bin/rm"}}, TierIrreversible},
+ {"a prune behind a subcommand", ipc.Tool{Cmd: []string{"docker", "volume", "prune"}}, TierIrreversible},
+ {"the house", ipc.Tool{Cmd: []string{"smarthome", "light.kitchen", "turn_off"}}, TierDestructive},
+ {"the house with the box unticked", ipc.Tool{Cmd: []string{"smarthome", "lock.front", "unlock"}}, TierDestructive},
+ {"an mcp read", ipc.Tool{Cmd: []string{"mcp", "vikunja", "list_tasks"}}, TierSafe},
+ {"an mcp write", ipc.Tool{Cmd: []string{"mcp", "vikunja", "delete_task"}, Destructive: true}, TierDestructive},
+ {"a shape nobody wrote yet", ipc.Tool{}, TierDestructive},
+ }
+ for _, c := range cases {
+ if got := RiskOf(c.tool); got != c.want {
+ t.Errorf("%s: RiskOf = %q; want %q", c.name, got, c.want)
+ }
+ }
+}
+
+// The default is the confirm turn. A tier this file does not know is not a
+// tier that runs freely.
+func TestPolicyForDefaultsToConfirming(t *testing.T) {
+ for _, r := range []Risk{TierDestructive, Risk("whatever-lands-here-next")} {
+ p := PolicyFor(r)
+ if !p.Confirm || !p.VoiceMayRun {
+ t.Errorf("PolicyFor(%q) = %+v; want a confirm turn she may run", r, p)
+ }
+ }
+ if p := PolicyFor(TierSafe); p.Confirm || !p.VoiceMayRun {
+ t.Errorf("PolicyFor(safe) = %+v; want it to run", p)
+ }
+ if p := PolicyFor(TierIrreversible); !p.Confirm || p.VoiceMayRun {
+ t.Errorf("PolicyFor(irreversible) = %+v; want voice refused", p)
+ }
+}
+
+// An irreversible act is refused whether or not he said "да", because there is
+// no second answer that changes what it would do.
+func TestExecRefusesIrreversibleEvenConfirmed(t *testing.T) {
+ api := fakeAPI{tools: map[string]ipc.Tool{
+ "wipe": {Name: "wipe", Status: "enabled", Cmd: []string{"rm", "-rf"}, Destructive: true},
+ }}
+ e := NewExecutor(api, 0)
+ ran := false
+ e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil }
+ for _, confirmed := range []bool{false, true} {
+ if _, err := e.Exec(context.Background(), "wipe", []string{"/data"}, confirmed); !errors.Is(err, ErrNeedsAuthedSurface) {
+ t.Errorf("confirmed=%v: %v; want ErrNeedsAuthedSurface", confirmed, err)
+ }
+ }
+ if ran {
+ t.Fatal("an irreversible act ran from the voice path")
+ }
+}
+
+// A row with no cmd at all is not a shape this file reads, and it must not
+// slide through as safe.
+func TestExecConfirmsAnUnreadableRow(t *testing.T) {
+ api := fakeAPI{tools: map[string]ipc.Tool{
+ "mystery": {Name: "mystery", Status: "enabled"},
+ }}
+ e := NewExecutor(api, 0)
+ if _, err := e.Exec(context.Background(), "mystery", nil, false); !errors.Is(err, ErrNeedsConfirm) {
+ t.Errorf("%v; want ErrNeedsConfirm", err)
+ }
+}
+
+// Hexis owns the tier of a Hexis capability, so this reads rather than derives
+// (Vikunja #523). The cases that matter are the ones where the three fields
+// disagree, or where the tier is a word this package has never seen: every one
+// of those goes up to a confirm, never down to running freely.
+func TestRiskOfCapabilityReadsHexis(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ risk string
+ ro bool
+ confirm bool
+ want Risk
+ }{
+ {"hexis says irreversible", "irreversible", false, true, TierIrreversible},
+ {"case and space do not change the tier", " Irreversible ", false, true, TierIrreversible},
+ {"hexis says destructive", "destructive", false, true, TierDestructive},
+ {"a read hexis calls safe", "safe", true, false, TierSafe},
+ {"safe but mutating is a contradiction", "safe", false, false, TierDestructive},
+ {"safe but wants a confirm is a contradiction", "safe", true, true, TierDestructive},
+ {"no tier, read-only, no confirm", "", true, false, TierSafe},
+ {"no tier and mutating", "", false, false, TierDestructive},
+ {"no tier but hexis wants a confirm", "", true, true, TierDestructive},
+ {"a word we have never seen", "spicy", true, false, TierDestructive},
+ } {
+ if got := RiskOfCapability(c.risk, c.ro, c.confirm); got != c.want {
+ t.Errorf("%s: RiskOfCapability(%q, ro=%v, confirm=%v) = %q; want %q",
+ c.name, c.risk, c.ro, c.confirm, got, c.want)
+ }
+ }
+}
diff --git a/internal/tool/tool.go b/internal/tool/tool.go
index d948d58..6b27924 100644
--- a/internal/tool/tool.go
+++ b/internal/tool/tool.go
@@ -67,6 +67,12 @@ var (
// proposal, and drafting a new proposal for a tool that already exists and
// is enabled is a lie about what is wrong.
ErrNotConnected = errors.New("tool is enabled but its backend is not connected")
+ // ErrNeedsAuthedSurface — the row is enabled and the act is understood,
+ // and its tier is one a spoken "да" may not authorise (risk.go,
+ // TierIrreversible). Held apart from ErrNeedsConfirm because there is no
+ // confirm turn that would help: asking again would imply the second answer
+ // changes the outcome.
+ ErrNeedsAuthedSurface = errors.New("tool is irreversible and voice may not authorise it")
)
// MCPCaller is the seam for an act that is an MCP tool call rather than a
@@ -121,7 +127,12 @@ func (e *Executor) WithHome(h HomeCaller) *Executor {
// Exec looks up name in the store and runs Cmd+args as argv (no shell).
// confirmed=true is the second turn of a destructive act (the user said "да");
// it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a
-// destructive tool with confirmed=false ⇒ ErrNeedsConfirm.
+// destructive tool with confirmed=false ⇒ ErrNeedsConfirm; an irreversible one
+// ⇒ ErrNeedsAuthedSurface, confirmed or not.
+//
+// Exec IS the voice path. Nothing else calls it, which is why the tier check
+// needs no surface argument: the authority it can offer a tool is a spoken
+// "да", and TierIrreversible says that is not enough.
func (e *Executor) Exec(ctx context.Context, name string, args []string, confirmed bool) (string, error) {
t, err := e.api.LookupTool(ctx, name)
if errors.Is(err, ipc.ErrToolNotFound) {
@@ -133,7 +144,15 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm
if t.Status != "enabled" {
return "", ErrNotEnabled
}
- if t.Destructive && !confirmed {
+ // The tier decides, not the column (Vikunja #449). RiskOf reads the row and
+ // answers the three questions the boolean never did: which acts are
+ // destructive, whether a confirm sticks (it never does), and what an
+ // unrecognised shape inherits (the confirm turn).
+ policy := PolicyFor(RiskOf(t))
+ if !policy.VoiceMayRun {
+ return "", ErrNeedsAuthedSurface
+ }
+ if policy.Confirm && !confirmed {
return "", ErrNeedsConfirm
}
// An MCP row is a call to a configured server, not a process. Everything
diff --git a/internal/voice/replier.go b/internal/voice/replier.go
index 1f6e71f..b1c5cca 100644
--- a/internal/voice/replier.go
+++ b/internal/voice/replier.go
@@ -25,7 +25,10 @@
// the daemon seam (config wiring, no CoreAPI or voice-package change).
package voice
-import "github.com/kami/maven/internal/router"
+import (
+ "github.com/kami/maven/internal/phraser"
+ "github.com/kami/maven/internal/router"
+)
// Replier — the reactive reply phrasing seam. The daemon's reactive handler
// calls Reply with the router's Decision; the impl produces a terse reply
@@ -60,27 +63,24 @@ func (s *StubReplier) Reply(d router.Decision) string {
if !d.Slots.HasFn {
return "не могу это сделать — не разобрала действие."
}
- return "ок, записала действие: " + d.Slots.Fn
+ return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn})
case router.IntentReminder:
- if d.Slots.HasTime {
- return "напомню."
- }
- return "напомню."
+ return phraser.Ack(phraser.AckReminder, nil)
case router.IntentFact:
if d.Slots.HasKey {
if d.Slots.Value != "" {
- return "отметила: " + d.Slots.Key + " = " + d.Slots.Value
+ return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
}
- return "отметила: " + d.Slots.Key
+ return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
}
- return "записала факт."
+ return phraser.Ack(phraser.AckFact, nil)
case router.IntentNote:
- return "сохранила заметку."
+ return phraser.Ack(phraser.AckNote, nil)
case router.IntentQuery:
return "поискала в заметках — ничего не нашла."
case router.IntentChat:
return "поговорили." // stub — LLMReplier replaces this
default:
- return "приняла."
+ return phraser.Ack(phraser.AckGeneric, nil)
}
}