Compare commits

..

21 Commits

Author SHA1 Message Date
claude 761cf9f3e0 Merge PR #115 into task/479 (V-498) 2026-08-04 14:08:56 +02:00
claude 1c8a32c3fd router: stage 0 claims "что дальше?" and "расскажи про X" (V-498)
Both shapes carry no question mark and no interrogative, so the model saw
them with nothing deterministic in front and routed both to fact. The fact
gate caught the write and re-ran the turn as a query, so nothing broke —
what they cost was a full model round trip for a decision two patterns can
make offline.

NarrativeQueryGrammars, wired after the agenda rules so that "расскажи,
что у меня сегодня" stays an agenda question. Two exclusions, both learned
from the fixture: a capture verb in the rest of the utterance means he
asked for a note, and an entertainment noun means chat — "расскажи анекдот
про программистов" is ru-chat-003, and my first pattern took it.

The fixture had no case for either shape, which is why they went unnoticed.
Added as ru-query-020 and ru-query-021: classifier+onnx 53/77 → 55/79
(68.8% → 69.6%), both new cases answered at stage 0, false clarifies
unchanged at 0.
2026-08-04 01:56:50 +04:00
claude 96b474223d mavend: an unconfigured capability names the gap (V-479)
Netscan and the crawler both declined their own turn when the wiring was
nil, and the question fell through to the search leg. "какие устройства в
сети?" came back as a paragraph about routers in general, and a question
about his own LAN went to an upstream engine — the personal boundary
exists to stop exactly that. A URL he named came back answered as though
he had not named it.

Both now claim the turn once their own recogniser has matched, and say
which capability is missing: net_off and page_off in the query family.

TestQueryWebPassesWhenNotConfigured encoded the old decision, that
announcing a configuration status is only for a capability that exists and
failed. It is rewritten, not deleted: the gap is the answer now.
2026-08-04 01:51:27 +04:00
claude 42d7a39c49 morning, tasks, memory: say the summaries from the file (V-506)
The three callers now read their sentences out of summary_ru_v1.json: the
plan lines in morning.Plan.FormatRU, the list and reason words in
tasks.FormatRU, and the habit readouts in memory.Profile.

Two behaviour_test assertions moved from substring to say.IsS, because the
habit gaps have variants now and a substring pins one of them. The
"по {day} у тебя обычно" variant was dropped on sight: the activities are
verbs, so it read "у тебя обычно тренируешься".

The persona scorer covers the family, and a new test asserts every gap
variant still says she has not seen enough rather than that he has nothing.
2026-08-04 01:47:52 +04:00
claude bad3fa4035 say: load the summaries family (V-506)
Same nil-safe shape as the four families in phraser: a floor holding the
exact literals that lived in Go, a load-time placeholder check on every
entry whose job is to read the aggregate back, and S/IsS for the callers
and their tests. No call site moved yet.
2026-08-04 01:44:18 +04:00
claude d819fc09f0 say: the summaries copy file (V-506)
summary_ru_v1.json: the morning plan, the ranked task list, and the habit
sentences read back out of behaviour records. Own schema_version.

The empty cases are the point. "I have not seen enough yet" and "there is
nothing there" are different claims about his life, and the habit entries
keep the first — three days of taps produce the same "обычно ты ..." as a
year of them. plan_rest_empty stays separate from plan_day_empty for the
same reason: a day that is over was not an empty day.

Count forms stay in Go. день/дня/дней and задача/задачи/задач are
morphology, and they arrive here through {word}. Loader in the next commit.
2026-08-04 01:44:18 +04:00
claude c35979d9f9 say: move the copy deck into a package memory can import (V-506)
The summaries family is spoken by internal/memory, internal/morning and
internal/tasks. internal/phraser already imports internal/memory, so the
deck cannot stay in phraser without a cycle.

internal/say is a leaf: embed, json, math/rand, strings, sync. The four
phraser families keep their files and their floors and now call say.Load,
*say.Deck, Text, Matches, Variants, RequirePlaceholder and RegisterFloor.
No copy changed and no behaviour changed.
2026-08-04 01:42:20 +04:00
claude f3c0540b42 mavend: say the act replies from the file (V-504)
Also fixes a flake this stack introduced: the feeds test matched "ничего
нового" as a substring, and query_ru_v1.json can answer with "в лентах тихо".
It asks the entry now, like the others.
2026-08-04 01:38:14 +04:00
claude 5b4192acb5 phraser: put the act and smart-home replies in a versioned json (V-504)
What she says when a capability ran, refused, or could not be reached. Around
forty literals across ecosystem_acts.go, actions_act.go and smarthome.go.

"It ran", "it was refused", "the ecosystem is down" and "I could not work out
what you meant" keep four entries. One variant set across them would let a
failure report itself as a success, which is the only failure mode this family
has.

The lines that report an act as done are fixed rather than varied. A success
report that rewords itself is harder to trust when he is listening for it, and
the confirmations are fixed for the same reason: they carry an instruction.

internal/smarthome/ha.go keeps its own "готово". It is a device driver, and
wiring the copy deck into one is the wrong dependency — the daemon relays that
word, it does not speak it.
2026-08-04 01:38:14 +04:00
claude 16d94894b7 mavend: say the query answers from the file (V-503)
The three daemon tests that pinned a wording ask the entry instead. The eval
scores every query variant on the persona checks, minus hisgender: it reads her
own feminine verb next to "у тебя" as addressing him as a woman.
2026-08-04 01:32:15 +04:00
claude ae8d38fc31 phraser: put the query answers and gaps in a versioned json (V-503)
What a query source says when it answers from something other than the model,
and what it says when it has nothing. Two dozen of them lived in
actions_query.go alone.

Every gap keeps its own entry. "The feeds are not configured", "the search
failed" and "I do not know" are different truths, and one variant set would let
them answer for each other. The personal boundary and the refusal to re-ask a
question for another day are fixed: both are load-bearing wording.

query_unknown is not the phraser fallback that reads the same. Here she looked
and found nothing; there she failed to phrase an answer she had.
2026-08-04 01:32:15 +04:00
claude b2521988e1 mavend, voice: say the acknowledgements from the file (V-502)
The daemon tests that compared against one literal ask the entry instead: IsAck
names the line she could have said without pinning the wording. The eval scores
every ack variant on the persona checks the nudges already pass.
2026-08-04 01:26:52 +04:00
claude dae123adac phraser: put the capture acknowledgements in a versioned json (V-502)
What she says after storing something he said, and what she says when storing
it failed. They were literals in eight files under cmd/mavend and the stub
replier.

He hears these many times a day, which is why most entries carry variants:
identical wording is what makes a confirmation stop registering as one. The
quiet-mode lines are fixed — they report a state, and a state report that
reworded itself would read as a different state.

His data stays Go-side. The file holds "отметила: {key} = {value}"; nothing he
said lives in the copy.
2026-08-04 01:26:52 +04:00
claude 1c9ddbbea2 phraser: move the fallbacks onto the deck (V-502) 2026-08-04 01:26:52 +04:00
claude 3f2782f5b7 phraser: add the shared deck for hand-written line families (V-502)
Every family of hand-written Russian lines wants the same mechanics: a
schema-versioned embedded file, variants with anti-repeat picking, and a floor
of Go literals under it. The acknowledgements are the second family, and
copying eighty lines of loader per family was not going to survive five of them.

Each family keeps its own file, keys, floor, validation and accessor names.
2026-08-04 01:26:52 +04:00
claude 865623ef3e phraser, mavend: read the fallbacks from the file (V-501)
The accessors are functions now, so the call sites that compared against one
literal compare against the entry instead: IsUnknownFallback and
IsSourcesFallback in the daemon tests, the entry key in the phraser tests. A
reworded variant no longer breaks a Go test.

The eval scores every variant on the persona checks the nudges already pass.
2026-08-04 01:19:41 +04:00
claude 4fdce3ca2c phraser: put the phrasing fallbacks in a versioned json (V-501)
Four lines he hears out loud lived as string literals in three Go files, so
rewording one meant a rebuild. They move to fallbacks_ru_v1.json on the shape
nudges_ru_v1.json already uses: embedded, schema-versioned, several variants,
never the same one twice running.

The gap phrase is marked fixed, because it names one specific missing model and
must not drift into a general "I do not know". Every accessor falls back to the
literal it replaced, including on a nil receiver: these strings exist because
something already failed, so a broken template file must not take her last
words away.
2026-08-04 01:19:41 +04:00
claude c47881106e phraser: say "даже не знаю, что сказать" when there is nothing to say (V-397)
Review of #108: "поговорили." reads as a summary of a conversation that did
not happen. One exported constant now, so the Stub, the LLMPhraser fallback
and the daemon all say the same thing.

internal/voice/replier.go keeps its own copy — that is the separate replier
seam, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:50:46 +04:00
claude 9a70f7378b phraser: move errEmptyResponse next to its only caller (V-397)
It sat in world.go, which is about the workstation model; it is a phrasing
error and belongs in llmphraser.go. Also trims the PhraseQuery doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:47:31 +04:00
claude b18f608594 mavend, eval: use the phrasing errors the phraser now returns (V-397)
Call sites take the fallback text and log the error instead of treating a
canned string as success. phraseSource drops the text entirely — its callers
hold the passage and read it back better than "вот что я нашла: <passage>".

The talk scorer's before-and-after model probe (the #395 workaround) goes;
the run now fails only when every case errored, which is the honest
"nothing was measured" condition. TalkFixture gets its own schema version so
the two fixtures can be versioned apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:41:16 +04:00
claude d1f8a734c5 phraser: report the failure next to the fallback (V-397)
PhraseChat and PhraseQuery returned canned text with a nil error, so a dead
or OOM-killed server was indistinguishable from bad phrasing — "не знаю." is
also a legitimate answer.

Both now return the fallback text AND the error. The daemon keeps using the
text, so the turn still survives; a measuring caller counts a real failure.
An empty response is its own error: the model is up and said nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:41:16 +04:00
82 changed files with 2223 additions and 2435 deletions
+3 -2
View File
@@ -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
+6 -1
View File
@@ -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
}
+9 -14
View File
@@ -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,37 +51,31 @@ 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 + "»? скажи «да» или «нет»."
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 "это я из голоса не выполню — после него ничего не вернуть. запусти сам, если правда надо."
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
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)
}
-73
View File
@@ -1,73 +0,0 @@
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)
}
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)
}
}
+3 -2
View File
@@ -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
-143
View File
@@ -1,143 +0,0 @@
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
}
-184
View File
@@ -1,184 +0,0 @@
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 say(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 := say(t, h, u); !ok {
t.Fatalf("%q was not claimed (reply %q)", u, reply)
}
}
if reply, ok := say(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 := say(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 := say(t, h, u); !ok {
t.Fatalf("%q was not claimed", u)
}
}
reply, ok := say(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 := say(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 := say(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)
}
}
+3 -8
View File
@@ -5,6 +5,7 @@ import (
"log"
"strconv"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -17,25 +18,19 @@ 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).
+51 -44
View File
@@ -85,11 +85,6 @@ 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
@@ -152,9 +147,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
@@ -172,11 +167,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.
@@ -202,7 +197,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
@@ -247,7 +242,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 {
@@ -286,7 +281,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 "в
@@ -294,7 +289,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 {
@@ -311,11 +306,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 — "что у меня сегодня?", "планы на завтра?"
@@ -329,7 +324,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
@@ -372,9 +367,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)
}
@@ -387,19 +385,23 @@ 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 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),
"condition": w.Condition,
}), true
}
// queryEmbed isn't an answer source — it's the shared cost the two recall
@@ -409,7 +411,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 +452,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 +480,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 +505,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 +527,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 +554,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 +620,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 +701,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 +731,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 +799,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
}
+3 -2
View File
@@ -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 ""
}
+5 -4
View File
@@ -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 — "какие у меня задачи?", "что мне нужно сделать?".
+2 -1
View File
@@ -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)
}
}
-36
View File
@@ -465,39 +465,3 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
t.Fatal("the expired question must be gone")
}
}
// The other half of the subject question: his answer must fill the empty slot,
// not replace the request. Slots.Text used to be the whole raw utterance for
// every intent, so the branch that fills a text slot could only ever overwrite
// (Vikunja #383). Here the parked request holds the hour and the answer holds
// what to say at it, and the reminder that lands has both.
func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
ctx := context.Background()
h, st, _ := newClarifyHandler(t)
at := h.now().Add(2 * time.Hour)
question, asked := h.askClarify(clarifyDec(router.IntentReminder,
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
if !asked || question != "О чём напомнить?" {
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
}
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
if !handled {
t.Fatal("the answer to an open question must be consumed as an answer")
}
if reply == clarifyGaveUp {
t.Fatalf("a good answer must not drop the request: %q", reply)
}
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
if err != nil || len(reminders) != 1 {
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
}
if !strings.Contains(reminders[0].Payload, "маме") {
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
}
if !strings.Contains(reminders[0].Payload, "11") {
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
}
}
+11 -7
View File
@@ -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,7 +160,7 @@ 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)
}
}
+3 -2
View File
@@ -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)
}
}
+27 -26
View File
@@ -9,6 +9,7 @@ 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"
)
@@ -144,10 +145,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
@@ -175,7 +176,7 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
}
}
}
return "требует внимания: " + strings.Join(parts, "; ")
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
}
// listChangesCapability reads the recent-changes feed.
@@ -192,10 +193,10 @@ 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
@@ -204,7 +205,7 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
typ, _ := c["change_type"].(string)
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
}
return "изменения: " + strings.Join(parts, "; ")
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
}
// entityAttentionCapability answers "what's going on with X" by resolving X to
@@ -230,12 +231,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 +249,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, nil)
}
return "экосистема недоступна, попробуй ещё раз."
return phraser.A(phraser.EcoDown, nil)
}
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 +269,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 +280,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 +304,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 +371,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 += ", и это не всё"
}
@@ -520,18 +521,18 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
if unauthorizedEcosystemError(err) {
return "экосистема отклоняет доступ, проверь токен."
return phraser.A(phraser.EcoDenied, nil)
}
// 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, nil)
}
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,
@@ -550,9 +551,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, nil)
}
return "экосистема недоступна, попробуй ещё раз."
return phraser.A(phraser.EcoDown, nil)
}
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
map[string]any{"entity_id": entityID, "count": len(caps)})
@@ -584,7 +585,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
for _, m := range matches {
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]
@@ -602,7 +603,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, "entity": displayName})
}
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
@@ -622,7 +623,7 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
mergeFields(traceErrorFields(err), map[string]any{
"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.
@@ -630,5 +631,5 @@ 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})
}
+2 -2
View File
@@ -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)
}
}
@@ -94,7 +94,7 @@ func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
}
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)
}
}
+3 -2
View File
@@ -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,7 +117,7 @@ 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)))
if shape := scanShape(res.Hosts); shape != "" {
+21
View File
@@ -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) {
@@ -169,3 +171,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)
}
}
-134
View File
@@ -1,134 +0,0 @@
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
}
-75
View File
@@ -1,75 +0,0 @@
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
}{
{"<think>он просил воду</think> попей воды.", 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", "<think>…"); 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")
}
}
+3 -3
View File
@@ -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)
}
})
+4 -3
View File
@@ -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
}
-5
View File
@@ -30,10 +30,5 @@ 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
}
+13 -2
View File
@@ -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)
+9 -4
View File
@@ -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": hostWord(dark),
}), true
default:
parts = append(parts, "всё выключено")
}
+3 -2
View File
@@ -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.
-3
View File
@@ -176,9 +176,6 @@ 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 {
+3 -1
View File
@@ -379,7 +379,9 @@ 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()...)
grammars = append(grammars, router.ListGrammars()...)
// 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.ReminderGrammar())
return router.New(router.Config{
Grammars: grammars,
+9 -1
View File
@@ -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 "вот что я нашла: <passage>" does.
log.Printf("voice: %s: phrase: %v", name, err)
return ""
}
return reply
}
+6 -6
View File
@@ -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())
}
}
+3 -17
View File
@@ -24,12 +24,7 @@ type runner struct {
mu sync.Mutex
cmd *exec.Cmd
ready bool
// yielding — stop() has sent the signal and the exit that follows is ours.
// llama-server aborts on SIGTERM (its static teardown throws, upstream
// ggml-org/llama.cpp), so a routine yield and a real crash produce the same
// "signal: aborted" and used to log identically (Vikunja #491).
yielding bool
http *http.Client
http *http.Client
}
func newRunner(bin string, args []string, readyURL string) *runner {
@@ -75,18 +70,13 @@ func (r *runner) start() error {
if err := cmd.Start(); err != nil {
return err
}
r.cmd, r.ready, r.yielding = cmd, false, false
r.cmd, r.ready = cmd, false
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
go func() {
err := cmd.Wait()
r.mu.Lock()
yielded := r.yielding
r.cmd, r.ready, r.yielding = nil, false, false
r.cmd, r.ready = nil, false
r.mu.Unlock()
if yielded {
log.Printf("mavgpud: llama-server stopped, card yielded (%v)", err)
return
}
log.Printf("mavgpud: llama-server exited: %v", err)
}()
return nil
@@ -100,10 +90,6 @@ func (r *runner) stop(grace time.Duration) {
r.mu.Lock()
cmd := r.cmd
r.ready = false
if cmd != nil && cmd.Process != nil {
// The exit that follows is ours, not a crash.
r.yielding = true
}
r.mu.Unlock()
if cmd == nil || cmd.Process == nil {
return
-59
View File
@@ -1,59 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
// fakeServer writes an executable standing in for llama-server: it ignores
// SIGTERM the way the real one effectively does — by dying messily rather than
// cleanly — and reports a non-zero status.
func fakeServer(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "fake-llama-server")
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
return path
}
// A deliberate stop is a yield, and the log has to say so.
//
// llama-server aborts inside its own static teardown on SIGTERM, so the exit
// status of a routine yield is identical to that of a real crash. Reading the
// mavgpud log, the two were indistinguishable (Vikunja #491).
func TestStopMarksTheExitAsAYield(t *testing.T) {
r := newRunner(fakeServer(t, "while : ; do sleep 1 ; done"), nil, "")
if err := r.start(); err != nil {
t.Fatalf("start: %v", err)
}
r.mu.Lock()
if r.yielding {
t.Error("a freshly started server is already marked as yielding")
}
r.mu.Unlock()
r.stop(2 * time.Second)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if !r.running() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("the child outlived stop")
}
// Stopping when nothing is running must not arm the flag for the next child.
// The next exit after that would be a real crash logged as a yield.
func TestStopWithNoChildDoesNotArmTheFlag(t *testing.T) {
r := newRunner("/nonexistent", nil, "")
r.stop(10 * time.Millisecond)
r.mu.Lock()
defer r.mu.Unlock()
if r.yielding {
t.Error("stop armed the yield flag with no child running")
}
}
+7 -16
View File
@@ -27,7 +27,6 @@ import (
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/pattern"
"github.com/kami/maven/internal/tasks"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
"github.com/kami/maven/internal/webauthn"
)
@@ -747,8 +746,6 @@ func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
var toolsTmpl = template.Must(template.New("tools").Funcs(func() template.FuncMap {
m := shellFuncs()
m["join"] = strings.Join
m["capability"] = func(t ipc.Tool) string { return tool.CapabilityOf(t).String() }
m["risk"] = func(t ipc.Tool) string { return string(tool.RiskOf(t)) }
return m
}()).Parse(shellTopHTML + toolsHTML + shellBottomHTML))
@@ -759,9 +756,9 @@ const toolsHTML = `{{template "shellTop" "tools"}}
<section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
<div class=scroll><table><tr><th>name</th><th>capability</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><code>{{capability .}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
<td><form method=post action=/tools>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
@@ -782,16 +779,14 @@ const toolsHTML = `{{template "shellTop" "tools"}}
</section>
<section class=card>
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
{{if .Enabled}}<p class=hint>grouped by capability domain. The dotted id is <code>scope.domain.action</code> — the same shape Hexis speaks — and it is derived from the row, so it always describes what the command actually does.</p>
{{range .Groups}}<h3 class=card-title><code>{{.Prefix}}</code> <span class=badge>{{len .Tools}}</span></h3>
<div class=scroll><table><tr><th>capability</th><th>name</th><th>command</th><th>risk</th><th></th></tr>
{{range .Tools}}<tr><td><code>{{capability .}}</code></td><td><code>{{.Name}}</code></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{$r := risk .}}{{if eq $r "irreversible"}}<span class=red>irreversible</span>{{else if eq $r "destructive"}}<span class=red>destructive</span>{{else}}<span class=badge>safe</span>{{end}}</td>
{{if .Enabled}}<div class=scroll><table><tr><th>name</th><th>scope</th><th>command</th><th></th><th></th></tr>
{{range .Enabled}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td><code>{{join .Cmd " "}}</code></td>
<td>{{if .Destructive}}<span class=red>destructive</span>{{end}}</td>
<td><form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=disable>
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>{{end}}
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no tools enabled</div>
@@ -1464,16 +1459,12 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
servers = nil
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Enabled rows are shown grouped by capability domain (Vikunja #452). A
// flat list stops answering "what can she do to the house" somewhere
// around fifteen rows, and that is the question this page exists for.
if err := toolsTmpl.Execute(w, struct {
Msg string
Proposed []ipc.Tool
Enabled []ipc.Tool
Groups []tool.CapabilityGroup
MCP []ipc.MCPServerStatus
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); err != nil {
}{msg, proposed, enabled, servers}); err != nil {
log.Printf("tools render: %v", err)
}
}
-4
View File
@@ -19,10 +19,6 @@ RestartSec=5
# llama-server on SIGTERM, so give it longer than stop_grace to do that.
KillSignal=SIGTERM
TimeoutStopSec=60
# llama-server aborts inside its own static teardown on SIGTERM, so every
# routine yield used to write a multi-gigabyte core into systemd-coredump
# (Vikunja #491). Yielding is meant to happen several times a day.
LimitCORE=0
[Install]
WantedBy=default.target
-76
View File
@@ -293,57 +293,6 @@ 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.
#### Capability ids
A row is also read as a dotted capability id, `scope.domain.action` — the same
shape Hexis has always spoken, which made the local surface the odd one out
(Vikunja #452). `homelab.docker.restart`, `house.lock.unlock`,
`mcp_vikunja.vikunja.delete_task`.
Derived, not stored, for the reason the tier is: a derivation is one place to
argue with. The name is still the primary key and nothing about lookup or
execution changed — this is a way to READ the allowlist, not a second one.
`/tools` groups the enabled rows by `scope.domain` and prints the id and the
tier beside each, because a flat list stops answering "what can she do to the
house" somewhere around fifteen rows.
`MatchCapability` widens one way: `house` and `house.lock` both cover
`house.lock.unlock`, and nothing lets a narrower id claim a wider pattern.
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)
@@ -681,31 +630,6 @@ 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
+4 -11
View File
@@ -18,7 +18,6 @@ import (
"sort"
"strings"
"time"
"unicode"
)
// Fact sources. A calendar event reaches the store as a
@@ -154,20 +153,14 @@ func Overlapping(events []Event, from, to time.Time) []Event {
return out
}
// safeKey makes a summary safe to use inside a fact key: letters and digits in
// any script, plus dashes, with space and underscore folded to a dash.
//
// It kept ASCII only until 04-08-2026, and dropped everything else. His
// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to
// "--" and produced the same key on the same day — the second event of the day
// silently overwrote the first (Vikunja #443). Letting the letters through is
// what makes the key identify the event. Migration #18 drops the keys written
// under the old rule; they are re-derived on the next poll.
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
// the day prefix carries the identity and this only disambiguates within a day.
func safeKey(s string) string {
var b strings.Builder
for _, r := range s {
switch {
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-':
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
b.WriteRune(r)
case r == ' ' || r == '_':
b.WriteRune('-')
-19
View File
@@ -139,9 +139,6 @@ func TestSafeKey(t *testing.T) {
{"Hello_World", "Hello-World"},
{"special@#$chars!!", "specialchars"},
{"ALL_CAPS_123", "ALL-CAPS-123"},
// His calendar is Russian. These reduced to "--" and "--" (Vikunja #443).
{"Встреча с Аней", "Встреча-с-Аней"},
{"Обед с мамой", "Обед-с-мамой"},
}
for _, tt := range tests {
if got := safeKey(tt.in); got != tt.want {
@@ -266,19 +263,3 @@ func TestSourceTrust(t *testing.T) {
t.Errorf("Sources() = %v", Sources())
}
}
// Two Russian events on one day must not share a key. They did: safeKey kept
// ASCII only, so both summaries collapsed to their spaces and the second event
// overwrote the first in the store (Vikunja #443).
func TestFactKeyDistinguishesRussianEventsOnOneDay(t *testing.T) {
day := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
a := Event{Summary: "Встреча с Аней", Start: day.Add(10 * time.Hour), End: day.Add(11 * time.Hour)}
b := Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(14 * time.Hour)}
if FactKeyIn(a, time.UTC) == FactKeyIn(b, time.UTC) {
t.Fatalf("both events keyed as %q", FactKeyIn(a, time.UTC))
}
// The day prefix still has to survive, because the store range-scans on it.
if !strings.HasPrefix(FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) {
t.Fatalf("key %q lost the day prefix %q", FactKeyIn(a, time.UTC), KeyPrefixForDay(day))
}
}
+30 -20
View File
@@ -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{
"sat": joinActivities(sat), "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,19 +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))
return say.S(say.HabitSpanDays, map[string]string{
"n": strconv.Itoa(days), "word": pluralDaysRU(days),
})
}
// pluralDaysRU — the Russian count form of "день" for n.
@@ -423,14 +429,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]
+6 -2
View File
@@ -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)
}
}
@@ -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)
}
}
+8 -4
View File
@@ -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, "; "),
})
}
+84
View File
@@ -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": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"]
}
}
}
+164
View File
@@ -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 = say.RegisterFloor(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 nil
}
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)
}
+186
View File
@@ -0,0 +1,186 @@
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"
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,
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.
// These are the exact strings that lived in Go before this file existed.
var actFloor = say.RegisterFloor(map[string]string{
ActDone: "готово.",
ActDoneOut: "готово: {out}",
ActDoneEntity: "команда выполнена для {name}.",
ActConfirm: "выполнить «{name}»? скажи «да» или «нет».",
ActConfirmEntity: "выполнить «{name}» для {entity}? скажи «да» или «нет».",
ActWhich: "какую команду для {name}: {items}?",
ActFail: "не получилось выполнить команду.",
ActFailOut: "не получилось выполнить команду: {out}",
ActFailEntity: "не получилось выполнить команду для {name}.",
ActServerDown: "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён.",
ActWithdrawn: "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools.",
ActNeedsArgs: "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать.",
EcoDenied: "экосистема отклоняет доступ, проверь токен.",
EcoDown: "экосистема недоступна, попробуй ещё раз.",
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, "{entity}"},
{ActWhich, "{name}"}, {ActWhich, "{items}"},
{EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"},
{AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"},
{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 nil
}
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)
}
+114
View File
@@ -0,0 +1,114 @@
{
"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\", \"the ecosystem 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.",
"Placeholders: {name} an entity or capability the caller resolved, {out} the command's own output, {items} a joined list, {count} a number. Entity names and capability ids are interpolated Go-side.",
"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 two 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}» для {entity}? скажи «да» или «нет»."]
},
"act_which": {
"variants": ["какую команду для {name}: {items}?"]
},
"act_fail": {
"variants": ["не получилось выполнить команду.", "команда не выполнилась."]
},
"act_fail_out": {
"variants": ["не получилось выполнить команду: {out}"]
},
"act_fail_entity": {
"variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."]
},
"act_server_down": {
"variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."]
},
"act_withdrawn": {
"fixed": true,
"variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."]
},
"act_needs_args": {
"variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."]
},
"eco_denied": {
"variants": ["экосистема отклоняет доступ, проверь токен."]
},
"eco_down": {
"variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."]
},
"eco_ambiguous": {
"variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"]
},
"eco_unknown_entity": {
"variants": ["не знаю такой сущности.", "такой сущности у меня нет."]
},
"eco_no_nexus": {
"variants": ["не могу связать это с сущностью — Nexus не настроен."]
},
"eco_about_what": {
"variants": ["про что именно спросить?", "про что спросить?"]
},
"eco_recall": {
"variants": ["я помню: {items}"]
},
"attention_none": {
"variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."]
},
"attention_list": {
"variants": ["требует внимания: {items}"]
},
"attention_fail": {
"variants": ["не могу сейчас узнать, что требует внимания."]
},
"attention_none_entity": {
"variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
},
"attention_list_entity": {
"variants": ["по «{name}»: {items}"]
},
"attention_fail_entity": {
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
},
"changes_none": {
"variants": ["нет изменений.", "изменений нет."]
},
"changes_list": {
"variants": ["изменения: {items}"]
},
"changes_fail": {
"variants": ["не могу сейчас узнать об изменениях."]
},
"home_unreachable": {
"variants": ["не смогла достучаться до дома.", "дом не отвечает."]
},
"home_empty": {
"variants": ["дом ничего не отдаёт.", "дом молчит."]
},
"home_on": {
"variants": ["включено: {items}"]
},
"home_dark": {
"variants": ["дом молчит: {count} {word} не отвечают."]
}
}
}
-13
View File
@@ -67,19 +67,6 @@ 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, ""}
+70
View File
@@ -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)
}
}
}
}
+8 -2
View File
@@ -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")
+11 -16
View File
@@ -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)
}
}
+80
View File
@@ -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)
}
}
+140
View File
@@ -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 = say.RegisterFloor(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 nil
}
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)
}
+42
View File
@@ -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": [
"сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
]
}
}
}
+74
View File
@@ -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])
}
}
+27 -18
View File
@@ -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 "вот что я нашла: <notes>" 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 —
// "вот что я нашла: <notes>", 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
+3 -6
View File
@@ -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).
+168
View File
@@ -0,0 +1,168 @@
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.
// These are the exact strings that lived in Go before this file existed.
var queryFloor = say.RegisterFloor(map[string]string{
QueryUnknown: "не знаю.",
QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
QueryFactWhen: "я записала это {when}",
QueryFactValue: "вот что я знаю: {key} — {value}",
QueryFound: "вот что я нашла: {text}",
QueryPageText: "вот что на странице: {text}",
QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.",
QueryPageEmpty: "страница открылась, но читать там нечего.",
QueryFeedsOff: "я пока не читаю ленты — они не настроены.",
QueryFeedsNew: "вот что нового: {items}",
QueryFeedsEmpty: "в лентах пока ничего нового.",
QueryFeedsTopic: "по этой теме в лентах пока ничего.",
QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.",
QueryWeatherOff: "погода не настроена.",
QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.",
QueryNetEmpty: "в сети никого не нашла{tail}.",
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, "{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 nil
}
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)
}
+101
View File
@@ -0,0 +1,101 @@
{
"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.",
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.",
"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": {
"variants": ["я записала это {when}", "записала это {when}"]
},
"fact_value": {
"variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"]
},
"found": {
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
},
"page_text": {
"variants": ["вот что на странице: {text}", "на странице вот это: {text}"]
},
"page_blocked": {
"fixed": true,
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
},
"page_empty": {
"variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."]
},
"feeds_off": {
"variants": ["я пока не читаю ленты — они не настроены."]
},
"feeds_new": {
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
},
"feeds_empty": {
"variants": ["в лентах пока ничего нового.", "в лентах тихо."]
},
"feeds_empty_topic": {
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
},
"weather_now": {
"variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."]
},
"weather_off": {
"variants": ["погода не настроена."]
},
"weather_nolocation": {
"fixed": true,
"variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."]
},
"net_off": {
"fixed": true,
"variants": ["сканирование сети не настроено."]
},
"page_off": {
"fixed": true,
"variants": ["я не читаю страницы — это не настроено."]
},
"net_empty": {
"variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."]
},
"fail_plan": {
"variants": ["не получилось собрать план.", "план не собрался."]
},
"fail_notes": {
"variants": ["не получилось посмотреть записи.", "записи не открылись."]
},
"fail_feeds": {
"variants": ["не получилось посмотреть ленты.", "ленты не открылись."]
},
"fail_calendar": {
"variants": ["не получилось проверить календарь.", "календарь не открылся."]
},
"fail_weather": {
"variants": ["не получилось узнать погоду.", "погода не пришла."]
},
"fail_answer": {
"variants": ["не получилось найти ответ.", "ответ не нашёлся."]
},
"fail_page": {
"variants": ["не получилось прочитать страницу.", "страница не прочиталась."]
},
"fail_netscan": {
"variants": ["не получилось просканировать сеть.", "сеть не просканировалась."]
}
}
}
+5 -3
View File
@@ -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")
+23 -30
View File
@@ -76,46 +76,39 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
}
}
// 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
// and these phrasings carry neither (Vikunja #471).
func TestAgendaCoversOtherDaysAndNamedEvents(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("route(%q): %v", u, err)
t.Fatalf("%q: %v", u, err)
}
if d.Intent != IntentQuery {
t.Errorf("route(%q) = %s, want query", u, d.Intent)
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 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.
func TestAgendaGrammarsLeaveTheWorldAlone(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)
for _, u := range []string{
"когда была битва при ватерлоо",
"когда изобрели телефон",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("route(%q): %v", u, err)
}
if d.Stage == 0 {
t.Errorf("route(%q) was claimed at stage 0 as %s", u, d.Intent)
}
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)
}
}
+1
View File
@@ -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,
+2 -2
View File
@@ -23,13 +23,13 @@
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
{ "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"] },
-247
View File
@@ -1,247 +0,0 @@
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
},
},
}
}
-89
View File
@@ -1,89 +0,0 @@
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")
}
}
+1 -5
View File
@@ -210,11 +210,7 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time)
d.Slots.HasKey = a.Key != ""
case IntentReminder:
d.Intent = IntentReminder
// No utterance fallback here, unlike every other intent below. The
// model returning no text for a reminder means it found no subject,
// and "напомни в 11" is not a subject. Leaving Text empty is what
// lets the gate turn that into a question (Vikunja #383).
d.Slots.Text = a.Text
d.Slots.Text = firstNonEmpty(a.Text, utterance)
case IntentNote:
d.Intent = IntentNote
d.Slots.Text = firstNonEmpty(a.Text, utterance)
-32
View File
@@ -356,35 +356,3 @@ func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) {
t.Fatalf("a fact the parser could key must not clarify: %+v", d)
}
}
// A reminder with a time and no subject must come back empty and gated, not
// backfilled with the raw words. "напомни в 11" carries an hour and nothing to
// say at that hour; parking the utterance in Text made the request look
// complete, so the daemon set a reminder that fires saying "напомни в 11"
// (Vikunja #383).
func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) {
r := newLLMTestRouter(t, `{"intent":"reminder"}`)
d, err := r.Route(context.Background(), "напомни в 11", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Slots.Text != "" {
t.Fatalf("subject backfilled from the utterance: %q", d.Slots.Text)
}
if !d.Clarify {
t.Fatalf("a subjectless reminder was accepted, confidence %v", d.Confidence)
}
}
// The gate is about the subject, not about reminders in general: one that has
// both halves still runs without a question.
func TestLLMReminderWithSubjectIsNotGated(t *testing.T) {
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
d, err := r.Route(context.Background(), "напомни в 11 позвонить маме", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Clarify {
t.Fatalf("a complete reminder was sent back as a question: %+v", d.Slots)
}
}
+1 -15
View File
@@ -147,15 +147,7 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
}
}
// The extractor's Text is the raw utterance, which is the payload for a
// note, a query or a chat turn but not for a reminder — there Text is the
// subject, what she says at the hour. Backfilling it made Text impossible
// to be empty, so StillMissing never reported SlotText and "О чём
// напомнить?" was unaskable; the answer to a question she did manage to
// ask then overwrote the whole request instead of filling one gap
// (Vikunja #383). A reminder with no subject stays empty and is gated
// below into a question.
if d.Slots.Text == "" && d.Intent != IntentReminder {
if d.Slots.Text == "" {
d.Slots.Text = ex.Text
}
// Stage stays 1: it says who decided the route, and that was the LLM.
@@ -185,12 +177,6 @@ func (r *Router) gateLLMDecision(d *Decision) {
if d.Intent == IntentAct && !d.Slots.HasFn && d.Confidence > llmThinConfidence {
d.Confidence = llmThinConfidence
}
// A reminder with no subject: she knows when but not what to say then.
// Setting it anyway fires an empty reminder at the hour, which reads as a
// bug to him and cannot be repaired after the fact. Ask (Vikunja #383).
if d.Intent == IntentReminder && d.Slots.Text == "" && d.Confidence > llmThinConfidence {
d.Confidence = llmThinConfidence
}
if d.Confidence < r.threshold {
d.Clarify = true
}
+61 -23
View File
@@ -182,37 +182,75 @@ func AgendaQueryGrammars() []Grammar {
Pattern: regexp.MustCompile(`(?i)^\s*(что|чего|какие|сколько|во\s+сколько|когда)\s+у\s+меня(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
}
}
// 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{
{
// A plan noun aimed at a named day, with no possessive to anchor
// on: "какие планы на завтра", "что по делам в среду". The rule
// above wants "у меня" and this phrasing never has it, so
// "какие планы на завтра" answered "пока не умею" while "какие
// планы на сегодня" worked (Vikunja #471). The day word is what
// makes it an agenda question rather than a topic.
Name: "plan-day-query",
// Only "план" and "дел". A verb stem like "встреч" would take
// "встречаемся в среду", which is him telling her something, not
// asking.
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
// "что дальше?" — 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,
},
{
// A named event with no calendar word at all: "когда планёрка?",
// "во сколько созвон". He is asking when something on his calendar
// happens, and the noun is the only signal. Closed list, so "когда
// битва при Ватерлоо" is still a world question.
Name: "event-time-query",
Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\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,
},
}
}
// 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
// than a fixed ending.
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
// 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)
}
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
// the intent only: the utterance travels intact and the query chain's own
+184
View File
@@ -0,0 +1,184 @@
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 nil *Deck answers from the
// floor, which is what an unloadable file leaves behind.
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, e)
}
}
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
}
// pick chooses at random, skipping whatever this entry said last time.
func (d *Deck) pick(key string, e Entry) string {
d.mu.Lock()
defer d.mu.Unlock()
choices := e.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, and works on a nil deck because that
// is exactly the case it exists for. The per-family map is the source of truth.
func floorOf(d *Deck, key string) string {
if d != nil && d.floor != nil {
return d.floor[key]
}
return deckFloors[key]
}
// deckFloors — every family's floor literals in one map, so a nil deck still
// finds them. Families register at init; the keys are namespaced by family.
var deckFloors = map[string]string{}
func RegisterFloor(floor map[string]string) map[string]string {
for k, v := range floor {
deckFloors[k] = v
}
return 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
}
+191
View File
@@ -0,0 +1,191 @@
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"
ReasonOverdueDay = "reason_overdue_day"
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, ReasonOverdueDay, 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.
// These are the exact strings that lived in Go before this file existed.
var summaryFloor = RegisterFloor(map[string]string{
PlanRestEmpty: "на сегодня больше ничего не запланировано.",
PlanDayEmpty: "на {date} ничего не запланировано.",
PlanDay: "план на {date}: {items}.",
PlanUncertain: "похоже, {line}",
TasksNone: "задач нет.",
TasksFirst: "сначала: {items}.",
TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.",
ReasonOverdue: "просрочено",
ReasonOverdueDay: "просрочено на день",
ReasonOverdueDays: "просрочено на {n} дн.",
ReasonToday: "сегодня",
ReasonTomorrow: "завтра",
ReasonInDays: "через {n} дн.",
ReasonImportant: "важно",
ReasonUrgent: "срочно",
ReasonStale: "давно в списке",
HabitWeekday: "по {day} ты обычно {items}.",
HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.",
HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {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}"}, {ReasonInDays, "{n}"},
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
{HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
{HabitWeekdayNone, "{day}"},
{HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{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 nil
}
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)
}
+141
View File
@@ -0,0 +1,141 @@
{
"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.",
"Placeholders: {date} a formatted date, {items} a joined list, {day} a weekday name, {span} the stretch of records a habit claim rests on, {n} a count, {word} a Russian count form built Go-side.",
"The count forms (день/дня/дней, задача/задачи/задач) are morphology, not copy. They stay in Go and arrive here through {word}.",
"fixed: true means exactly one variant and no picking. Used where the wording is the distinction: the day that is over versus the day that was empty, and the list of tasks he never confirmed."
],
"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_day": {
"fixed": true,
"variants": ["просрочено на день"]
},
"reason_overdue_days": {
"fixed": true,
"variants": ["просрочено на {n} дн."]
},
"reason_today": {
"fixed": true,
"variants": ["сегодня"]
},
"reason_tomorrow": {
"fixed": true,
"variants": ["завтра"]
},
"reason_in_days": {
"fixed": true,
"variants": ["через {n} дн."]
},
"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": {
"variants": [
"по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по {day} всё как обычно — то же, что и в остальные дни: {items}."
]
},
"habit_weekday_none": {
"variants": [
"по {day} я пока не вижу у тебя ничего постоянного.",
"по {day} у тебя пока ничего постоянного не вижу — записей мало."
]
},
"habit_weekend_both": {
"variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."]
},
"habit_weekend_sat": {
"variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."]
},
"habit_weekend_sun": {
"variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."]
},
"habit_weekend_same": {
"variants": [
"по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по выходным всё как обычно — то же, что и в остальные дни: {items}."
]
},
"habit_weekend_none": {
"variants": [
"по выходным я пока не вижу у тебя ничего постоянного.",
"по выходным у тебя пока ничего постоянного не вижу — записей мало."
]
},
"habit_overall": {
"variants": ["обычно ты {items} — {span}."]
},
"habit_overall_none": {
"variants": [
"я ещё не набрала достаточно записей, чтобы говорить о привычках.",
"записей пока мало — на привычки я так не сошлюсь."
]
},
"habit_span_today": {
"variants": ["по записям за сегодня"]
},
"habit_span_days": {
"variants": ["по записям за последние {n} {word}"]
},
"habit_unglossed": {
"fixed": true,
"variants": ["отмечаешь «{key}»"]
},
"habit_at": {
"fixed": true,
"variants": ["{gloss} около {time}"]
}
}
}
+49
View File
@@ -0,0 +1,49 @@
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)
}
}
}
}
-194
View File
@@ -1,194 +0,0 @@
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
}
-149
View File
@@ -1,149 +0,0 @@
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)
}
}
-32
View File
@@ -208,38 +208,6 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
// list_tasks into something that writes without the row changing by one
// byte. The fingerprint is the declared shape at approval time, so a
// redefinition is a re-approval instead of a silent upgrade.
`DELETE FROM facts
WHERE key LIKE 'calendar_event_%'
AND replace(substr(key, 25), '-', '') = '';`,
// #18 — drop the calendar keys written while safeKey dropped Cyrillic
// (Vikunja #443). Everything after the date prefix was punctuation, so
// every Russian event on one day shared one key and only the last one
// survived. Deleting rather than rewriting: a calendar fact is derived
// data, the next poll writes the day again under keys that identify the
// 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.
}
// migrate applies every migration with a number greater than the DB's current
-33
View File
@@ -47,36 +47,3 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
}
}
// Migration #18 clears the calendar keys written while safeKey dropped
// Cyrillic. Those rows are indistinguishable from real events on read, so
// leaving them would recite one meeting as several (Vikunja #443).
func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
rows := []string{
"calendar_event_20260804_--", // "Встреча с Аней" under the old rule
"calendar_event_20260804_", // a one-word Russian summary
"calendar_event_20260804_Встреча-с-Аней", // the new format
"calendar_event_20260804_Standup", // an ASCII summary, always fine
}
for _, key := range rows {
if _, err := s.db.ExecContext(ctx,
`INSERT INTO facts (ts, kind, key, value, source, confidence) VALUES (0, 'env', ?, 'x', 'poll:caldav', 1.0)`,
key); err != nil {
t.Fatalf("seed %q: %v", key, err)
}
}
if _, err := s.db.ExecContext(ctx, migrations[17]); err != nil {
t.Fatalf("migration 18: %v", err)
}
var got int
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM facts WHERE key LIKE 'calendar_event_%'`).Scan(&got); err != nil {
t.Fatal(err)
}
if got != 2 {
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
}
}
+19 -16
View File
@@ -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,21 @@ func score(it Item, now time.Time) (float64, string) {
bonus = scoreOverdueCap
}
total += scoreOverdue + bonus
reason = "просрочено"
reason = say.S(say.ReasonOverdue, nil)
if late == 1 {
reason = "просрочено на день"
reason = say.S(say.ReasonOverdueDay, nil)
} else if late > 1 {
reason = fmt.Sprintf("просрочено на %d дн.", late)
reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(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)})
default:
total += scoreDueLater
}
@@ -147,9 +150,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 +166,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 +213,22 @@ 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 {
if b.Len() > 0 {
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()
}
-140
View File
@@ -1,140 +0,0 @@
package tool
import (
"sort"
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/smarthome"
)
// Capability ids (Vikunja #452).
//
// A tool row is flat: one name, one scope, one enabled bit. Permission is
// therefore per name, and nothing groups. Hexis has spoken dotted capability
// ids since it existed, so the local surface was the odd one out — and the
// flat shape gets expensive around fifteen rows, when "what can she do to the
// house" stops being a question anyone can answer by reading a list.
//
// A capability id is scope.domain.action: homelab.docker.restart,
// house.lock.unlock, mcp_vikunja.vikunja.delete_task.
//
// DERIVED, not stored, for the same reason the risk tier is (risk.go): a
// derivation is one place to argue with, a column is whatever the last person
// to enable the row happened to type. The name stays the primary key and
// nothing about lookup or execution changes — this is a way to READ the
// allowlist, not a second allowlist.
type Capability struct {
Scope string
Domain string
Action string
}
// String renders the dotted id. An empty segment becomes "unknown" rather than
// collapsing, so an id always has three parts and a prefix match cannot
// accidentally widen.
func (c Capability) String() string {
return capSegment(c.Scope) + "." + capSegment(c.Domain) + "." + capSegment(c.Action)
}
func capSegment(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, ".", "_")
s = strings.ReplaceAll(s, " ", "_")
if s == "" {
return "unknown"
}
return s
}
// CapabilityOf derives the id of a tool row.
//
// The domain is the thing acted on and the action is what is done to it, read
// off whichever dispatch shape the row uses:
//
// - a house row: the Home Assistant entity domain and the service, so
// light.kitchen + turn_off becomes house.light.turn_off. Its scope is
// "house" whatever the row says, because the entity id is what decides
// what it touches.
// - an MCP row: the server handle and the remote tool name.
// - a process row: the program (path stripped) and its first subcommand, or
// the tool name when the argv carries no second word.
func CapabilityOf(t ipc.Tool) Capability {
if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok {
domain := entityID
if i := strings.Index(entityID, "."); i > 0 {
domain = entityID[:i]
}
return Capability{Scope: "house", Domain: domain, Action: service}
}
if server, remote, ok := mcp.ParseCmd(t.Cmd); ok {
return Capability{Scope: "mcp_" + server, Domain: server, Action: remote}
}
scope := t.Scope
if scope == "" {
scope = "homelab"
}
if len(t.Cmd) == 0 {
// A proposal has no argv yet. It still gets an id, because "what did
// she ask for" is exactly the question the proposed list answers.
return Capability{Scope: scope, Domain: "unknown", Action: t.Name}
}
program := t.Cmd[0]
if i := strings.LastIndex(program, "/"); i >= 0 {
program = program[i+1:]
}
action := t.Name
if len(t.Cmd) > 1 && !strings.HasPrefix(t.Cmd[1], "-") {
action = t.Cmd[1]
}
return Capability{Scope: scope, Domain: program, Action: action}
}
// MatchCapability reports whether an id matches a pattern. A pattern is a
// dotted id whose segments may be "*", and a pattern with fewer segments than
// the id matches every id under it: "house" and "house.*" both cover
// house.lock.unlock.
//
// Prefix widening is deliberate and one-directional. "house.lock" covers every
// action on the locks; nothing lets a narrower id claim a wider pattern.
func MatchCapability(pattern string, c Capability) bool {
want := strings.Split(strings.ToLower(strings.TrimSpace(pattern)), ".")
got := strings.Split(c.String(), ".")
if len(want) > len(got) {
return false
}
for i, w := range want {
if w == "*" || w == "" {
continue
}
if w != got[i] {
return false
}
}
return true
}
// GroupByDomain buckets rows by "scope.domain" and returns the buckets in a
// stable order, which is what makes the allowlist readable past the point
// where a flat list stops being.
func GroupByDomain(tools []ipc.Tool) []CapabilityGroup {
byKey := map[string][]ipc.Tool{}
for _, t := range tools {
c := CapabilityOf(t)
byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)] = append(byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)], t)
}
out := make([]CapabilityGroup, 0, len(byKey))
for k, v := range byKey {
sort.Slice(v, func(i, j int) bool { return v[i].Name < v[j].Name })
out = append(out, CapabilityGroup{Prefix: k, Tools: v})
}
sort.Slice(out, func(i, j int) bool { return out[i].Prefix < out[j].Prefix })
return out
}
// CapabilityGroup — one scope.domain and the rows under it.
type CapabilityGroup struct {
Prefix string
Tools []ipc.Tool
}
-92
View File
@@ -1,92 +0,0 @@
package tool
import (
"testing"
"github.com/kami/maven/internal/ipc"
)
func TestCapabilityOfDescribesTheRow(t *testing.T) {
cases := []struct {
name string
tool ipc.Tool
want string
}{
{
"a process with a subcommand",
ipc.Tool{Name: "restart", Scope: "homelab", Cmd: []string{"docker", "restart"}},
"homelab.docker.restart",
},
{
"a program with a path and a flag",
ipc.Tool{Name: "backup", Scope: "homelab", Cmd: []string{"/usr/local/bin/borg", "-v"}},
"homelab.borg.backup",
},
{
"the house",
ipc.Tool{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}},
"house.lock.unlock",
},
{
"an mcp tool",
ipc.Tool{Name: "vikunja_delete_task", Cmd: []string{"mcp", "vikunja", "delete_task"}},
"mcp_vikunja.vikunja.delete_task",
},
{
"a proposal with no command yet",
ipc.Tool{Name: "перезапусти", Scope: "homelab"},
"homelab.unknown.перезапусти",
},
}
for _, c := range cases {
if got := CapabilityOf(c.tool).String(); got != c.want {
t.Errorf("%s: %q; want %q", c.name, got, c.want)
}
}
}
// A dotted id always has three segments, so a prefix pattern cannot widen by
// accident onto a row whose scope happens to be empty.
func TestCapabilityStringAlwaysHasThreeSegments(t *testing.T) {
if got := (Capability{}).String(); got != "unknown.unknown.unknown" {
t.Errorf("empty capability = %q", got)
}
if got := (Capability{Scope: "home lab", Domain: "a.b", Action: "X"}).String(); got != "home_lab.a_b.x" {
t.Errorf("segments not folded: %q", got)
}
}
func TestMatchCapabilityWidensOneWay(t *testing.T) {
c := CapabilityOf(ipc.Tool{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}})
for _, p := range []string{"house", "house.lock", "house.lock.unlock", "house.*.unlock", "*.lock"} {
if !MatchCapability(p, c) {
t.Errorf("%q did not match %s", p, c)
}
}
for _, p := range []string{"homelab", "house.light", "house.lock.lock", "house.lock.unlock.now"} {
if MatchCapability(p, c) {
t.Errorf("%q matched %s", p, c)
}
}
}
func TestGroupByDomainIsStable(t *testing.T) {
tools := []ipc.Tool{
{Name: "restart", Scope: "homelab", Cmd: []string{"docker", "restart"}},
{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}},
{Name: "logs", Scope: "homelab", Cmd: []string{"docker", "logs"}},
}
groups := GroupByDomain(tools)
if len(groups) != 2 {
t.Fatalf("%d groups; want 2", len(groups))
}
if groups[0].Prefix != "homelab.docker" || len(groups[0].Tools) != 2 {
t.Errorf("first group %+v; want homelab.docker with 2 rows", groups[0])
}
if groups[0].Tools[0].Name != "logs" {
t.Errorf("rows not sorted: %+v", groups[0].Tools)
}
if groups[1].Prefix != "house.lock" {
t.Errorf("second group %q; want house.lock", groups[1].Prefix)
}
}
-145
View File
@@ -1,145 +0,0 @@
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,
}
// RiskOf derives the tier of an enabled tool row.
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
}
-81
View File
@@ -1,81 +0,0 @@
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)
}
}
+2 -21
View File
@@ -67,12 +67,6 @@ 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
@@ -127,12 +121,7 @@ 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; 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.
// destructive tool with confirmed=false ⇒ ErrNeedsConfirm.
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) {
@@ -144,15 +133,7 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm
if t.Status != "enabled" {
return "", ErrNotEnabled
}
// 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 {
if t.Destructive && !confirmed {
return "", ErrNeedsConfirm
}
// An MCP row is a call to a configured server, not a process. Everything
+11 -11
View File
@@ -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)
}
}