Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a21478f36 | |||
| 958d2a2fc8 | |||
| 7db139b83e | |||
| 0987dabfc4 | |||
| 947506c7b8 | |||
| 6c67e61962 | |||
| 0990f32808 | |||
| d41878c2b1 | |||
| e023638135 | |||
| 0d52344d27 | |||
| 5bd303788b | |||
| afac8fb670 |
@@ -51,6 +51,12 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||
case errors.Is(err, tool.ErrNeedsAuthedSurface):
|
||||
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
||||
// help: everything that proposed this act — the STT, the router,
|
||||
// the fuzzy allowlist match — is a guess, and a spoken "да" checks
|
||||
// none of it. She names the gap instead.
|
||||
return "это я из голоса не выполню — после него ничего не вернуть. запусти сам, если правда надо."
|
||||
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):
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ 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).
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-13
@@ -106,11 +106,11 @@ func trimClarifyExpired(s string) string {
|
||||
// out, and "" when nothing was parked. Call it right after
|
||||
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
||||
// only reported here — the words themselves still go on to be routed fresh.
|
||||
func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||
func (h *reactiveHandler) clarifyExpiredNotice() string {
|
||||
if h.clarifyStore == nil {
|
||||
return ""
|
||||
}
|
||||
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
|
||||
if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) {
|
||||
return ""
|
||||
}
|
||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||
@@ -157,7 +157,7 @@ func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
// askClarify parks the request and returns the question to ask instead of the
|
||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||
// the caller falls back to the canned reply.
|
||||
func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
|
||||
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -165,7 +165,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: toDialogueSlots(dec.Slots),
|
||||
Missing: []dialogue.Slot{slot},
|
||||
@@ -192,7 +192,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
return "", false
|
||||
}
|
||||
@@ -206,9 +206,9 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||||
return h.reaskOrGiveUp(q, merged, text), true
|
||||
}
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
|
||||
// One gap filled is not the same as a complete request. askClarify parks
|
||||
// only the first gap, because one question per turn is the rule, but a
|
||||
@@ -217,7 +217,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
// a reminder with no time, which answered "не получилось разобрать время
|
||||
// напоминания." — an error for a request she never finished asking about.
|
||||
// Re-enter the loop instead, one question at a time as before.
|
||||
if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
|
||||
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
|
||||
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
||||
// costs a question exactly like a second try at the first one does, so the cap
|
||||
// still bounds how many times she can speak before acting or letting go.
|
||||
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
@@ -270,7 +270,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
if !ok || !q.CanAsk() {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
Missing: []dialogue.Slot{remaining[0]},
|
||||
@@ -287,13 +287,13 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||
// again while she has attempts left, otherwise say she did not understand and
|
||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
question := ""
|
||||
if len(q.Missing) > 0 {
|
||||
question = clarifyQuestions[q.Missing[0]]
|
||||
}
|
||||
if question == "" || !q.CanAsk() {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||||
return clarifyGaveUp
|
||||
}
|
||||
@@ -302,7 +302,7 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
|
||||
q.Slots = merged
|
||||
q.Attempts++
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
h.clarifyStore.Put(voiceDialogueID, q)
|
||||
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
||||
return question
|
||||
}
|
||||
|
||||
+21
-50
@@ -81,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||
t.Fatal("a fact with no key should be asked about")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
||||
@@ -128,7 +128,7 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
@@ -147,7 +147,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a first question")
|
||||
}
|
||||
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
||||
@@ -185,7 +185,7 @@ func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
||||
@@ -199,7 +199,7 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
// First answer parses, but re-park it by hand as if she had asked again:
|
||||
@@ -232,7 +232,7 @@ func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("an act with no fn should be asked about")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
||||
@@ -260,7 +260,7 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
||||
@@ -284,7 +284,7 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
||||
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
||||
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
||||
} {
|
||||
if question, asked := h.askClarify(context.Background(), dec); asked {
|
||||
if question, asked := h.askClarify(dec); asked {
|
||||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
||||
}
|
||||
}
|
||||
@@ -296,29 +296,29 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
||||
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
||||
// TTL: she must say the old request is gone AND still answer the new words.
|
||||
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||
ctx := context.Background()
|
||||
h, _, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "", "как дела")
|
||||
reply := h.handleText(ctx, "как дела")
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||
}
|
||||
if trimClarifyExpired(reply) == "" {
|
||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
// The notice is said once, not on every later utterance.
|
||||
if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) {
|
||||
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) {
|
||||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -340,7 +340,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
@@ -380,7 +380,7 @@ func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
t.Fatal("expected the subject question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
@@ -440,10 +440,10 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
// The confirm turn used to return before the notice was even computed, so he
|
||||
// answered the confirm and never heard that the older request was let go.
|
||||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||
ctx := context.Background()
|
||||
h, _, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
// A confirm parked with a longer life than the question, so only the
|
||||
@@ -451,7 +451,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "", "нет")
|
||||
reply := h.handleText(ctx, "нет")
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||||
}
|
||||
@@ -461,7 +461,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
if h.pending != nil {
|
||||
t.Fatal("the confirm must still have been consumed")
|
||||
}
|
||||
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
}
|
||||
@@ -476,7 +476,7 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
at := h.now().Add(2 * time.Hour)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||||
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)
|
||||
@@ -501,32 +501,3 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||||
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyIsPerConversation — the parked question belongs to the reach that
|
||||
// was asked. Before this the clarify store had one global key, so a question
|
||||
// asked in the web chat and never answered captured the next utterance from
|
||||
// telegram, or from the mic, and answered it against a request the speaker had
|
||||
// never made (Vikunja #466).
|
||||
func TestClarifyIsPerConversation(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
|
||||
|
||||
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question on the web conversation")
|
||||
}
|
||||
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
|
||||
t.Fatal("a question asked on the web must not eat a telegram utterance")
|
||||
}
|
||||
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
|
||||
t.Fatal("a question asked on the web must not eat what he says at the mic")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// voiceCtx — the mic's conversation, which carries no id of its own.
|
||||
func voiceCtx() context.Context {
|
||||
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
}
|
||||
|
||||
+3
-50
@@ -1,64 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// voiceDialogueID — the dialogue-session key for the microphone, and the
|
||||
// clarify key for it too. This is a single-user box (ponytail), so one slot
|
||||
// suffices; a second speaker would need per-speaker ids, which waits on
|
||||
// voice-print attribution (see PROGRESS multi-user deferral).
|
||||
// voiceDialogueID — the single dialogue-session key. This is a single-user box
|
||||
// (ponytail), so one slot suffices; a second speaker would need per-speaker ids,
|
||||
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
|
||||
const voiceDialogueID = "voice"
|
||||
|
||||
// textDialogueID — the clarify key for a text turn that named no conversation.
|
||||
// Separate from the mic: an old client that sends no id still must not answer
|
||||
// a question she asked out loud.
|
||||
const textDialogueID = "text"
|
||||
|
||||
// dialogueKey — the context key carrying the id of the conversation this turn
|
||||
// belongs to. It rides the context rather than a parameter for the same reason
|
||||
// the correlation id does: every step of the turn needs it, most of them only
|
||||
// to hand to the next one, and threading it by hand would put it in six
|
||||
// clarify signatures that have nothing else to say about it.
|
||||
type dialogueKey struct{}
|
||||
|
||||
// dialogueIDFor builds the id a turn is held under: the conversation the reach
|
||||
// named, qualified by the tap it arrived on, or the tap's own fallback when it
|
||||
// named none.
|
||||
//
|
||||
// A parked clarifying question used to be held under voiceDialogueID no matter
|
||||
// where the turn came from, so one unanswerable question captured the next
|
||||
// three utterances from anywhere. Three independent curl sessions fed a
|
||||
// capture attempt that had already failed, and a reminder among them was lost
|
||||
// (Vikunja #466).
|
||||
func dialogueIDFor(src turnSource, conversation string) string {
|
||||
if conversation != "" {
|
||||
return string(src) + ":" + conversation
|
||||
}
|
||||
if src == sourceVoice {
|
||||
return voiceDialogueID
|
||||
}
|
||||
return textDialogueID
|
||||
}
|
||||
|
||||
// withDialogueID tags a turn with that id.
|
||||
func withDialogueID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, dialogueKey{}, id)
|
||||
}
|
||||
|
||||
// dialogueIDOf reads it back. Falls back to the microphone's slot, which is
|
||||
// what an unthreaded caller — a test, an internal replay — gets.
|
||||
func dialogueIDOf(ctx context.Context) string {
|
||||
if id, ok := ctx.Value(dialogueKey{}).(string); ok && id != "" {
|
||||
return id
|
||||
}
|
||||
return voiceDialogueID
|
||||
}
|
||||
|
||||
// toDialogueSlots and applyDialogueSlots are the only bridge between
|
||||
// router.Slots and dialogue.Slots. dialogue must not import router (import
|
||||
// cycle), so the two structs are hand-kept copies and every field has to be
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
@@ -87,38 +85,3 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
|
||||
// task table. It went dead when the router started claiming the marker as an
|
||||
// act: capture rides the note intent, so nothing below actionNote was ever
|
||||
// reached and every capture answered "Что сделать?" (Vikunja #467).
|
||||
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Now()
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
matcher := tool.NewMatcher(api)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
router: buildRouter(emb, matcher, 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Fatalf("capture did not claim the turn: %q", reply)
|
||||
}
|
||||
open, err := st.ListTasks(ctx, store.TaskOpen)
|
||||
if err != nil || len(open) != 1 {
|
||||
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
|
||||
}
|
||||
// The words he said, not the model's rewrite of them.
|
||||
if open[0].Text != "купить молоко" {
|
||||
t.Fatalf("task text was rewritten: %q", open[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+6
-3
@@ -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 {
|
||||
@@ -986,7 +989,7 @@ type daemonAPI struct {
|
||||
getTrace func() *loop.TickTrace
|
||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||
chatFn func(ctx context.Context, conversation, text string) string
|
||||
chatFn func(ctx context.Context, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
}
|
||||
@@ -1002,11 +1005,11 @@ func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent,
|
||||
return d.getEvents(n), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
if d.chatFn == nil {
|
||||
return "", errors.New("mavend: chat not available")
|
||||
}
|
||||
return d.chatFn(ctx, conversation, text), nil
|
||||
return d.chatFn(ctx, text), nil
|
||||
}
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
|
||||
+4
-4
@@ -220,9 +220,9 @@ func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
|
||||
// handleText — the core reactive path without stt/tts. Used by the IPC Chat
|
||||
// endpoint (and eventually by telegram). Splits out the audio bookends from
|
||||
// HandlePushToTalk so text channels share the same routing logic.
|
||||
func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string {
|
||||
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
||||
log.Printf("voice: handleText: %q", text)
|
||||
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), text, sourceText)
|
||||
return h.runTurn(ctx, text, sourceText)
|
||||
}
|
||||
|
||||
// turnSource — which channel this utterance arrived on, in the same provenance
|
||||
@@ -255,7 +255,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// early. He can be asked a question, walk off, come back and say "да" to a
|
||||
// confirm that is still parked; computing the notice after that return meant
|
||||
// he answered the confirm and never heard that the older request was let go.
|
||||
expiredNotice := h.clarifyExpiredNotice(ctx)
|
||||
expiredNotice := h.clarifyExpiredNotice()
|
||||
|
||||
// 2. confirm turn — if a destructive act is parked, this utterance is its
|
||||
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
|
||||
@@ -351,7 +351,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
// and park the request (clarify.go); otherwise the replier's canned reply
|
||||
// stands.
|
||||
if dec.Clarify {
|
||||
if question, asked := h.askClarify(ctx, dec); asked {
|
||||
if question, asked := h.askClarify(dec); asked {
|
||||
return withNotice(expiredNotice, question)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,12 +379,8 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
// question and must keep reaching replySystem, while "что у меня сегодня"
|
||||
// is an agenda question and must not.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
grammars = append(grammars, router.ListGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||
// explicit capture marker beats the model, which called it an act and
|
||||
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||
// marker never collides with a clock or agenda question.
|
||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
Classifier: cls,
|
||||
|
||||
@@ -77,7 +77,7 @@ func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
return f.mcpServers, f.mcpErr
|
||||
}
|
||||
|
||||
func (f *fakeCore) Chat(_ context.Context, _, text string) (string, error) {
|
||||
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
|
||||
f.chatText = text
|
||||
if f.chatErr != nil {
|
||||
return "", f.chatErr
|
||||
|
||||
+17
-14
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1678,13 +1687,7 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
// One conversation id for the whole web chat, and a different one from
|
||||
// telegram or the mic. A parked question belongs to the reach that was
|
||||
// asked; before this, a clarify nobody answered on the web ate the next
|
||||
// utterance spoken at the mic (Vikunja #466). This server has no
|
||||
// per-browser session, so every browser tab is the same conversation —
|
||||
// which is right for a single-owner box.
|
||||
reply, err := core.Chat(r.Context(), "web", text)
|
||||
reply, err := core.Chat(r.Context(), text)
|
||||
if err != nil {
|
||||
log.Printf("chat api: %v", err)
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -311,7 +311,7 @@ func TestGate_IpcServer_CheckWiredThroughSocket(t *testing.T) {
|
||||
if fake.writes != 0 {
|
||||
t.Errorf("auth refused but CoreAPI was called %d time(s); refused calls must not reach CoreAPI", fake.writes)
|
||||
}
|
||||
_, err = cli.Chat(context.Background(), "web", "привет")
|
||||
_, err = cli.Chat(context.Background(), "привет")
|
||||
if !errors.Is(err, ipc.ErrForbidden) {
|
||||
t.Errorf("wire: chat from unenrolled uid = %v; want ipc.ErrForbidden", err)
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = cli.Close() })
|
||||
reply, err := cli.Chat(context.Background(), "web", "привет")
|
||||
reply, err := cli.Chat(context.Background(), "привет")
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
@@ -373,7 +373,7 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
|
||||
return int64(r.writes), nil
|
||||
}
|
||||
|
||||
func (r *recordingAPI) Chat(_ context.Context, _, text string) (string, error) {
|
||||
func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) {
|
||||
r.chats++
|
||||
return "echo: " + text, nil
|
||||
}
|
||||
|
||||
+2
-13
@@ -593,14 +593,8 @@ type MCPServerStatus struct {
|
||||
}
|
||||
|
||||
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
|
||||
//
|
||||
// Conversation names the thread this utterance belongs to: a mavweb session, a
|
||||
// telegram chat. It is opaque to the daemon and only has to be stable for one
|
||||
// conversation and distinct across them. Empty is allowed and means "the
|
||||
// unattributed text tap", which is what an old client sends.
|
||||
type chatReq struct {
|
||||
Text string `json:"text"`
|
||||
Conversation string `json:"conversation,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
type chatResp struct {
|
||||
Reply string `json:"reply"`
|
||||
@@ -765,12 +759,7 @@ type CoreAPI interface {
|
||||
// Chat routes a text utterance through the reactive handler's core path
|
||||
// (router → dialogue → action → replier) and returns the reply text.
|
||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||
//
|
||||
// conversation names the thread. A parked clarifying question is held per
|
||||
// conversation, so an unanswered question on one reach cannot eat the next
|
||||
// utterance from another (Vikunja #466). Empty means the unattributed text
|
||||
// tap and is still one conversation of its own, separate from the mic.
|
||||
Chat(ctx context.Context, conversation, text string) (string, error)
|
||||
Chat(ctx context.Context, text string) (string, error)
|
||||
|
||||
// RecentEvents returns the daemon's unified intake journal, newest first
|
||||
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
|
||||
|
||||
@@ -623,9 +623,9 @@ func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (c *Client) Chat(ctx context.Context, text string) (string, error) {
|
||||
var r chatResp
|
||||
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
|
||||
if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.Reply, nil
|
||||
|
||||
@@ -401,7 +401,7 @@ func TestChatViaClient(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { _ = cli.Close() })
|
||||
|
||||
reply, err := cli.Chat(context.Background(), "web", "привет")
|
||||
reply, err := cli.Chat(context.Background(), "привет")
|
||||
if err != nil {
|
||||
t.Fatalf("Chat: %v", err)
|
||||
}
|
||||
@@ -417,7 +417,7 @@ type chatTestAPI struct {
|
||||
UnimplementedCoreAPI
|
||||
}
|
||||
|
||||
func (a *chatTestAPI) Chat(ctx context.Context, _, text string) (string, error) {
|
||||
func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
if text == "привет" {
|
||||
return "и тебе привет!", nil
|
||||
}
|
||||
|
||||
@@ -240,7 +240,7 @@ func (a *storeAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
return newID, mapErr(err)
|
||||
}
|
||||
|
||||
func (a *storeAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (a *storeAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return "", errors.New("store: chat not available via direct store API")
|
||||
}
|
||||
|
||||
@@ -974,7 +974,7 @@ var methodTable = map[Method]handlerFunc{
|
||||
return map[string]int64{"new_id": newID}, nil
|
||||
}),
|
||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
||||
reply, err := api.Chat(ctx, p.Text)
|
||||
return chatResp{Reply: reply}, err
|
||||
}),
|
||||
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
||||
|
||||
@@ -141,6 +141,6 @@ func (UnimplementedCoreAPI) MCPServers(ctx context.Context) ([]MCPServerStatus,
|
||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, conversation, text string) (string, error) {
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -53,31 +53,9 @@ const MinOnPatternFraction = 0.7
|
||||
// a repeat. False negatives cost one more observation and nothing else.
|
||||
const MinEvents = 4
|
||||
|
||||
// MinIntervalDays — the fastest rhythm that may be called a routine. Two
|
||||
// hours.
|
||||
//
|
||||
// Without a floor, four taps of the same key minutes apart give intervals near
|
||||
// 0.002 days. They all sit inside the ±50% band by construction, so the
|
||||
// detector proposed a routine and PhraseRoutine worded it as "каждый день"
|
||||
// (Vikunja #468). The damage outlives the mistake: UNIQUE(action, object)
|
||||
// means dismissing the bogus proposal burns that pair permanently, so the real
|
||||
// routine behind it can never be proposed again.
|
||||
//
|
||||
// Two hours rather than a day, because a genuine habit can run several times a
|
||||
// day — meals, water, a break. Anything faster than that is not a habit she
|
||||
// should be proposing to remind him about; the loop rules already cover that
|
||||
// range, and they are rules, not guesses. It is checked against the median, so
|
||||
// one quick repeat inside a real rhythm still counts.
|
||||
//
|
||||
// The other half of this is that hand-QA of the detector was unsafe: seeding a
|
||||
// pattern the obvious way, four chat turns in a row, poisoned the very pair
|
||||
// being tested.
|
||||
const MinIntervalDays = 2.0 / 24.0
|
||||
|
||||
// Detect checks whether a sequence of events for the same action+object
|
||||
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
||||
// - At least MinEvents events exist (≥3 intervals)
|
||||
// - The median interval is at least MinIntervalDays
|
||||
// - At least MinOnPatternFraction of the intervals sit within
|
||||
// MaxIntervalRatio of the median interval
|
||||
//
|
||||
@@ -110,8 +88,8 @@ func Detect(events []Event) (*ProposedRoutine, error) {
|
||||
}
|
||||
|
||||
center := medianFloat(intervals)
|
||||
if center <= 0 || center < MinIntervalDays {
|
||||
return nil, nil // a burst, not a rhythm — see MinIntervalDays
|
||||
if center <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Keep the intervals that sit inside the band around the median. The
|
||||
|
||||
@@ -216,46 +216,3 @@ func TestDetectMedianBandNotExtremes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A burst is not a habit. Four taps of the same key minutes apart give
|
||||
// intervals near 0.002 days, all inside the ±50% band by construction, so the
|
||||
// detector called it a daily routine (Vikunja #468). Dismissing that proposal
|
||||
// burns the action+object pair permanently, which also made hand-QA of the
|
||||
// detector unsafe.
|
||||
func TestDetectRejectsABurst(t *testing.T) {
|
||||
base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||
var events []Event
|
||||
for i := 0; i < 4; i++ {
|
||||
events = append(events, Event{
|
||||
Action: "refill", Object: "cat_water",
|
||||
Ts: base.Add(time.Duration(i) * 7 * time.Minute),
|
||||
})
|
||||
}
|
||||
r, err := Detect(events)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect: %v", err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Fatalf("four taps minutes apart proposed a routine every %.3f days", r.IntervalDays)
|
||||
}
|
||||
}
|
||||
|
||||
// The floor is two hours, not a day: a habit that runs several times a day is
|
||||
// still a habit.
|
||||
func TestDetectKeepsASeveralTimesADayHabit(t *testing.T) {
|
||||
base := time.Date(2026, 8, 4, 8, 0, 0, 0, time.UTC)
|
||||
var events []Event
|
||||
for i := 0; i < 5; i++ {
|
||||
events = append(events, Event{
|
||||
Action: "drink", Object: "water",
|
||||
Ts: base.Add(time.Duration(i) * 4 * time.Hour),
|
||||
})
|
||||
}
|
||||
r, err := Detect(events)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect: %v", err)
|
||||
}
|
||||
if r == nil {
|
||||
t.Fatal("a four-hour rhythm over five events is a habit, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ""}
|
||||
@@ -173,23 +186,12 @@ func checkFeminine(body string) Result {
|
||||
// Second pass: self-reference with the pronoun dropped — "напомнил тебе",
|
||||
// "проверил за тебя". A masculine past-tense verb whose object is HIM can
|
||||
// only be her speaking about herself.
|
||||
//
|
||||
// Two guards, both from a false positive on the talk fixture: "ты заплатил
|
||||
// за домен до марта" scored as her drift and cost the run a point it had
|
||||
// earned (Vikunja #462). He is male, so a past-tense verb governed by "ты"
|
||||
// must be masculine. And a bare "за" is not evidence of anything — "за
|
||||
// домен" is a price, "за тебя" is her doing something on his behalf — so it
|
||||
// only counts when he is the one it points at.
|
||||
for i, w := range words {
|
||||
if !masculinePast(w) || i+1 >= len(words) || governedByYou(words, i) {
|
||||
if !masculinePast(w) || i+1 >= len(words) {
|
||||
continue
|
||||
}
|
||||
next := words[i+1]
|
||||
aboutHim := next == "тебе" || next == "тебя"
|
||||
if next == "за" && i+2 < len(words) && (words[i+2] == "тебя" || words[i+2] == "тебе") {
|
||||
aboutHim = true
|
||||
}
|
||||
if aboutHim {
|
||||
if next == "тебе" || next == "тебя" || next == "за" {
|
||||
return Result{CheckFeminine, false,
|
||||
fmt.Sprintf("masculine self-reference %q before %q", w, next)}
|
||||
}
|
||||
@@ -663,19 +665,3 @@ func checkEllipsis(body string) Result {
|
||||
}
|
||||
return Result{CheckEllipsis, true, ""}
|
||||
}
|
||||
|
||||
// governedByYou reports whether "ты" stands close enough in front of the verb
|
||||
// at index i to be its subject. Three words, the same window checkFeminine's
|
||||
// first pass uses after "я", and it stops at a first-person pronoun so "ты
|
||||
// просил, я напомнил" still trips.
|
||||
func governedByYou(words []string, i int) bool {
|
||||
for j := i - 1; j >= 0 && j >= i-3; j-- {
|
||||
switch words[j] {
|
||||
case "ты":
|
||||
return true
|
||||
case "я":
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -106,12 +106,6 @@ func TestChecksCatchWhatTheyClaim(t *testing.T) {
|
||||
{"masculine predicative", "я должен сказать: попей воды.", CheckFeminine},
|
||||
// The other direction: HE is male, so second-person masculine is right.
|
||||
{"second person masculine ok", "ты не пил воду четыре часа.", ""},
|
||||
// The recorded false positive: "заплатил" sits before "за", and the
|
||||
// second pass read that as her dropping the pronoun. The subject is
|
||||
// "ты" and he is male, so the reply is right (Vikunja #462).
|
||||
{"second person masculine before за", "ты заплатил за домен до марта, а воду пить всё равно надо.", ""},
|
||||
// The same shape she really does get wrong still trips.
|
||||
{"masculine on his behalf", "проверил за тебя — воды не было четыре часа.", CheckFeminine},
|
||||
// The real observed failure: she addressed him as a woman.
|
||||
{"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender},
|
||||
{"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender},
|
||||
|
||||
@@ -71,7 +71,6 @@
|
||||
{ "id": "ru-note-003", "utterance": "заметка про настройку vlan на свитче", "lang": "ru", "intent": "note", "tags": ["homelab"] },
|
||||
{ "id": "ru-note-004", "utterance": "запиши идею: гидропоника на балконе", "lang": "ru", "intent": "note" },
|
||||
{ "id": "ru-note-005", "utterance": "запиши что сосед просил номер электрика", "lang": "ru", "intent": "note" },
|
||||
{ "id": "ru-note-006", "utterance": "добавь в задачи купить молоко", "lang": "ru", "intent": "note", "tags": ["capture"], "note": "an explicit capture marker — the model called it an act and rewrote the payload (Vikunja #467), stage 0 claims it" },
|
||||
{ "id": "en-note-001", "utterance": "note: rotate the kuma api key", "lang": "en", "intent": "note", "tags": ["homelab"] },
|
||||
|
||||
{ "id": "ru-sys-001", "utterance": "сколько сейчас времени в киеве", "lang": "ru", "intent": "system", "tags": ["time"] },
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+1
-48
@@ -1,9 +1,6 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// Task capture and task listing, matched deterministically (Vikunja #130).
|
||||
//
|
||||
@@ -204,47 +201,3 @@ func IsTaskListQuery(text string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// TaskCaptureGrammar — stage 0 for an explicit capture marker, so the resident
|
||||
// model never sees it (Vikunja #467).
|
||||
//
|
||||
// Capture was built to ride the note intent, deliberately: #130 said no eighth
|
||||
// intent, and while the classifier was routing, a note-shaped utterance with a
|
||||
// marker in it reached actionNote and captureTaskFromNote claimed it there. The
|
||||
// router pre-empted that. Measured 2026-08-02: "добавь в задачи купить молоко"
|
||||
// routed act, so captureTaskFromNote was never consulted, the act arm found no
|
||||
// allowlisted fn, and the gate asked "Что сделать?". Every capture utterance
|
||||
// tried filed nothing.
|
||||
//
|
||||
// The model also rewrote the payload on the way — "купить молоко" came back as
|
||||
// "сделать покупку молока". A task must read as the words he said, which is a
|
||||
// second reason to answer this before the model rather than to prompt around
|
||||
// it.
|
||||
//
|
||||
// The marker list is data (task_phrases.json) and the parse strips urgency, so
|
||||
// the pattern here matches any utterance and the decision is ParseTaskCapture's
|
||||
// to make — same shape as the wake-word act grammar, which also matches broadly
|
||||
// and refuses in Build. Intent stays note: the daemon's note path is where
|
||||
// capture lives, and nothing about the contract with the model changes.
|
||||
func TaskCaptureGrammar() Grammar {
|
||||
return Grammar{
|
||||
Name: "task-capture",
|
||||
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
|
||||
Build: func(m []string) (Decision, bool) {
|
||||
c, ok := ParseTaskCapture(m[1])
|
||||
if !ok {
|
||||
return Decision{}, false // not a capture — fall through
|
||||
}
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentNote,
|
||||
Confidence: 1.0,
|
||||
// The capture text, not the raw utterance: it is what the
|
||||
// clarify gate reads as the payload. captureTaskFromNote
|
||||
// re-parses the utterance itself, so the task text comes from
|
||||
// the same place either way.
|
||||
Slots: Slots{Text: c.Text},
|
||||
}, true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@
|
||||
"добавь в список",
|
||||
"добавь задачу",
|
||||
"запиши в задачи",
|
||||
"запиши в список дел",
|
||||
"запиши в список задач",
|
||||
"запиши в список",
|
||||
"запиши задачу",
|
||||
"новая задача",
|
||||
"поставь задачу",
|
||||
|
||||
@@ -85,37 +85,3 @@ func TestIsTaskListQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskCaptureGrammarClaimsTheMarker — the capture marker is answered at
|
||||
// stage 0, so the model never gets to call it an act (Vikunja #467).
|
||||
func TestTaskCaptureGrammarClaimsTheMarker(t *testing.T) {
|
||||
g := TaskCaptureGrammar()
|
||||
captures := map[string]string{
|
||||
"добавь в задачи купить молоко": "купить молоко",
|
||||
"запиши в список дел купить хлеб": "купить хлеб",
|
||||
"поставь задачу вынести мусор": "вынести мусор",
|
||||
"добавь в задачи срочно оплатить дом": "оплатить дом",
|
||||
}
|
||||
for in, want := range captures {
|
||||
m := g.Pattern.FindStringSubmatch(in)
|
||||
if m == nil {
|
||||
t.Fatalf("%q did not match the grammar pattern", in)
|
||||
}
|
||||
d, ok := g.Build(m)
|
||||
if !ok {
|
||||
t.Fatalf("%q must be claimed as a capture", in)
|
||||
}
|
||||
if d.Intent != IntentNote || d.Slots.Text != want {
|
||||
t.Errorf("%q → intent=%s text=%q, want note/%q", in, d.Intent, d.Slots.Text, want)
|
||||
}
|
||||
}
|
||||
// Everything without a marker falls through, including a marker with no
|
||||
// task after it and a question about the list.
|
||||
for _, in := range []string{"надо бы поспать", "добавь в задачи", "какие у меня задачи?", "перезапусти nginx"} {
|
||||
if m := g.Pattern.FindStringSubmatch(in); m != nil {
|
||||
if _, ok := g.Build(m); ok {
|
||||
t.Errorf("%q must fall through to the cascade", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,27 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
// event, and the old rows would otherwise be recited as extra meetings.
|
||||
// The filter is exact — it keeps any key whose summary part still has a
|
||||
// letter or a digit in it.
|
||||
`CREATE TABLE IF NOT EXISTS list_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_ts INTEGER NOT NULL,
|
||||
list TEXT NOT NULL,
|
||||
item TEXT NOT NULL,
|
||||
norm TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','done','dropped')),
|
||||
resolved_ts INTEGER
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_list_items_live ON list_items (list, norm) WHERE status = 'open';
|
||||
CREATE INDEX IF NOT EXISTS idx_list_items_list ON list_items (list, status, created_ts);`,
|
||||
// #19 — standing lists (Vikunja #453). The fourth append-only shape, after
|
||||
// facts, notes and tasks, and the reason it is its own table rather than a
|
||||
// tag on tasks: milk on the shopping list is not work. Nothing prioritises
|
||||
// it, nothing nudges about it, and the prioritiser must not start counting
|
||||
// groceries as outstanding errands.
|
||||
//
|
||||
// The live-only unique index is the tasks one, per list: saying "молоко"
|
||||
// twice before the shop keeps one row, saying it again next week after the
|
||||
// last one was crossed off writes a new one.
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user