Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a21478f36 | |||
| 958d2a2fc8 | |||
| 7db139b83e | |||
| 0987dabfc4 | |||
| 947506c7b8 | |||
| 6c67e61962 | |||
| 0990f32808 | |||
| d41878c2b1 | |||
| e023638135 | |||
| 0d52344d27 | |||
| 5bd303788b | |||
| afac8fb670 | |||
| 908d92a7e8 | |||
| 43f2c37538 | |||
| 6d3f5b5b01 | |||
| eda1112f3b |
+2
-3
@@ -16,7 +16,6 @@ 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"
|
||||
)
|
||||
@@ -36,10 +35,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 phraser.Ack(phraser.FailAck, nil), true
|
||||
return "не получилось отметить.", true
|
||||
}
|
||||
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||
return phraser.Ack(phraser.AckNudge, nil), true
|
||||
return "отлично, отметила.", true
|
||||
}
|
||||
|
||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||
|
||||
@@ -40,7 +40,6 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -59,14 +58,10 @@ 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)
|
||||
}
|
||||
if reply == "" {
|
||||
return phraser.ChatFallback()
|
||||
return "поговорили."
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
)
|
||||
@@ -51,31 +50,37 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
// destructive: park it and ask. The next utterance answers.
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return phraser.A(phraser.ActConfirm, map[string]string{"name": 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 "это я из голоса не выполню — после него ничего не вернуть. запусти сам, если правда надо."
|
||||
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 phraser.A(phraser.ActServerDown, nil)
|
||||
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
||||
case errors.Is(err, mcp.ErrToolGone):
|
||||
return phraser.A(phraser.ActWithdrawn, nil)
|
||||
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
||||
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 phraser.A(phraser.ActNeedsArgs, nil)
|
||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
if out != "" {
|
||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||
return "не получилось выполнить команду: " + firstLine(out)
|
||||
}
|
||||
return phraser.A(phraser.ActFail, nil)
|
||||
return "не получилось выполнить команду."
|
||||
}
|
||||
if out != "" {
|
||||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||
return "готово: " + firstLine(out)
|
||||
}
|
||||
return phraser.A(phraser.ActDone, nil)
|
||||
return "готово."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ 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"
|
||||
)
|
||||
@@ -15,7 +14,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 phraser.Ack(phraser.FailFactUnparsed, nil)
|
||||
return "не разобрала, что записать — попробуй иначе."
|
||||
}
|
||||
// A question is never a fact about him (#470). "какая последняя версия
|
||||
// языка Go?" used to land here, and the value stored was whatever the
|
||||
@@ -63,7 +62,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 phraser.Ack(phraser.FailFact, nil)
|
||||
return "не получилось сохранить факт."
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Standing lists on the voice path (Vikunja #453).
|
||||
//
|
||||
// Three halves, mirroring what task capture already does: an add that runs at
|
||||
// the top of actionNote, a read-back query source, and a crossing-off that runs
|
||||
// on the same note path because "всё купил" is note-shaped.
|
||||
//
|
||||
// These read h.dataStore rather than the CoreAPI. A list is local to the core
|
||||
// and nothing outside it writes one: the web UI has no list page, no reach
|
||||
// files groceries, and the digestion worker does not read the table. When
|
||||
// something outside mavend needs to add to a list, the ipc seam is what it
|
||||
// grows through — the intake rules that CaptureTaskReq documents are about
|
||||
// shared intake, and there is none here yet.
|
||||
//
|
||||
// Nothing here speaks unprompted. A list is answered when asked about.
|
||||
|
||||
// captureListFromNote claims the turn when the utterance adds to, clears, or
|
||||
// crosses one item off a list. ("", false) hands the turn back to the note path.
|
||||
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||
if h.dataStore == nil {
|
||||
return "", false
|
||||
}
|
||||
// Clearing is read before removing on purpose: "всё купил" and "купил
|
||||
// молоко" start with the same word, and only the second one names an item.
|
||||
if list, ok := router.ParseListClear(dec.Utterance); ok {
|
||||
n, err := h.dataStore.ClearList(ctx, list, h.now())
|
||||
if err != nil {
|
||||
log.Printf("voice: clear list: %v", err)
|
||||
return "не получилось обновить список.", true
|
||||
}
|
||||
if n == 0 {
|
||||
return "в списке и так ничего не было.", true
|
||||
}
|
||||
return "вычеркнула всё, список пустой.", true
|
||||
}
|
||||
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
|
||||
if reply, ok := h.removeListItem(ctx, cap); ok {
|
||||
return reply, true
|
||||
}
|
||||
// Nothing on the list by that name. "купил новый ноутбук" is a note and
|
||||
// must stay one, so the turn goes back rather than claiming a removal
|
||||
// that removed nothing.
|
||||
return "", false
|
||||
}
|
||||
cap, ok := router.ParseListCapture(dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
|
||||
List: cap.List,
|
||||
Item: cap.Item,
|
||||
Source: "tap:voice",
|
||||
CreatedTs: h.now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: add list item: %v", err)
|
||||
return "не получилось добавить в список.", true
|
||||
}
|
||||
if !res.Created {
|
||||
return cap.Item + " уже в списке.", true
|
||||
}
|
||||
return "добавила в список: " + cap.Item + ".", true
|
||||
}
|
||||
|
||||
// removeListItem crosses one named item off. It reports false when the list
|
||||
// holds nothing by that name, which is what keeps the marker words from
|
||||
// swallowing ordinary notes.
|
||||
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
|
||||
items, err := h.dataStore.ListItems(ctx, cap.List, "")
|
||||
if err != nil {
|
||||
log.Printf("voice: list items: %v", err)
|
||||
return "", false
|
||||
}
|
||||
want := store.NormalizeTaskText(cap.Item)
|
||||
for _, li := range items {
|
||||
if store.NormalizeTaskText(li.Item) != want {
|
||||
continue
|
||||
}
|
||||
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
|
||||
log.Printf("voice: cross off list item: %v", err)
|
||||
return "не получилось обновить список.", true
|
||||
}
|
||||
return "вычеркнула: " + li.Item + ".", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// queryList — "что в списке покупок?", "что мне купить?".
|
||||
//
|
||||
// A query source, so it sits in querySources and either claims the turn or
|
||||
// passes it on. It is before the recall sources for the reason every specific
|
||||
// source is: the notes pass would otherwise answer a list question with
|
||||
// whatever note is nearest.
|
||||
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
list, ok := router.ParseListQuery(t.dec.Utterance)
|
||||
if !ok || h.dataStore == nil {
|
||||
return "", false
|
||||
}
|
||||
items, err := h.dataStore.ListItems(ctx, list, "")
|
||||
if err != nil {
|
||||
log.Printf("voice: list items: %v", err)
|
||||
return "не получилось посмотреть список.", true
|
||||
}
|
||||
return formatListRU(list, items), true
|
||||
}
|
||||
|
||||
// formatListRU reads a list aloud. One sentence, comma-separated, because a
|
||||
// shopping list is heard in a shop and a numbered recital is unusable there.
|
||||
func formatListRU(list string, items []store.ListItem) string {
|
||||
name := "списке " + listGenitive(list)
|
||||
if len(items) == 0 {
|
||||
return "в " + name + " пусто."
|
||||
}
|
||||
names := make([]string, 0, len(items))
|
||||
for _, li := range items {
|
||||
names = append(names, li.Item)
|
||||
}
|
||||
return "в " + name + ": " + strings.Join(names, ", ") + "."
|
||||
}
|
||||
|
||||
// listGenitive puts a list tag into the case "список <…>" needs. Russian
|
||||
// declines the noun and she must not say "в списке покупки".
|
||||
func listGenitive(list string) string {
|
||||
switch list {
|
||||
case "покупки":
|
||||
return "покупок"
|
||||
case "аптека":
|
||||
return "аптеки"
|
||||
case "хозяйство":
|
||||
return "хозяйства"
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
||||
|
||||
func listHandler(t *testing.T) *reactiveHandler {
|
||||
t.Helper()
|
||||
return &reactiveHandler{dataStore: newTestStore(t), now: listNow}
|
||||
}
|
||||
|
||||
func 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)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -18,19 +17,25 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||
return reply
|
||||
}
|
||||
// A standing list is neither work nor recall (Vikunja #453). Checked here
|
||||
// for the same reason and at the same cost: before the embedding is paid
|
||||
// for, and it passes the turn straight back when no marker matches.
|
||||
if reply, ok := h.captureListFromNote(ctx, dec); ok {
|
||||
return reply
|
||||
}
|
||||
// embed the note text with the same model the classifier uses, persist
|
||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||
// facts — no predicate reads it (spec's two-memory split).
|
||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
return "не получилось сохранить заметку."
|
||||
}
|
||||
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 phraser.Ack(phraser.FailNote, nil)
|
||||
return "не получилось сохранить заметку."
|
||||
}
|
||||
// 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).
|
||||
|
||||
+44
-51
@@ -85,6 +85,11 @@ var querySources = []querySource{
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
||||
// about the shopping list, and the recall pass would otherwise answer it
|
||||
// from an old note about the shop. Its matcher needs an explicit list
|
||||
// marker, so "надо бы съездить в магазин" is untouched.
|
||||
{name: "list", answer: (*reactiveHandler).queryList},
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// a question about the feeds she reads, and general knowledge would answer
|
||||
@@ -147,9 +152,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// The previous question cannot be re-asked for another day. Saying so
|
||||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||||
// truth is that she never looked.
|
||||
return phraser.Q(phraser.QueryOtherDay, nil)
|
||||
return "про другой день так не отвечу — спроси целиком."
|
||||
}
|
||||
return phraser.Q(phraser.QueryUnknown, nil)
|
||||
return "не знаю."
|
||||
}
|
||||
|
||||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||||
@@ -167,11 +172,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
if dec.Slots.HasTime {
|
||||
// The query asks about timing — the fact's own timestamp is the
|
||||
// answer it's looking for. Format as a natural reply.
|
||||
return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
|
||||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
||||
}
|
||||
// General fact reference: describe what we know.
|
||||
if dec.Utterance == "" {
|
||||
return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
|
||||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
||||
}
|
||||
// The utterance still carries the question; fall through to normal RAG
|
||||
// with the resolved key in context.
|
||||
@@ -197,7 +202,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
plan, err := h.api.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return phraser.Q(phraser.QueryFailPlan, nil), true
|
||||
return "не получилось собрать план.", true
|
||||
}
|
||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
@@ -242,7 +247,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return phraser.Q(phraser.QueryFailNotes, nil), true
|
||||
return "не получилось посмотреть записи.", true
|
||||
}
|
||||
obs := make([]memory.Observation, 0, len(facts))
|
||||
for _, f := range facts {
|
||||
@@ -281,7 +286,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||
// letting general knowledge answer "что нового?" would be an invented
|
||||
// news bulletin.
|
||||
return phraser.Q(phraser.QueryFeedsOff, nil), true
|
||||
return "я пока не читаю ленты — они не настроены.", true
|
||||
}
|
||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||
// used to push the newest headline out of the window, and she answered "в
|
||||
@@ -289,7 +294,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return phraser.Q(phraser.QueryFailFeeds, nil), true
|
||||
return "не получилось посмотреть ленты.", true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
@@ -306,11 +311,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if q.Category != "" {
|
||||
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||
return "по этой теме в лентах пока ничего.", true
|
||||
}
|
||||
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||
return "в лентах пока ничего нового.", true
|
||||
}
|
||||
return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
|
||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||
}
|
||||
|
||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||
@@ -324,7 +329,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
log.Printf("voice: calendar events: %v", err)
|
||||
return phraser.Q(phraser.QueryFailCalendar, nil), true
|
||||
return "не получилось проверить календарь.", true
|
||||
}
|
||||
// Provenance travels with each event. A work meeting relayed off a phone
|
||||
// notification (source ambient:notif, #126) is stored below full confidence
|
||||
@@ -367,12 +372,9 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// 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
|
||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||
// swallow "сколько устройств в сети?" before recall has looked.
|
||||
return "", false
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
@@ -385,23 +387,19 @@ 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 phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", 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 phraser.Q(phraser.QueryWeatherOff, nil), true
|
||||
return "погода не настроена.", true
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("voice: weather: %v", err)
|
||||
return phraser.Q(phraser.QueryFailWeather, nil), true
|
||||
return "не получилось узнать погоду.", true
|
||||
}
|
||||
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
||||
"location": w.Location,
|
||||
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
||||
"condition": w.Condition,
|
||||
}), true
|
||||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
||||
}
|
||||
|
||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||||
@@ -411,7 +409,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 phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
return "не получилось найти ответ.", true
|
||||
}
|
||||
t.vec = vec
|
||||
return "", false
|
||||
@@ -452,13 +450,7 @@ 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" {
|
||||
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 != "":
|
||||
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
||||
return reply, true
|
||||
}
|
||||
}
|
||||
@@ -480,7 +472,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 phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
return "не получилось найти ответ.", true
|
||||
}
|
||||
t.notes = notes
|
||||
noteScores := make([]float64, len(notes))
|
||||
@@ -505,7 +497,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: phrase query: %v", err)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
|
||||
reply = "вот что я нашла: " + texts[0]
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -527,24 +519,25 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
return "", false
|
||||
}
|
||||
if h.crawler == nil {
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
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 phraser.Q(phraser.QueryPageBlocked, nil), true
|
||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||
}
|
||||
log.Printf("voice: web: %v", err)
|
||||
return phraser.Q(phraser.QueryFailPage, nil), true
|
||||
return "не получилось прочитать страницу.", true
|
||||
}
|
||||
if page.Text == "" {
|
||||
return phraser.Q(phraser.QueryPageEmpty, nil), true
|
||||
return "страница открылась, но читать там нечего.", 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
|
||||
@@ -554,7 +547,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 phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
|
||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -620,7 +613,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 phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
|
||||
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -701,7 +694,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 phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
||||
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -731,7 +724,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 phraser.Q(phraser.QueryPersonalNone, nil), true
|
||||
return "не знаю — не нашла у тебя такой записи.", true
|
||||
}
|
||||
|
||||
// personalMarkers — first-person POSSESSION, not first person generally.
|
||||
@@ -799,15 +792,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 phraser.Q(phraser.QueryUnknown, nil), true
|
||||
return "не знаю.", 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 phraser.Q(phraser.QueryUnknown, nil), true
|
||||
return "не знаю.", true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -22,13 +21,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
}
|
||||
}
|
||||
if !dec.Slots.HasTime {
|
||||
return phraser.Ack(phraser.FailReminderTime, nil)
|
||||
return "не получилось разобрать время напоминания."
|
||||
}
|
||||
}
|
||||
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 phraser.Ack(phraser.FailReminder, nil)
|
||||
return "не получилось поставить напоминание."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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"
|
||||
@@ -41,18 +40,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: capture task: %v", err)
|
||||
return phraser.Ack(phraser.FailTask, nil), true
|
||||
return "не получилось записать задачу.", 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 phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
|
||||
return "поняла, беру в работу: " + cap.Text, true
|
||||
}
|
||||
if !resp.Created {
|
||||
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
|
||||
return "это уже в списке.", true
|
||||
}
|
||||
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
|
||||
return "записала: " + cap.Text, true
|
||||
}
|
||||
|
||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -109,7 +108,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 !phraser.IsAck(phraser.FailTask, nil, reply) {
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want an honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,3 +465,39 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,17 +125,13 @@ func TestQueryWebPassesWithoutAURL(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) {
|
||||
// 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) {
|
||||
h := buildWebHandler(nil)
|
||||
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)
|
||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +156,7 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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"
|
||||
)
|
||||
@@ -148,8 +147,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||
}
|
||||
if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
||||
t.Errorf("reply = %q, want the honest failure", reply)
|
||||
if reply != "не получилось собрать план." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
)
|
||||
@@ -145,10 +144,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 phraser.A(phraser.AttentionFail, nil)
|
||||
return "не могу сейчас узнать, что требует внимания."
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return phraser.A(phraser.AttentionNone, nil)
|
||||
return "ничего не требует внимания."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
@@ -176,7 +175,7 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
}
|
||||
}
|
||||
}
|
||||
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||
return "требует внимания: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// listChangesCapability reads the recent-changes feed.
|
||||
@@ -193,10 +192,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 phraser.A(phraser.ChangesFail, nil)
|
||||
return "не могу сейчас узнать об изменениях."
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return phraser.A(phraser.ChangesNone, nil)
|
||||
return "нет изменений."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||
var parts []string
|
||||
@@ -205,7 +204,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 phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||
return "изменения: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||
@@ -231,12 +230,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
subject = dec.Slots.Text
|
||||
}
|
||||
if subject == "" {
|
||||
return phraser.A(phraser.EcoAboutWhat, nil)
|
||||
return "про что именно спросить?"
|
||||
}
|
||||
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 phraser.A(phraser.EcoNoNexus, nil)
|
||||
return "не могу связать это с сущностью — Nexus не настроен."
|
||||
}
|
||||
|
||||
started := h.now()
|
||||
@@ -249,15 +248,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 phraser.A(phraser.EcoDenied, nil)
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, nil)
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
return phraser.A(phraser.EcoUnknownEntity, nil)
|
||||
return "не знаю такой сущности."
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = subject
|
||||
@@ -269,7 +268,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 phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
items, scoped := scopedToEntity(items, entityID)
|
||||
if !scoped {
|
||||
@@ -280,7 +279,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 phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||
"entity_id": entityID, "count": len(items),
|
||||
@@ -304,9 +303,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
|
||||
return "по «" + displayName + "» ничего нет."
|
||||
}
|
||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
@@ -371,7 +370,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
|
||||
out := "я помню: " + strings.Join(parts, ", ")
|
||||
if more {
|
||||
out += ", и это не всё"
|
||||
}
|
||||
@@ -521,18 +520,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 phraser.A(phraser.EcoDenied, nil)
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
// 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 phraser.A(phraser.EcoDown, nil)
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
map[string]any{"candidates": len(ambiguous)})
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||
@@ -551,9 +550,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 phraser.A(phraser.EcoDenied, nil)
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, nil)
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
@@ -585,7 +584,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
for _, m := range matches {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
|
||||
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
||||
}
|
||||
matched := matches[0]
|
||||
|
||||
@@ -603,7 +602,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 phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName})
|
||||
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
||||
}
|
||||
|
||||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||||
@@ -623,7 +622,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 phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
||||
return "не получилось выполнить команду для " + displayName + "."
|
||||
}
|
||||
// One record per hop: the second write this used to make said the same
|
||||
// thing under a different key, in a different shape.
|
||||
@@ -631,5 +630,5 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
"entity_id": entityID, "entity_name": displayName,
|
||||
"capability": capName, "causation_id": causationID,
|
||||
})
|
||||
return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
|
||||
return "команда выполнена для " + displayName + "."
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||
}
|
||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||
if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
|
||||
if !strings.Contains(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 || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
|
||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ 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
|
||||
@@ -107,7 +106,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 phraser.Q(phraser.QueryFailNetscan, nil), true
|
||||
return "не получилось просканировать сеть.", 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
|
||||
@@ -117,7 +116,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
if len(res.Hosts) == 0 {
|
||||
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
|
||||
return "в сети никого не нашла" + tail + ".", true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
|
||||
@@ -10,8 +10,6 @@ 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) {
|
||||
@@ -171,22 +169,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
)
|
||||
|
||||
// The persona checks, run before she speaks (Vikunja #399).
|
||||
//
|
||||
// RunChecks and RunTalkChecks only ever ran from the eval package, so
|
||||
// everything the fixtures measured was offline knowledge: we could say "about
|
||||
// one reply in three is broken" and still ship every one of them. This runs the
|
||||
// cheap half of that on the live path, and replaces a failing message with the
|
||||
// deterministic floor.
|
||||
//
|
||||
// Which checks: the unambiguous string tests only — feminine self-reference,
|
||||
// how she addresses him, and a leaked-reasoning test. Not length, which is
|
||||
// path-specific, and not ontopic, which compares against fragments the fixture
|
||||
// supplies and runtime does not have. Not hisgender either — see guardSpoken.
|
||||
//
|
||||
// No retry. A retry doubles the latency on the exact turn that is already going
|
||||
// badly, and on the nudge path the moment has passed.
|
||||
//
|
||||
// The known cost, written down because it is real: a wrongly flagged good reply
|
||||
// is replaced by a flatter stub one. That is the right trade — a stub sentence
|
||||
// is dull, a leaked reasoning trace is broken — but it means these checks can
|
||||
// no longer be tuned for sensitivity alone.
|
||||
|
||||
// checkLeak — the name reported when the model's scaffolding reaches the text.
|
||||
const checkLeak = "leak"
|
||||
|
||||
// leakPatterns — reasoning and protocol that belongs to the model, not to him.
|
||||
// The resident model is a Thinking variant, so an unclosed reasoning block is
|
||||
// the failure mode, not a hypothetical (Vikunja #398).
|
||||
var leakPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)<\s*/?\s*think`),
|
||||
regexp.MustCompile(`(?i)thinking\s*(process|:)`),
|
||||
regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
|
||||
// Raw contract JSON: the parser already unwraps a good one, so a body that
|
||||
// still carries the keys is one it could not read.
|
||||
regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
|
||||
// The persona block quoted back at him.
|
||||
regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
|
||||
}
|
||||
|
||||
// checkPersonaLeak reports whether the model's own scaffolding is in the text.
|
||||
func checkPersonaLeak(body string) (string, bool) {
|
||||
for _, re := range leakPatterns {
|
||||
if m := re.FindString(body); m != "" {
|
||||
return "leaked " + strings.TrimSpace(m), false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// personaRejects counts what the guard caught, by check name, so the real
|
||||
// production rate is knowable rather than inferred from the fixture.
|
||||
var personaRejects = struct {
|
||||
mu sync.Mutex
|
||||
by map[string]int
|
||||
}{by: map[string]int{}}
|
||||
|
||||
func personaRejectCounts() map[string]int {
|
||||
personaRejects.mu.Lock()
|
||||
defer personaRejects.mu.Unlock()
|
||||
out := make(map[string]int, len(personaRejects.by))
|
||||
for k, v := range personaRejects.by {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// guardSpoken checks a phrased message. It returns the failed check and false
|
||||
// when the message must not be said; path names the caller, for the log.
|
||||
//
|
||||
// An empty message passes: the caller already treats that as a failure and
|
||||
// falls back on its own, and reporting it as a persona breach would put a
|
||||
// misleading line in the count.
|
||||
func guardSpoken(path, body string) (string, bool) {
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return "", true
|
||||
}
|
||||
if detail, ok := checkPersonaLeak(body); !ok {
|
||||
return rejectSpoken(path, checkLeak, detail, body), false
|
||||
}
|
||||
// Feminine and address only. HisGender is not run here: it reads a
|
||||
// sentence-initial feminine verb with no pronoun — "записала, что ты выпил
|
||||
// воды" — as a woman being addressed, when it is her own correct
|
||||
// self-reference. Offline that is a point of score; on this path it would
|
||||
// replace a good reply with a stub one on every fact she confirms.
|
||||
for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
|
||||
if !r.Pass {
|
||||
return rejectSpoken(path, r.Name, r.Detail, body), false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// rejectSpoken logs what she nearly said and counts it. The whole text, not a
|
||||
// prefix: the point of the log line is that the failure can be read back later
|
||||
// and argued with.
|
||||
func rejectSpoken(path, check, detail, body string) string {
|
||||
personaRejects.mu.Lock()
|
||||
personaRejects.by[check]++
|
||||
personaRejects.mu.Unlock()
|
||||
log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
|
||||
return check
|
||||
}
|
||||
|
||||
// guardNudge checks a phrased nudge and falls back to the deterministic floor
|
||||
// when it fails. The nudge path, unlike the reply path, cannot ask again: the
|
||||
// tick has already decided she speaks, so the choice is the floor's wording or
|
||||
// a broken sentence.
|
||||
func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
|
||||
if _, ok := guardSpoken("nudge", pn.Body); ok {
|
||||
return pn
|
||||
}
|
||||
stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
|
||||
if err != nil {
|
||||
// The Stub is templates over the candidate and does not fail. If it
|
||||
// somehow does, the model's text is still what the rule decided to
|
||||
// say, and saying nothing is the worse outcome.
|
||||
return pn
|
||||
}
|
||||
return stub
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
)
|
||||
|
||||
func TestGuardPassesWhatSheShouldSay(t *testing.T) {
|
||||
good := []string{
|
||||
"записала: купить хлеб.",
|
||||
"поняла, напомню в 11:00.",
|
||||
"ты не пил воду с утра.",
|
||||
"я рада, что получилось.",
|
||||
"",
|
||||
}
|
||||
for _, body := range good {
|
||||
if check, ok := guardSpoken("test", body); !ok {
|
||||
t.Errorf("guardSpoken(%q) rejected on %s", body, check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardStopsWhatSheShouldNot(t *testing.T) {
|
||||
bad := []struct {
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"<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")
|
||||
}
|
||||
}
|
||||
@@ -121,8 +121,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
||||
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
||||
})
|
||||
reply := askQuery(t, h, q)
|
||||
if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
|
||||
t.Errorf("reply %q, want the note read back", reply)
|
||||
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
|
||||
t.Errorf("reply %q, want %q", reply, want)
|
||||
}
|
||||
// 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); !phraser.IsUnknownFallback(reply) {
|
||||
if reply := askQuery(t, h, q); reply != "не знаю." {
|
||||
t.Errorf("reply %q, want silence", reply)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||
@@ -33,10 +32,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
||||
return "", false
|
||||
}
|
||||
val := "false"
|
||||
reply := phraser.Ack(phraser.AckQuietOff, nil)
|
||||
reply := "тихий режим выключен."
|
||||
if on {
|
||||
val = "true"
|
||||
reply = phraser.Ack(phraser.AckQuietOn, nil)
|
||||
reply = "тихий режим включён. буду реже напоминать."
|
||||
}
|
||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: h.now(),
|
||||
@@ -47,7 +46,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 phraser.Ack(phraser.FailQuiet, nil), true
|
||||
return "не получилось переключить тихий режим.", true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -30,5 +30,10 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
||||
if err != nil || out == "" {
|
||||
return r.stub.Reply(d)
|
||||
}
|
||||
// The persona checks, on the live path (personaguard.go). A reply that
|
||||
// leaks reasoning or calls him "вы" is worse than a flat one.
|
||||
if _, ok := guardSpoken("reply", out); !ok {
|
||||
return r.stub.Reply(d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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"
|
||||
)
|
||||
@@ -30,12 +29,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
|
||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
|
||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
||||
}
|
||||
|
||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
@@ -43,16 +42,6 @@ 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)
|
||||
|
||||
@@ -4,12 +4,10 @@ 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"
|
||||
)
|
||||
@@ -142,10 +140,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 phraser.A(phraser.HomeUnreachable, nil), true
|
||||
return "не смогла достучаться до дома.", true
|
||||
}
|
||||
if len(ents) == 0 {
|
||||
return phraser.A(phraser.HomeEmpty, nil), true
|
||||
return "дом ничего не отдаёт.", true
|
||||
}
|
||||
var on []string
|
||||
var sensors []string
|
||||
@@ -179,7 +177,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 := phraser.A(phraser.HomeOn, map[string]string{"items": strings.Join(shown, ", ")})
|
||||
line := "включено: " + strings.Join(shown, ", ")
|
||||
if rest > 0 {
|
||||
line += fmt.Sprintf(" и ещё %d", rest)
|
||||
}
|
||||
@@ -187,10 +185,7 @@ 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 phraser.A(phraser.HomeDark, map[string]string{
|
||||
"count": strconv.Itoa(dark),
|
||||
"word": hostWord(dark),
|
||||
}), true
|
||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
|
||||
default:
|
||||
parts = append(parts, "всё выключено")
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -52,10 +51,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 phraser.Ack(phraser.FailSnooze, nil), true
|
||||
return "не получилось отложить.", true
|
||||
}
|
||||
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||
return phraser.Ack(phraser.AckSnooze, nil), true
|
||||
return "хорошо, вернусь к этому позже.", true
|
||||
}
|
||||
|
||||
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
||||
|
||||
@@ -176,6 +176,9 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
t.queueNudge(ctx, cand, state, now)
|
||||
} else {
|
||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||
if err == nil {
|
||||
pn = guardNudge(pn, *cand)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||
} else {
|
||||
|
||||
@@ -379,9 +379,7 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||
// is an agenda question and must not.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
// After the agenda rules: "расскажи, что у меня сегодня" is an agenda
|
||||
// question first and a narrative request second (Vikunja #498).
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
grammars = append(grammars, router.ListGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
|
||||
@@ -23,11 +23,7 @@ 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.
|
||||
//
|
||||
// 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() }
|
||||
const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
||||
|
||||
// phraseWorld asks the world model, or reports the gap.
|
||||
//
|
||||
@@ -58,11 +54,7 @@ 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
|
||||
}
|
||||
|
||||
@@ -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 !phraser.IsUnknownFallback(reply) {
|
||||
if 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-3
@@ -24,7 +24,12 @@ type runner struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ready bool
|
||||
http *http.Client
|
||||
// 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
|
||||
}
|
||||
|
||||
func newRunner(bin string, args []string, readyURL string) *runner {
|
||||
@@ -70,13 +75,18 @@ func (r *runner) start() error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.cmd, r.ready = cmd, false
|
||||
r.cmd, r.ready, r.yielding = cmd, false, false
|
||||
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
r.mu.Lock()
|
||||
r.cmd, r.ready = nil, false
|
||||
yielded := r.yielding
|
||||
r.cmd, r.ready, r.yielding = nil, false, 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
|
||||
@@ -90,6 +100,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+16
-7
@@ -27,6 +27,7 @@ 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"
|
||||
)
|
||||
@@ -746,6 +747,8 @@ 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))
|
||||
|
||||
@@ -756,9 +759,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>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||
<div class=scroll><table><tr><th>name</th><th>capability</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
|
||||
{{range .Proposed}}<tr>
|
||||
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
|
||||
<td><code>{{.Name}}</code></td><td><code>{{capability .}}</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}}">
|
||||
@@ -779,14 +782,16 @@ const toolsHTML = `{{template "shellTop" "tools"}}
|
||||
</section>
|
||||
<section class=card>
|
||||
<h2 class=card-title>enabled <span class=badge>{{len .Enabled}}</span></h2>
|
||||
{{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>
|
||||
{{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>
|
||||
<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>
|
||||
<button class=btn>disable</button></form></td></tr>{{end}}</table></div>{{end}}
|
||||
{{else}}<div class=empty>
|
||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
|
||||
<div>no tools enabled</div>
|
||||
@@ -1459,12 +1464,16 @@ 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, servers}); err != nil {
|
||||
}{msg, proposed, enabled, tool.GroupByDomain(enabled), servers}); err != nil {
|
||||
log.Printf("tools render: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,10 @@ 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
|
||||
|
||||
@@ -293,6 +293,57 @@ 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)
|
||||
@@ -630,6 +681,31 @@ add a new principle; it applied the existing one at smaller and smaller scope.
|
||||
|
||||
---
|
||||
|
||||
## A list is the fourth shape
|
||||
|
||||
Facts, notes and tasks were the three append-only shapes. `list_items` is the
|
||||
fourth (Vikunja #453): an item, a status, and a list tag.
|
||||
|
||||
It is not a task. Milk is not work, nothing prioritises it, and the ranker must
|
||||
not start counting groceries as outstanding errands. It is not a fact either,
|
||||
because it claims nothing about the world. What it is, is a set that grows and
|
||||
shrinks.
|
||||
|
||||
The property that makes the separate table worth it: no predicate reads a list.
|
||||
Nothing ranks it, nothing nudges about it, the digestion worker ignores it. So
|
||||
two people adding to the same list at once cost nothing — there is no order to
|
||||
disagree about and no lifecycle past crossed-off.
|
||||
|
||||
The unique index is the tasks one, per list, and live rows only. Saying "молоко"
|
||||
twice before the shop is one line; saying it again next week, after the last one
|
||||
was crossed off, is a new line.
|
||||
|
||||
Spoken, it is four turns: add, read back, cross one item off, cross the lot off.
|
||||
All four are matched deterministically in `internal/router/list.go` and all four
|
||||
run at stage 0, because an add and a read-back are cheap and should not depend on
|
||||
the resident model having a good turn. Crossing one item off claims the turn only
|
||||
when the list holds that item, which is what keeps "купил новый ноутбук" a note.
|
||||
|
||||
## Calendar
|
||||
|
||||
Integration with **Radicale** (self-hosted CalDAV), not Nextcloud. Scope is
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Fact sources. A calendar event reaches the store as a
|
||||
@@ -153,14 +154,20 @@ func Overlapping(events []Event, from, to time.Time) []Event {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func safeKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-':
|
||||
b.WriteRune(r)
|
||||
case r == ' ' || r == '_':
|
||||
b.WriteRune('-')
|
||||
|
||||
@@ -139,6 +139,9 @@ 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 {
|
||||
@@ -263,3 +266,19 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
+20
-30
@@ -3,11 +3,8 @@ package memory
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// Behavioural memory — "what do I usually do?" (Vikunja #254).
|
||||
@@ -340,14 +337,13 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
|
||||
day := weekdayRU[int(wd)%7]
|
||||
acts := p.Weekly[wd]
|
||||
if len(acts) > 0 {
|
||||
return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
|
||||
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
|
||||
}
|
||||
if len(p.Everyday) > 0 {
|
||||
return say.S(say.HabitWeekdaySame, map[string]string{
|
||||
"day": day, "items": joinActivities(p.Everyday),
|
||||
})
|
||||
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
day, joinActivities(p.Everyday))
|
||||
}
|
||||
return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
|
||||
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
|
||||
}
|
||||
|
||||
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
|
||||
@@ -359,17 +355,19 @@ func (p Profile) FormatWeekendRU() string {
|
||||
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
|
||||
switch {
|
||||
case len(sat) > 0 && len(sun) > 0:
|
||||
return say.S(say.HabitWeekendBoth, map[string]string{
|
||||
"sat": joinActivities(sat), "sun": joinActivities(sun),
|
||||
})
|
||||
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
|
||||
joinActivities(sat), joinActivities(sun))
|
||||
case len(sat) > 0:
|
||||
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
|
||||
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
|
||||
joinActivities(sat))
|
||||
case len(sun) > 0:
|
||||
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
|
||||
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
|
||||
joinActivities(sun))
|
||||
case len(p.Everyday) > 0:
|
||||
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
|
||||
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
|
||||
joinActivities(p.Everyday))
|
||||
}
|
||||
return say.S(say.HabitWeekendNone, nil)
|
||||
return "по выходным я пока не вижу у тебя ничего постоянного."
|
||||
}
|
||||
|
||||
// FormatOverallRU reads back the habits that hold across the whole week, and
|
||||
@@ -380,23 +378,19 @@ 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 say.S(say.HabitOverallNone, nil)
|
||||
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
|
||||
}
|
||||
return say.S(say.HabitOverall, map[string]string{
|
||||
"items": joinActivities(p.All), "span": p.spanRU(),
|
||||
})
|
||||
return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), 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 say.S(say.HabitSpanToday, nil)
|
||||
return "по записям за сегодня"
|
||||
}
|
||||
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
|
||||
return say.S(say.HabitSpanDays, map[string]string{
|
||||
"n": strconv.Itoa(days), "word": pluralDaysRU(days),
|
||||
})
|
||||
return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
|
||||
}
|
||||
|
||||
// pluralDaysRU — the Russian count form of "день" for n.
|
||||
@@ -429,18 +423,14 @@ 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 = say.S(say.HabitUnglossed, map[string]string{
|
||||
"key": strings.ReplaceAll(a.Key, "_", " "),
|
||||
})
|
||||
gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
|
||||
}
|
||||
if !a.HasTypical {
|
||||
parts[i] = gloss
|
||||
continue
|
||||
}
|
||||
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),
|
||||
})
|
||||
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
|
||||
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
// habitHistory — n weeks of the same weekday, at the given local time.
|
||||
@@ -72,7 +70,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(); !say.IsS(say.HabitOverallNone, nil, got) {
|
||||
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
|
||||
t.Errorf("empty profile reads %q", got)
|
||||
}
|
||||
}
|
||||
@@ -205,9 +203,7 @@ 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 !say.IsS(say.HabitWeekdaySame, map[string]string{
|
||||
"day": "средам", "items": "пьёшь воду около 13:30",
|
||||
}, wed) {
|
||||
if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
|
||||
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/say"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -165,20 +164,17 @@ 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 say.S(say.PlanRestEmpty, nil)
|
||||
return "на сегодня больше ничего не запланировано."
|
||||
}
|
||||
return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")})
|
||||
return fmt.Sprintf("на %s ничего не запланировано.", 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 = say.S(say.PlanUncertain, map[string]string{"line": line})
|
||||
line = "похоже, " + line
|
||||
}
|
||||
parts[i] = line
|
||||
}
|
||||
return say.S(say.PlanDay, map[string]string{
|
||||
"date": p.Date.Format("02.01.2006"),
|
||||
"items": strings.Join(parts, "; "),
|
||||
})
|
||||
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
{
|
||||
"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": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
{
|
||||
"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} не отвечают."]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,19 @@ func RunChecks(c Case, body, mood string) []Result {
|
||||
}
|
||||
}
|
||||
|
||||
// Feminine, HisGender and Address expose three checks one at a time, so the
|
||||
// daemon can run them on a phrased message before he hears it (Vikunja #399).
|
||||
// Only these three: they are unambiguous string tests with nothing to compare
|
||||
// against, while length is path-specific and ontopic needs the fixture's
|
||||
// expected fragments, which do not exist at runtime.
|
||||
func Feminine(body string) Result { return checkFeminine(body) }
|
||||
|
||||
// HisGender — see checkHisGender.
|
||||
func HisGender(body string) Result { return checkHisGender(body) }
|
||||
|
||||
// Address — see checkAddress.
|
||||
func Address(body string) Result { return checkAddress(body) }
|
||||
|
||||
func checkMood(mood string) Result {
|
||||
if Moods[mood] {
|
||||
return Result{CheckMood, true, ""}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,12 +78,6 @@ 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"`
|
||||
@@ -98,8 +92,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 != TalkSchemaVersion {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
|
||||
if f.SchemaVersion != SchemaVersion {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
if len(f.Cases) == 0 {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
||||
|
||||
@@ -142,13 +142,19 @@ func TestLLMTalkBaseline(t *testing.T) {
|
||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||
defer p.Close()
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
model, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatalf("no model at %s: %v", base, err)
|
||||
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.Logf("scoring model %s at %s", model, base)
|
||||
|
||||
@@ -163,11 +169,10 @@ func TestLLMTalkBaseline(t *testing.T) {
|
||||
}
|
||||
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
{
|
||||
"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": [
|
||||
"сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -27,11 +26,6 @@ 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
|
||||
@@ -434,11 +428,8 @@ 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. 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.
|
||||
// compose a natural answer. Falls back to "вот что я нашла: <notes>" on any
|
||||
// LLM error — better to give the raw data than silence.
|
||||
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
|
||||
@@ -448,15 +439,13 @@ 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 {
|
||||
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err)
|
||||
}
|
||||
if resp == "" {
|
||||
return UnknownFallback(), errEmptyResponse
|
||||
if err != nil || resp == "" {
|
||||
return "не знаю.", nil
|
||||
}
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr)
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
return "не знаю.", nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
@@ -468,12 +457,13 @@ 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.
|
||||
cause := err
|
||||
if cause == nil {
|
||||
cause = perr
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
}
|
||||
return SourcesFallback(strings.Join(notes, "; ")),
|
||||
fmt.Errorf("phrase query (evidence): %w", cause)
|
||||
if len(notes) == 1 {
|
||||
return "вот что я нашла: " + notes[0], nil
|
||||
}
|
||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
@@ -482,9 +472,8 @@ 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. 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.
|
||||
// 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.
|
||||
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
||||
sys := chatSystemPrompt(p.cfg.ContextBlock)
|
||||
msgs := []chatMsg{
|
||||
@@ -501,11 +490,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
|
||||
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
||||
if err != nil {
|
||||
return ChatFallback(), fmt.Errorf("phrase chat: %w", err)
|
||||
log.Printf("phraser: PhraseChat: %v", err)
|
||||
return "поговорили.", nil
|
||||
}
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
return ChatFallback(), fmt.Errorf("phrase chat: %w", perr)
|
||||
log.Printf("phraser: PhraseChat: %v", perr)
|
||||
return "поговорили.", nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
|
||||
@@ -70,15 +70,18 @@ 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 ChatFallback(), nil
|
||||
return "поговорили.", 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 UnknownFallback(), nil
|
||||
return "не знаю.", nil
|
||||
}
|
||||
return SourcesFallback(strings.Join(notes, "; ")), nil
|
||||
if len(notes) == 1 {
|
||||
return "вот что я нашла: " + notes[0], nil
|
||||
}
|
||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
||||
}
|
||||
|
||||
// Close implements Phraser.Close (no-op for the stub).
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
{
|
||||
"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": ["не получилось просканировать сеть.", "сеть не просканировалась."]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,12 +215,10 @@ 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, and since
|
||||
// Vikunja #397 it reports the error next to that fallback so a measuring
|
||||
// caller can tell "no model" from "bad phrasing".
|
||||
// Phrasing degrades to its fallback instead of failing the turn.
|
||||
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if !errors.Is(err, ErrNoBackend) {
|
||||
t.Errorf("PhraseChat error = %v; want ErrNoBackend alongside the fallback", err)
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseChat after a total failure returned an error: %v", err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("PhraseChat returned empty; the fallback must still say something")
|
||||
|
||||
@@ -76,39 +76,46 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The two shapes that carried no question mark and no interrogative, so the
|
||||
// model saw them first and called them facts (Vikunja #498).
|
||||
func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
|
||||
// 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) {
|
||||
r := agendaRouter(t)
|
||||
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||
for _, u := range []string{
|
||||
"что дальше?",
|
||||
"и что там дальше",
|
||||
"what's next?",
|
||||
"расскажи про битву при Ватерлоо",
|
||||
"объясни как работает дизель",
|
||||
"опиши Ватерлоо",
|
||||
"какие планы на завтра?",
|
||||
"какие планы на послезавтра",
|
||||
"что по делам в среду",
|
||||
"какие планы на выходные",
|
||||
"когда планёрка?",
|
||||
"во сколько созвон",
|
||||
"когда будет совещание",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", u, err)
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
if d.Intent != IntentQuery || d.Stage != 0 {
|
||||
t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage)
|
||||
if d.Intent != IntentQuery {
|
||||
t.Errorf("route(%q) = %s, want query", u, d.Intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
r := agendaRouter(t)
|
||||
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||
d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Stage == 0 && d.Intent == IntentQuery {
|
||||
t.Errorf("stage 0 claimed a capture: %+v", d)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,7 +236,6 @@ 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,
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Standing lists, matched deterministically (Vikunja #453).
|
||||
//
|
||||
// Same posture as task capture in task.go and for the same reason: the intent
|
||||
// enum is a contract shared with the relabelling prompt, so a list is not an
|
||||
// eighth intent. It is a note-shaped or query-shaped utterance carrying an
|
||||
// explicit marker, and the marker is a lookup.
|
||||
//
|
||||
// The markers are deliberately explicit. "молоко закончилось" is an
|
||||
// observation about the world and belongs in a note; only an instruction to
|
||||
// put something on a list puts it there.
|
||||
|
||||
// listStems — the lists he can name, by the stem every case form shares.
|
||||
// Russian declines the tag ("список покупок", "в покупки", "в покупках"), so
|
||||
// matching a stem is what makes those the same list.
|
||||
var listStems = []struct{ stem, list string }{
|
||||
{"покуп", "покупки"},
|
||||
{"продукт", "покупки"},
|
||||
{"магазин", "покупки"},
|
||||
{"аптек", "аптека"},
|
||||
{"хозяйств", "хозяйство"},
|
||||
{"shopping", "покупки"},
|
||||
{"groceries", "покупки"},
|
||||
{"pharmacy", "аптека"},
|
||||
}
|
||||
|
||||
// listCapturePrefixes — an instruction to add to a list. Longest match wins.
|
||||
var listCapturePrefixes = []string{
|
||||
"добавь в список",
|
||||
"добавь в покупки",
|
||||
"добавь к покупкам",
|
||||
"запиши в список",
|
||||
"внеси в список",
|
||||
"положи в список",
|
||||
"в список покупок",
|
||||
"add to the list",
|
||||
"add to my list",
|
||||
"add to the shopping list",
|
||||
"put on the list",
|
||||
}
|
||||
|
||||
// listQueryPrefixes — an ask to read a list back.
|
||||
var listQueryPrefixes = []string{
|
||||
"что в списке",
|
||||
"что в покупках",
|
||||
"что мне купить",
|
||||
"что нужно купить",
|
||||
"что надо купить",
|
||||
"покажи список",
|
||||
"прочитай список",
|
||||
"список покупок",
|
||||
"мой список",
|
||||
"what is on the list",
|
||||
"what's on the list",
|
||||
"read me the list",
|
||||
"show me the list",
|
||||
"shopping list",
|
||||
}
|
||||
|
||||
// listClearPhrases — the whole list is got. One sentence, one turn.
|
||||
var listClearPhrases = []string{
|
||||
"всё купил",
|
||||
"все купил",
|
||||
"всё взял",
|
||||
"все взял",
|
||||
"очисти список",
|
||||
"очисти покупки",
|
||||
"список пустой",
|
||||
"got everything",
|
||||
"clear the list",
|
||||
}
|
||||
|
||||
// listRemovePrefixes — one item off the list.
|
||||
var listRemovePrefixes = []string{
|
||||
"вычеркни",
|
||||
"убери из списка",
|
||||
"убери со списка",
|
||||
"купил",
|
||||
"взял",
|
||||
"cross off",
|
||||
"remove from the list",
|
||||
}
|
||||
|
||||
// listTrimCut — punctuation and connectives to strip off a parsed remainder.
|
||||
const listTrimCut = " .,;:!?—-"
|
||||
|
||||
// ListCapture — a parsed list instruction: which list, and the item.
|
||||
type ListCapture struct {
|
||||
List string
|
||||
Item string
|
||||
}
|
||||
|
||||
// ParseListCapture reports whether an utterance puts something on a list, and
|
||||
// returns the list tag and the item. A marker with nothing usable after it is
|
||||
// not a capture: there is no item in "добавь в список покупок".
|
||||
func ParseListCapture(text string) (ListCapture, bool) {
|
||||
rest, ok := afterLongestPrefix(text, listCapturePrefixes)
|
||||
if !ok {
|
||||
return ListCapture{}, false
|
||||
}
|
||||
list, rest := takeListTag(rest)
|
||||
rest = strings.Trim(rest, listTrimCut)
|
||||
if rest == "" {
|
||||
return ListCapture{}, false
|
||||
}
|
||||
return ListCapture{List: list, Item: rest}, true
|
||||
}
|
||||
|
||||
// ParseListQuery reports whether an utterance asks for a list, and which one.
|
||||
func ParseListQuery(text string) (string, bool) {
|
||||
rest, ok := afterLongestPrefix(text, listQueryPrefixes)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
list, _ := takeListTag(rest)
|
||||
return list, true
|
||||
}
|
||||
|
||||
// ParseListClear reports whether an utterance crosses off a whole list.
|
||||
func ParseListClear(text string) (string, bool) {
|
||||
lower := strings.ToLower(strings.Trim(strings.TrimSpace(text), listTrimCut))
|
||||
for _, p := range listClearPhrases {
|
||||
if lower == p || strings.HasPrefix(lower, p+" ") {
|
||||
list, _ := takeListTag(strings.TrimSpace(lower[len(p):]))
|
||||
return list, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ParseListRemove reports whether an utterance takes one named item off a
|
||||
// list, and returns the list and the item.
|
||||
//
|
||||
// The item is required. "купил" on its own is him reporting he shopped, which
|
||||
// ParseListClear reads first, and it must not fall through to here and remove
|
||||
// nothing while sounding like it did.
|
||||
func ParseListRemove(text string) (ListCapture, bool) {
|
||||
rest, ok := afterLongestPrefix(text, listRemovePrefixes)
|
||||
if !ok {
|
||||
return ListCapture{}, false
|
||||
}
|
||||
list, rest := takeListTag(rest)
|
||||
rest = strings.Trim(rest, listTrimCut)
|
||||
for _, lead := range []string{"из списка ", "со списка ", "из ", "from the list "} {
|
||||
rest = strings.TrimPrefix(rest, lead)
|
||||
}
|
||||
rest = strings.Trim(rest, listTrimCut)
|
||||
if rest == "" {
|
||||
return ListCapture{}, false
|
||||
}
|
||||
return ListCapture{List: list, Item: rest}, true
|
||||
}
|
||||
|
||||
// afterLongestPrefix matches the longest prefix in the table and returns what
|
||||
// follows it, trimmed. Lowercasing does not change the byte length of Russian
|
||||
// or English letters, so the index carries over to the original text.
|
||||
func afterLongestPrefix(text string, prefixes []string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
lower := strings.ToLower(trimmed)
|
||||
best := ""
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(lower, p) && len(p) > len(best) {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", false
|
||||
}
|
||||
return strings.Trim(trimmed[len(best):], listTrimCut), true
|
||||
}
|
||||
|
||||
// takeListTag reads a list name off the front of the remainder and returns the
|
||||
// list plus what is left. A remainder naming no list is the default list, and
|
||||
// nothing is consumed — "добавь в список молоко" names no list and the item is
|
||||
// молоко.
|
||||
func takeListTag(rest string) (string, string) {
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) == 0 {
|
||||
return "покупки", ""
|
||||
}
|
||||
head := strings.ToLower(strings.Trim(fields[0], listTrimCut))
|
||||
// "в список покупок" leaves "покупок"; "в списке" leaves nothing.
|
||||
if head == "список" || head == "списке" || head == "списка" || head == "list" {
|
||||
fields = fields[1:]
|
||||
if len(fields) == 0 {
|
||||
return "покупки", ""
|
||||
}
|
||||
head = strings.ToLower(strings.Trim(fields[0], listTrimCut))
|
||||
}
|
||||
for _, s := range listStems {
|
||||
if strings.HasPrefix(head, s.stem) {
|
||||
return s.list, strings.Join(fields[1:], " ")
|
||||
}
|
||||
}
|
||||
return "покупки", strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
// ListGrammars — stage 0 for the list (Vikunja #453).
|
||||
//
|
||||
// Both patterns match everything and the Build functions are the real filter,
|
||||
// the shape the wake-word act grammar already uses: the parsers above are the
|
||||
// definition of a list utterance and duplicating them as regexps would give
|
||||
// two answers to one question.
|
||||
//
|
||||
// Why stage 0 at all: an add and a read-back are deterministic and cheap, and
|
||||
// leaving them to the model means "добавь в список покупок молоко" lands as an
|
||||
// act or a fact on the turns the model has a bad day. The action handlers still
|
||||
// re-parse, so a list turn that arrives by any other route still works.
|
||||
func ListGrammars() []Grammar {
|
||||
anything := regexp.MustCompile(`(?s)^(.*)$`)
|
||||
return []Grammar{
|
||||
{
|
||||
Name: "list-query",
|
||||
Pattern: anything,
|
||||
Build: func(m []string) (Decision, bool) {
|
||||
if _, ok := ParseListQuery(m[1]); !ok {
|
||||
return Decision{}, false
|
||||
}
|
||||
return Decision{Stage: 0, Intent: IntentQuery, Confidence: 1.0}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list-capture",
|
||||
Pattern: anything,
|
||||
Build: func(m []string) (Decision, bool) {
|
||||
text := m[1]
|
||||
_, add := ParseListCapture(text)
|
||||
_, clear := ParseListClear(text)
|
||||
if !add && !clear {
|
||||
return Decision{}, false
|
||||
}
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentNote,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Text: strings.TrimSpace(text)},
|
||||
}, true
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseListCaptureReadsListAndItem(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
list string
|
||||
item string
|
||||
}{
|
||||
{"добавь в список покупок молоко", "покупки", "молоко"},
|
||||
{"добавь в список молоко", "покупки", "молоко"},
|
||||
{"Добавь в покупки хлеб и яйца", "покупки", "хлеб и яйца"},
|
||||
{"запиши в список аптеки бинт", "аптека", "бинт"},
|
||||
{"добавь в список хозяйства лампочки.", "хозяйство", "лампочки"},
|
||||
{"add to the shopping list milk", "покупки", "milk"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := ParseListCapture(c.utterance)
|
||||
if !ok {
|
||||
t.Errorf("ParseListCapture(%q) did not claim it", c.utterance)
|
||||
continue
|
||||
}
|
||||
if got.List != c.list || got.Item != c.item {
|
||||
t.Errorf("ParseListCapture(%q) = %+v; want list %q item %q", c.utterance, got, c.list, c.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A marker with no item is not a capture, and an utterance that only mentions
|
||||
// shopping is not one either.
|
||||
func TestParseListCapturePasses(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"добавь в список покупок",
|
||||
"добавь в список",
|
||||
"молоко закончилось",
|
||||
"надо бы съездить в магазин",
|
||||
"добавь в задачи купить молоко",
|
||||
} {
|
||||
if got, ok := ParseListCapture(u); ok {
|
||||
t.Errorf("ParseListCapture(%q) claimed it as %+v", u, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListQueryNamesTheList(t *testing.T) {
|
||||
cases := []struct{ utterance, list string }{
|
||||
{"что в списке покупок?", "покупки"},
|
||||
{"что в списке", "покупки"},
|
||||
{"что мне купить", "покупки"},
|
||||
{"покажи список аптеки", "аптека"},
|
||||
{"what's on the list", "покупки"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
list, ok := ParseListQuery(c.utterance)
|
||||
if !ok {
|
||||
t.Errorf("ParseListQuery(%q) did not claim it", c.utterance)
|
||||
continue
|
||||
}
|
||||
if list != c.list {
|
||||
t.Errorf("ParseListQuery(%q) = %q; want %q", c.utterance, list, c.list)
|
||||
}
|
||||
}
|
||||
if _, ok := ParseListQuery("какие у меня задачи"); ok {
|
||||
t.Error("ParseListQuery claimed a task question")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListClearAndRemove(t *testing.T) {
|
||||
if list, ok := ParseListClear("всё купил"); !ok || list != "покупки" {
|
||||
t.Errorf("ParseListClear = %q, %v; want покупки, true", list, ok)
|
||||
}
|
||||
if list, ok := ParseListClear("очисти список аптеки"); !ok || list != "аптека" {
|
||||
t.Errorf("ParseListClear = %q, %v; want аптека, true", list, ok)
|
||||
}
|
||||
if _, ok := ParseListClear("купил молоко"); ok {
|
||||
t.Error("ParseListClear claimed a single item")
|
||||
}
|
||||
got, ok := ParseListRemove("вычеркни молоко")
|
||||
if !ok || got.Item != "молоко" || got.List != "покупки" {
|
||||
t.Errorf("ParseListRemove = %+v, %v; want молоко on покупки", got, ok)
|
||||
}
|
||||
if got, ok := ParseListRemove("убери из списка аптеки бинт"); !ok || got.Item != "бинт" || got.List != "аптека" {
|
||||
t.Errorf("ParseListRemove = %+v, %v; want бинт on аптека", got, ok)
|
||||
}
|
||||
if _, ok := ParseListRemove("вычеркни"); ok {
|
||||
t.Error("ParseListRemove claimed a marker with no item")
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,11 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time)
|
||||
d.Slots.HasKey = a.Key != ""
|
||||
case IntentReminder:
|
||||
d.Intent = IntentReminder
|
||||
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
||||
// 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
|
||||
case IntentNote:
|
||||
d.Intent = IntentNote
|
||||
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
||||
|
||||
@@ -356,3 +356,35 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,15 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
|
||||
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
|
||||
}
|
||||
}
|
||||
if d.Slots.Text == "" {
|
||||
// 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 {
|
||||
d.Slots.Text = ex.Text
|
||||
}
|
||||
// Stage stays 1: it says who decided the route, and that was the LLM.
|
||||
@@ -177,6 +185,12 @@ 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
|
||||
}
|
||||
|
||||
+23
-61
@@ -182,75 +182,37 @@ 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{
|
||||
{
|
||||
// "что дальше?" — 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|[?!.]|$)`),
|
||||
// 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|[?!.]|$)`),
|
||||
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,
|
||||
// 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// entertainmentNouns — what "расскажи" asks for when it is not asking for
|
||||
// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make
|
||||
// something up, which is the one case where inventing is the right answer
|
||||
// (fixture ru-chat-003).
|
||||
var entertainmentNouns = []string{
|
||||
"анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки",
|
||||
"joke", "jokes", "story",
|
||||
}
|
||||
|
||||
// narrativeQueryBuild — the narrative shape is a query unless he also said one
|
||||
// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him
|
||||
// asking for a note, and stage 0 must not take either off the cascade.
|
||||
func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||
rest := ""
|
||||
if len(m) > 3 {
|
||||
rest = m[3]
|
||||
}
|
||||
for _, t := range planTokens(rest) {
|
||||
for _, v := range captureVerbs {
|
||||
if t == v {
|
||||
return Decision{}, false
|
||||
}
|
||||
}
|
||||
for _, v := range entertainmentNouns {
|
||||
if t == v {
|
||||
return Decision{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return agendaQueryBuild(m)
|
||||
}
|
||||
// dayWordPattern — the day words an agenda question can name. Weekdays appear
|
||||
// in the accusative and prepositional forms the questions actually use ("в
|
||||
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
||||
// than a fixed ending.
|
||||
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
|
||||
|
||||
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
||||
// the intent only: the utterance travels intact and the query chain's own
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
{
|
||||
"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}"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// List items — the fourth append-only shape (Vikunja #453).
|
||||
//
|
||||
// A list is a standing set of short strings under a tag: покупки, аптека,
|
||||
// хозяйство. It is not work and it is not a claim about the world, which is
|
||||
// why it is neither a task nor a fact. Nothing here is prioritised, nothing
|
||||
// nudges about it, and the digestion worker does not read it. The only two
|
||||
// things a list does are grow and shrink.
|
||||
//
|
||||
// The consequence that made it worth a table: because no predicate touches a
|
||||
// list item, several people adding to the same list at once cost nothing. There
|
||||
// is no ranking to disagree about and no lifecycle beyond crossed-off.
|
||||
const (
|
||||
// ListItemOpen — on the list.
|
||||
ListItemOpen = "open"
|
||||
// ListItemDone — bought, taken, crossed off.
|
||||
ListItemDone = "done"
|
||||
// ListItemDropped — removed without being got.
|
||||
ListItemDropped = "dropped"
|
||||
)
|
||||
|
||||
// DefaultList — the list a capture lands on when he names none. Almost every
|
||||
// spoken list item is groceries, and asking "в какой список?" for the common
|
||||
// case would be a nag.
|
||||
const DefaultList = "покупки"
|
||||
|
||||
// ListItem — one line on one list.
|
||||
type ListItem struct {
|
||||
ID int64
|
||||
CreatedTs time.Time
|
||||
List string
|
||||
Item string
|
||||
Source string
|
||||
Status string
|
||||
ResolvedTs *time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
ErrListItemNotFound = errors.New("store: list item not found")
|
||||
ErrListItemEmpty = errors.New("store: list item is empty")
|
||||
ErrListItemStatus = errors.New("store: invalid list item status")
|
||||
)
|
||||
|
||||
// NormalizeListName folds a list tag to its dedupe form. Lists are named out
|
||||
// loud, so "Покупки" and "покупки " are the same list.
|
||||
func NormalizeListName(s string) string {
|
||||
n := NormalizeTaskText(s)
|
||||
if n == "" {
|
||||
return DefaultList
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AddListItem puts an item on a list, or returns the existing row when the same
|
||||
// item is already on it. Created says which happened, so the caller can say
|
||||
// "уже есть" instead of pretending it wrote something.
|
||||
func (s *Store) AddListItem(ctx context.Context, li ListItem) (CaptureResult, error) {
|
||||
item := strings.TrimSpace(li.Item)
|
||||
if item == "" {
|
||||
return CaptureResult{}, ErrListItemEmpty
|
||||
}
|
||||
list := NormalizeListName(li.List)
|
||||
norm := NormalizeTaskText(item)
|
||||
created := li.CreatedTs
|
||||
if created.IsZero() {
|
||||
created = time.Now()
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO list_items (created_ts, list, item, norm, source, status)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
created.UnixMilli(), list, item, norm, li.Source, ListItemOpen)
|
||||
if err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("add list item: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("add list item: rows affected: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("add list item: last insert id: %w", err)
|
||||
}
|
||||
return CaptureResult{ID: id, Created: true}, nil
|
||||
}
|
||||
var id int64
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT id FROM list_items WHERE list = ? AND norm = ? AND status = ?`,
|
||||
list, norm, ListItemOpen).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return CaptureResult{}, ErrListItemNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("add list item: lookup: %w", err)
|
||||
}
|
||||
return CaptureResult{ID: id}, nil
|
||||
}
|
||||
|
||||
// ListItems reads one list in the order it was added. An empty status reads the
|
||||
// open items, which is what reading the list aloud means.
|
||||
func (s *Store) ListItems(ctx context.Context, list, status string) ([]ListItem, error) {
|
||||
if status == "" {
|
||||
status = ListItemOpen
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, created_ts, list, item, source, status, resolved_ts
|
||||
FROM list_items WHERE list = ? AND status = ?
|
||||
ORDER BY created_ts, id`,
|
||||
NormalizeListName(list), status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ListItem
|
||||
for rows.Next() {
|
||||
var (
|
||||
li ListItem
|
||||
created int64
|
||||
resolved sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&li.ID, &created, &li.List, &li.Item, &li.Source, &li.Status, &resolved); err != nil {
|
||||
return nil, fmt.Errorf("list items: scan: %w", err)
|
||||
}
|
||||
li.CreatedTs = time.UnixMilli(created)
|
||||
if resolved.Valid {
|
||||
t := time.UnixMilli(resolved.Int64)
|
||||
li.ResolvedTs = &t
|
||||
}
|
||||
out = append(out, li)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list items: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetListItemStatus crosses an item off, or removes it. Moving an item that is
|
||||
// already resolved is not an error — crossing off twice is the same list.
|
||||
func (s *Store) SetListItemStatus(ctx context.Context, id int64, status string, at time.Time) error {
|
||||
if status != ListItemOpen && status != ListItemDone && status != ListItemDropped {
|
||||
return fmt.Errorf("%w: %q", ErrListItemStatus, status)
|
||||
}
|
||||
var resolved sql.NullInt64
|
||||
if status != ListItemOpen {
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
resolved = sql.NullInt64{Int64: at.UnixMilli(), Valid: true}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE id = ?`,
|
||||
status, resolved, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set list item status: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set list item status: rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrListItemNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearList crosses off every open item on a list and reports how many. This is
|
||||
// "всё купил", which is one sentence and must not become one turn per item.
|
||||
func (s *Store) ClearList(ctx context.Context, list string, at time.Time) (int, error) {
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE list = ? AND status = ?`,
|
||||
ListItemDone, at.UnixMilli(), NormalizeListName(list), ListItemOpen)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("clear list: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("clear list: rows affected: %w", err)
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var listNow = time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestAddListItemDedupesTheOpenList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
first, err := s.AddListItem(ctx, ListItem{Item: "молоко", Source: "tap:voice", CreatedTs: listNow})
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if !first.Created {
|
||||
t.Fatal("the first молоко did not create a row")
|
||||
}
|
||||
again, err := s.AddListItem(ctx, ListItem{Item: " Молоко ", Source: "tap:voice", CreatedTs: listNow})
|
||||
if err != nil {
|
||||
t.Fatalf("add again: %v", err)
|
||||
}
|
||||
if again.Created {
|
||||
t.Error("молоко was added twice")
|
||||
}
|
||||
if again.ID != first.ID {
|
||||
t.Errorf("second add points at %d; want the existing %d", again.ID, first.ID)
|
||||
}
|
||||
if _, err := s.AddListItem(ctx, ListItem{Item: " "}); !errors.Is(err, ErrListItemEmpty) {
|
||||
t.Errorf("empty item: %v; want ErrListItemEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A crossed-off item does not block the next one: buying milk again next week
|
||||
// is a new line, the way saying an errand again is a new task.
|
||||
func TestCrossedOffItemComesBack(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
first, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow})
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if err := s.SetListItemStatus(ctx, first.ID, ListItemDone, listNow); err != nil {
|
||||
t.Fatalf("cross off: %v", err)
|
||||
}
|
||||
next, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow.Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatalf("add after: %v", err)
|
||||
}
|
||||
if !next.Created || next.ID == first.ID {
|
||||
t.Errorf("second молоко reused row %d; want a new one", next.ID)
|
||||
}
|
||||
open, err := s.ListItems(ctx, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(open) != 1 || open[0].ID != next.ID {
|
||||
t.Errorf("open list %+v; want only the new row", open)
|
||||
}
|
||||
}
|
||||
|
||||
// Lists are separate stores under one table: the same word on two lists is two
|
||||
// items, and reading one never reads the other.
|
||||
func TestListsDoNotSeeEachOther(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.AddListItem(ctx, ListItem{List: "покупки", Item: "вода", CreatedTs: listNow}); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if _, err := s.AddListItem(ctx, ListItem{List: "Аптека", Item: "вода", CreatedTs: listNow}); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
for _, c := range []struct{ list, want string }{
|
||||
{"покупки", "покупки"},
|
||||
{"аптека", "аптека"},
|
||||
{"", "покупки"},
|
||||
} {
|
||||
got, err := s.ListItems(ctx, c.list, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list %q: %v", c.list, err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("list %q has %d items; want 1", c.list, len(got))
|
||||
}
|
||||
if got[0].List != c.want {
|
||||
t.Errorf("list %q returned tag %q; want %q", c.list, got[0].List, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearListCrossesOffEverythingOpen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
for _, item := range []string{"молоко", "хлеб", "яйца"} {
|
||||
if _, err := s.AddListItem(ctx, ListItem{Item: item, CreatedTs: listNow}); err != nil {
|
||||
t.Fatalf("add %s: %v", item, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.AddListItem(ctx, ListItem{List: "аптека", Item: "бинт", CreatedTs: listNow}); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
n, err := s.ClearList(ctx, "покупки", listNow)
|
||||
if err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("cleared %d; want 3", n)
|
||||
}
|
||||
left, err := s.ListItems(ctx, "покупки", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(left) != 0 {
|
||||
t.Errorf("%d items still open; want none", len(left))
|
||||
}
|
||||
done, err := s.ListItems(ctx, "покупки", ListItemDone)
|
||||
if err != nil {
|
||||
t.Fatalf("list done: %v", err)
|
||||
}
|
||||
if len(done) != 3 || done[0].ResolvedTs == nil {
|
||||
t.Errorf("done list %+v; want 3 rows carrying a resolved time", done)
|
||||
}
|
||||
other, err := s.ListItems(ctx, "аптека", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(other) != 1 {
|
||||
t.Error("clearing покупки touched аптека")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetListItemStatusRejectsWhatIsNotAStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.SetListItemStatus(ctx, 1, "куплено", listNow); !errors.Is(err, ErrListItemStatus) {
|
||||
t.Errorf("bad status: %v; want ErrListItemStatus", err)
|
||||
}
|
||||
if err := s.SetListItemStatus(ctx, 999, ListItemDone, listNow); !errors.Is(err, ErrListItemNotFound) {
|
||||
t.Errorf("missing row: %v; want ErrListItemNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,38 @@ 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
|
||||
|
||||
@@ -47,3 +47,36 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-19
@@ -19,11 +19,8 @@ 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
|
||||
@@ -120,21 +117,21 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
bonus = scoreOverdueCap
|
||||
}
|
||||
total += scoreOverdue + bonus
|
||||
reason = say.S(say.ReasonOverdue, nil)
|
||||
reason = "просрочено"
|
||||
if late == 1 {
|
||||
reason = say.S(say.ReasonOverdueDay, nil)
|
||||
reason = "просрочено на день"
|
||||
} else if late > 1 {
|
||||
reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)})
|
||||
reason = fmt.Sprintf("просрочено на %d дн.", late)
|
||||
}
|
||||
case days == 0:
|
||||
total += scoreDueToday
|
||||
reason = say.S(say.ReasonToday, nil)
|
||||
reason = "сегодня"
|
||||
case days == 1:
|
||||
total += scoreDueTomorrow
|
||||
reason = say.S(say.ReasonTomorrow, nil)
|
||||
reason = "завтра"
|
||||
case days <= 7:
|
||||
total += scoreDueWeek
|
||||
reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)})
|
||||
reason = fmt.Sprintf("через %d дн.", days)
|
||||
default:
|
||||
total += scoreDueLater
|
||||
}
|
||||
@@ -150,9 +147,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 = say.S(say.ReasonImportant, nil)
|
||||
reason = "важно"
|
||||
if w >= MaxWeight {
|
||||
reason = say.S(say.ReasonUrgent, nil)
|
||||
reason = "срочно"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -166,7 +163,7 @@ func score(it Item, now time.Time) (float64, string) {
|
||||
}
|
||||
total += age
|
||||
if reason == "" && weeks >= 2 {
|
||||
reason = say.S(say.ReasonStale, nil)
|
||||
reason = "давно в списке"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -213,22 +210,22 @@ func FormatRU(ranked []Ranked) string {
|
||||
}
|
||||
}
|
||||
if len(open) == 0 && len(cands) == 0 {
|
||||
return say.S(say.TasksNone, nil)
|
||||
return "задач нет."
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
if len(open) > 0 {
|
||||
b.WriteString(say.S(say.TasksFirst, map[string]string{
|
||||
"items": joinRU(open, SpokenLimit, true),
|
||||
}))
|
||||
b.WriteString("сначала: ")
|
||||
b.WriteString(joinRU(open, SpokenLimit, true))
|
||||
b.WriteString(".")
|
||||
}
|
||||
if len(cands) > 0 {
|
||||
if b.Len() > 0 {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString(say.S(say.TasksCandidates, map[string]string{
|
||||
"items": joinRU(cands, SpokenLimit, false),
|
||||
}))
|
||||
b.WriteString("ещё я нашла, но ты не подтвердил: ")
|
||||
b.WriteString(joinRU(cands, SpokenLimit, false))
|
||||
b.WriteString(".")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+21
-2
@@ -67,6 +67,12 @@ var (
|
||||
// proposal, and drafting a new proposal for a tool that already exists and
|
||||
// is enabled is a lie about what is wrong.
|
||||
ErrNotConnected = errors.New("tool is enabled but its backend is not connected")
|
||||
// ErrNeedsAuthedSurface — the row is enabled and the act is understood,
|
||||
// and its tier is one a spoken "да" may not authorise (risk.go,
|
||||
// TierIrreversible). Held apart from ErrNeedsConfirm because there is no
|
||||
// confirm turn that would help: asking again would imply the second answer
|
||||
// changes the outcome.
|
||||
ErrNeedsAuthedSurface = errors.New("tool is irreversible and voice may not authorise it")
|
||||
)
|
||||
|
||||
// MCPCaller is the seam for an act that is an MCP tool call rather than a
|
||||
@@ -121,7 +127,12 @@ func (e *Executor) WithHome(h HomeCaller) *Executor {
|
||||
// Exec looks up name in the store and runs Cmd+args as argv (no shell).
|
||||
// confirmed=true is the second turn of a destructive act (the user said "да");
|
||||
// it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a
|
||||
// destructive tool with confirmed=false ⇒ ErrNeedsConfirm.
|
||||
// destructive tool with confirmed=false ⇒ ErrNeedsConfirm; an irreversible one
|
||||
// ⇒ ErrNeedsAuthedSurface, confirmed or not.
|
||||
//
|
||||
// Exec IS the voice path. Nothing else calls it, which is why the tier check
|
||||
// needs no surface argument: the authority it can offer a tool is a spoken
|
||||
// "да", and TierIrreversible says that is not enough.
|
||||
func (e *Executor) Exec(ctx context.Context, name string, args []string, confirmed bool) (string, error) {
|
||||
t, err := e.api.LookupTool(ctx, name)
|
||||
if errors.Is(err, ipc.ErrToolNotFound) {
|
||||
@@ -133,7 +144,15 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm
|
||||
if t.Status != "enabled" {
|
||||
return "", ErrNotEnabled
|
||||
}
|
||||
if t.Destructive && !confirmed {
|
||||
// The tier decides, not the column (Vikunja #449). RiskOf reads the row and
|
||||
// answers the three questions the boolean never did: which acts are
|
||||
// destructive, whether a confirm sticks (it never does), and what an
|
||||
// unrecognised shape inherits (the confirm turn).
|
||||
policy := PolicyFor(RiskOf(t))
|
||||
if !policy.VoiceMayRun {
|
||||
return "", ErrNeedsAuthedSurface
|
||||
}
|
||||
if policy.Confirm && !confirmed {
|
||||
return "", ErrNeedsConfirm
|
||||
}
|
||||
// An MCP row is a call to a configured server, not a process. Everything
|
||||
|
||||
+11
-11
@@ -25,10 +25,7 @@
|
||||
// the daemon seam (config wiring, no CoreAPI or voice-package change).
|
||||
package voice
|
||||
|
||||
import (
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
import "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
|
||||
@@ -63,24 +60,27 @@ func (s *StubReplier) Reply(d router.Decision) string {
|
||||
if !d.Slots.HasFn {
|
||||
return "не могу это сделать — не разобрала действие."
|
||||
}
|
||||
return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn})
|
||||
return "ок, записала действие: " + d.Slots.Fn
|
||||
case router.IntentReminder:
|
||||
return phraser.Ack(phraser.AckReminder, nil)
|
||||
if d.Slots.HasTime {
|
||||
return "напомню."
|
||||
}
|
||||
return "напомню."
|
||||
case router.IntentFact:
|
||||
if d.Slots.HasKey {
|
||||
if d.Slots.Value != "" {
|
||||
return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
|
||||
return "отметила: " + d.Slots.Key + " = " + d.Slots.Value
|
||||
}
|
||||
return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
|
||||
return "отметила: " + d.Slots.Key
|
||||
}
|
||||
return phraser.Ack(phraser.AckFact, nil)
|
||||
return "записала факт."
|
||||
case router.IntentNote:
|
||||
return phraser.Ack(phraser.AckNote, nil)
|
||||
return "сохранила заметку."
|
||||
case router.IntentQuery:
|
||||
return "поискала в заметках — ничего не нашла."
|
||||
case router.IntentChat:
|
||||
return "поговорили." // stub — LLMReplier replaces this
|
||||
default:
|
||||
return phraser.Ack(phraser.AckGeneric, nil)
|
||||
return "приняла."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user