Compare commits

...

8 Commits

Author SHA1 Message Date
claude 947506c7b8 docs: a list is the fourth append-only shape (V-453) 2026-08-04 04:45:41 +04:00
claude 6c67e61962 mavend: cover the spoken list path (V-453) 2026-08-04 04:45:21 +04:00
claude 0990f32808 mavend: the list is reachable from voice (V-453)
An add and a crossing-off run at the top of actionNote, next to task
capture and before the embedding is paid for; the read-back is a query
source sitting beside "tasks", so the recall pass cannot answer "что мне
купить?" from an old note about the shop.

Crossing off one item claims the turn only when the list actually holds
that item, which is what keeps "купил новый ноутбук" a note.

These read h.dataStore rather than the CoreAPI: a list is local to the core
and nothing outside it writes one. The ipc seam is what it grows through
when something outside mavend needs to add to a list.
2026-08-04 04:45:21 +04:00
claude d41878c2b1 router: cover the list parsers and wire the grammars (V-453) 2026-08-04 04:45:13 +04:00
claude e023638135 router: parse list capture, read-back and crossing off (V-453)
Same posture as task capture 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 and stays a note. The list tag is matched by stem, because
Russian declines it: "список покупок", "в покупки" and "в покупках" are one
list. ListGrammars puts both halves at stage 0, so an add and a read-back
never depend on the model having a good turn.
2026-08-04 04:45:13 +04:00
claude 0d52344d27 store: cover the list_items shape with tests (V-453) 2026-08-04 04:39:24 +04:00
claude 5bd303788b store: add list_items, the fourth append-only shape (V-453)
A list is a standing set of short strings under a tag. Not a task, because
milk is not work and the prioritiser must not count it as an errand; not a
fact, because it claims nothing. Nothing predicates over it, so two people
adding to the same list at once costs nothing.

Migration #19, plus AddListItem, ListItems, SetListItemStatus and ClearList.
The live-only unique index is the tasks one, per list: молоко twice before
the shop is one row, молоко again after it was crossed off is a new one.
2026-08-04 04:39:24 +04:00
claude afac8fb670 mavend: run the persona checks before she speaks (V-399)
The checks stay in the eval package and the daemon calls three of them:
feminine, address, and a new leaked-reasoning test. No retry — it doubles
the latency on the turn that is already going badly, and on the nudge path
the moment has passed. A failure falls back to the deterministic floor and
is logged with the whole rejected text and counted by check name.

hisgender is deliberately not run: the simulator showed it rejecting
"записала, что ты выпил воды", which is her own correct self-reference.
2026-08-04 04:35:42 +04:00
16 changed files with 1294 additions and 0 deletions
+143
View File
@@ -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
}
+184
View File
@@ -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)
}
}
+6
View File
@@ -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).
+5
View File
@@ -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
+134
View File
@@ -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
}
+75
View File
@@ -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")
}
}
+5
View File
@@ -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
}
+3
View File
@@ -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 {
+1
View File
@@ -379,6 +379,7 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// question and must keep reaching replySystem, while "что у меня сегодня"
// is an agenda question and must not.
grammars = append(grammars, router.AgendaQueryGrammars()...)
grammars = append(grammars, router.ListGrammars()...)
grammars = append(grammars, router.ReminderGrammar())
return router.New(router.Config{
Grammars: grammars,
+25
View File
@@ -630,6 +630,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
+13
View File
@@ -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, ""}
+247
View File
@@ -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
},
},
}
}
+89
View File
@@ -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")
}
}
+194
View File
@@ -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
}
+149
View File
@@ -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)
}
}
+21
View File
@@ -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