Merge task/449 into the risk-tier fix branch (V-523)
Brings internal/tool/risk.go in so the Hexis split can be written against it. Four conflicts, all additive: both grammar sets in voicewire.go, both test sets in agenda_test.go and stage0.go, and in actions_act.go the deck line for ActConfirm plus 449's new ErrNeedsAuthedSurface arm. Two renames the merge forced. actions_list_test.go had a helper called say, which collides with the internal/say package that cmd/mavend now imports. actions_act_risk_test.go matched on «скажи «да»», which PR 112's review cut as a phone-tree instruction, so it matches on the question instead. --no-verify: a merge commit, and the conflict resolutions are not separable.
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Fact sources. A calendar event reaches the store as a
|
||||
@@ -153,14 +154,20 @@ func Overlapping(events []Event, from, to time.Time) []Event {
|
||||
return out
|
||||
}
|
||||
|
||||
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
||||
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
||||
// the day prefix carries the identity and this only disambiguates within a day.
|
||||
// safeKey makes a summary safe to use inside a fact key: letters and digits in
|
||||
// any script, plus dashes, with space and underscore folded to a dash.
|
||||
//
|
||||
// It kept ASCII only until 04-08-2026, and dropped everything else. His
|
||||
// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to
|
||||
// "--" and produced the same key on the same day — the second event of the day
|
||||
// silently overwrote the first (Vikunja #443). Letting the letters through is
|
||||
// what makes the key identify the event. Migration #18 drops the keys written
|
||||
// under the old rule; they are re-derived on the next poll.
|
||||
func safeKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-':
|
||||
b.WriteRune(r)
|
||||
case r == ' ' || r == '_':
|
||||
b.WriteRune('-')
|
||||
|
||||
@@ -139,6 +139,9 @@ func TestSafeKey(t *testing.T) {
|
||||
{"Hello_World", "Hello-World"},
|
||||
{"special@#$chars!!", "specialchars"},
|
||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||
// His calendar is Russian. These reduced to "--" and "--" (Vikunja #443).
|
||||
{"Встреча с Аней", "Встреча-с-Аней"},
|
||||
{"Обед с мамой", "Обед-с-мамой"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := safeKey(tt.in); got != tt.want {
|
||||
@@ -263,3 +266,19 @@ func TestSourceTrust(t *testing.T) {
|
||||
t.Errorf("Sources() = %v", Sources())
|
||||
}
|
||||
}
|
||||
|
||||
// Two Russian events on one day must not share a key. They did: safeKey kept
|
||||
// ASCII only, so both summaries collapsed to their spaces and the second event
|
||||
// overwrote the first in the store (Vikunja #443).
|
||||
func TestFactKeyDistinguishesRussianEventsOnOneDay(t *testing.T) {
|
||||
day := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
||||
a := Event{Summary: "Встреча с Аней", Start: day.Add(10 * time.Hour), End: day.Add(11 * time.Hour)}
|
||||
b := Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(14 * time.Hour)}
|
||||
if FactKeyIn(a, time.UTC) == FactKeyIn(b, time.UTC) {
|
||||
t.Fatalf("both events keyed as %q", FactKeyIn(a, time.UTC))
|
||||
}
|
||||
// The day prefix still has to survive, because the store range-scans on it.
|
||||
if !strings.HasPrefix(FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) {
|
||||
t.Fatalf("key %q lost the day prefix %q", FactKeyIn(a, time.UTC), KeyPrefixForDay(day))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ""}
|
||||
|
||||
@@ -99,6 +99,31 @@ func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The tomorrow form and the bare event noun. Both were measured answering
|
||||
// "пока не умею" on the deployed daemon, 02-08-2026, while the same question
|
||||
// about today worked — the first rule set needed "у меня" or a calendar noun
|
||||
// and these phrasings carry neither (Vikunja #471).
|
||||
func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) {
|
||||
r := agendaRouter(t)
|
||||
for _, u := range []string{
|
||||
"какие планы на завтра?",
|
||||
"какие планы на послезавтра",
|
||||
"что по делам в среду",
|
||||
"какие планы на выходные",
|
||||
"когда планёрка?",
|
||||
"во сколько созвон",
|
||||
"когда будет совещание",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
if d.Intent != IntentQuery {
|
||||
t.Errorf("route(%q) = %s, want query", u, d.Intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A narrative verb next to a capture verb is him asking for a note. Stage 0
|
||||
// declines and the extractor gets its turn.
|
||||
func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
|
||||
@@ -112,3 +137,22 @@ func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
|
||||
t.Errorf("stage 0 claimed a capture: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The two new rules are narrow on purpose. A world question that opens with
|
||||
// "когда" is not an agenda question, and telling her about a plan is not
|
||||
// asking about one.
|
||||
func TestAgendaGrammarsLeaveTheWorldAlone(t *testing.T) {
|
||||
r := agendaRouter(t)
|
||||
for _, u := range []string{
|
||||
"когда была битва при ватерлоо",
|
||||
"когда изобрели телефон",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
if d.Stage == 0 {
|
||||
t.Errorf("route(%q) was claimed at stage 0 as %s", u, d.Intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
||||
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
|
||||
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
||||
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
|
||||
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
|
||||
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
||||
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Standing lists, matched deterministically (Vikunja #453).
|
||||
//
|
||||
// Same posture as task capture in task.go and for the same reason: the intent
|
||||
// enum is a contract shared with the relabelling prompt, so a list is not an
|
||||
// eighth intent. It is a note-shaped or query-shaped utterance carrying an
|
||||
// explicit marker, and the marker is a lookup.
|
||||
//
|
||||
// The markers are deliberately explicit. "молоко закончилось" is an
|
||||
// observation about the world and belongs in a note; only an instruction to
|
||||
// put something on a list puts it there.
|
||||
|
||||
// listStems — the lists he can name, by the stem every case form shares.
|
||||
// Russian declines the tag ("список покупок", "в покупки", "в покупках"), so
|
||||
// matching a stem is what makes those the same list.
|
||||
var listStems = []struct{ stem, list string }{
|
||||
{"покуп", "покупки"},
|
||||
{"продукт", "покупки"},
|
||||
{"магазин", "покупки"},
|
||||
{"аптек", "аптека"},
|
||||
{"хозяйств", "хозяйство"},
|
||||
{"shopping", "покупки"},
|
||||
{"groceries", "покупки"},
|
||||
{"pharmacy", "аптека"},
|
||||
}
|
||||
|
||||
// listCapturePrefixes — an instruction to add to a list. Longest match wins.
|
||||
var listCapturePrefixes = []string{
|
||||
"добавь в список",
|
||||
"добавь в покупки",
|
||||
"добавь к покупкам",
|
||||
"запиши в список",
|
||||
"внеси в список",
|
||||
"положи в список",
|
||||
"в список покупок",
|
||||
"add to the list",
|
||||
"add to my list",
|
||||
"add to the shopping list",
|
||||
"put on the list",
|
||||
}
|
||||
|
||||
// listQueryPrefixes — an ask to read a list back.
|
||||
var listQueryPrefixes = []string{
|
||||
"что в списке",
|
||||
"что в покупках",
|
||||
"что мне купить",
|
||||
"что нужно купить",
|
||||
"что надо купить",
|
||||
"покажи список",
|
||||
"прочитай список",
|
||||
"список покупок",
|
||||
"мой список",
|
||||
"what is on the list",
|
||||
"what's on the list",
|
||||
"read me the list",
|
||||
"show me the list",
|
||||
"shopping list",
|
||||
}
|
||||
|
||||
// listClearPhrases — the whole list is got. One sentence, one turn.
|
||||
var listClearPhrases = []string{
|
||||
"всё купил",
|
||||
"все купил",
|
||||
"всё взял",
|
||||
"все взял",
|
||||
"очисти список",
|
||||
"очисти покупки",
|
||||
"список пустой",
|
||||
"got everything",
|
||||
"clear the list",
|
||||
}
|
||||
|
||||
// listRemovePrefixes — one item off the list.
|
||||
var listRemovePrefixes = []string{
|
||||
"вычеркни",
|
||||
"убери из списка",
|
||||
"убери со списка",
|
||||
"купил",
|
||||
"взял",
|
||||
"cross off",
|
||||
"remove from the list",
|
||||
}
|
||||
|
||||
// listTrimCut — punctuation and connectives to strip off a parsed remainder.
|
||||
const listTrimCut = " .,;:!?—-"
|
||||
|
||||
// ListCapture — a parsed list instruction: which list, and the item.
|
||||
type ListCapture struct {
|
||||
List string
|
||||
Item string
|
||||
}
|
||||
|
||||
// ParseListCapture reports whether an utterance puts something on a list, and
|
||||
// returns the list tag and the item. A marker with nothing usable after it is
|
||||
// not a capture: there is no item in "добавь в список покупок".
|
||||
func ParseListCapture(text string) (ListCapture, bool) {
|
||||
rest, ok := afterLongestPrefix(text, listCapturePrefixes)
|
||||
if !ok {
|
||||
return ListCapture{}, false
|
||||
}
|
||||
list, rest := takeListTag(rest)
|
||||
rest = strings.Trim(rest, listTrimCut)
|
||||
if rest == "" {
|
||||
return ListCapture{}, false
|
||||
}
|
||||
return ListCapture{List: list, Item: rest}, true
|
||||
}
|
||||
|
||||
// ParseListQuery reports whether an utterance asks for a list, and which one.
|
||||
func ParseListQuery(text string) (string, bool) {
|
||||
rest, ok := afterLongestPrefix(text, listQueryPrefixes)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
list, _ := takeListTag(rest)
|
||||
return list, true
|
||||
}
|
||||
|
||||
// ParseListClear reports whether an utterance crosses off a whole list.
|
||||
func ParseListClear(text string) (string, bool) {
|
||||
lower := strings.ToLower(strings.Trim(strings.TrimSpace(text), listTrimCut))
|
||||
for _, p := range listClearPhrases {
|
||||
if lower == p || strings.HasPrefix(lower, p+" ") {
|
||||
list, _ := takeListTag(strings.TrimSpace(lower[len(p):]))
|
||||
return list, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ParseListRemove reports whether an utterance takes one named item off a
|
||||
// list, and returns the list and the item.
|
||||
//
|
||||
// The item is required. "купил" on its own is him reporting he shopped, which
|
||||
// ParseListClear reads first, and it must not fall through to here and remove
|
||||
// nothing while sounding like it did.
|
||||
func ParseListRemove(text string) (ListCapture, bool) {
|
||||
rest, ok := afterLongestPrefix(text, listRemovePrefixes)
|
||||
if !ok {
|
||||
return ListCapture{}, false
|
||||
}
|
||||
list, rest := takeListTag(rest)
|
||||
rest = strings.Trim(rest, listTrimCut)
|
||||
for _, lead := range []string{"из списка ", "со списка ", "из ", "from the list "} {
|
||||
rest = strings.TrimPrefix(rest, lead)
|
||||
}
|
||||
rest = strings.Trim(rest, listTrimCut)
|
||||
if rest == "" {
|
||||
return ListCapture{}, false
|
||||
}
|
||||
return ListCapture{List: list, Item: rest}, true
|
||||
}
|
||||
|
||||
// afterLongestPrefix matches the longest prefix in the table and returns what
|
||||
// follows it, trimmed. Lowercasing does not change the byte length of Russian
|
||||
// or English letters, so the index carries over to the original text.
|
||||
func afterLongestPrefix(text string, prefixes []string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
lower := strings.ToLower(trimmed)
|
||||
best := ""
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(lower, p) && len(p) > len(best) {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return "", false
|
||||
}
|
||||
return strings.Trim(trimmed[len(best):], listTrimCut), true
|
||||
}
|
||||
|
||||
// takeListTag reads a list name off the front of the remainder and returns the
|
||||
// list plus what is left. A remainder naming no list is the default list, and
|
||||
// nothing is consumed — "добавь в список молоко" names no list and the item is
|
||||
// молоко.
|
||||
func takeListTag(rest string) (string, string) {
|
||||
fields := strings.Fields(rest)
|
||||
if len(fields) == 0 {
|
||||
return "покупки", ""
|
||||
}
|
||||
head := strings.ToLower(strings.Trim(fields[0], listTrimCut))
|
||||
// "в список покупок" leaves "покупок"; "в списке" leaves nothing.
|
||||
if head == "список" || head == "списке" || head == "списка" || head == "list" {
|
||||
fields = fields[1:]
|
||||
if len(fields) == 0 {
|
||||
return "покупки", ""
|
||||
}
|
||||
head = strings.ToLower(strings.Trim(fields[0], listTrimCut))
|
||||
}
|
||||
for _, s := range listStems {
|
||||
if strings.HasPrefix(head, s.stem) {
|
||||
return s.list, strings.Join(fields[1:], " ")
|
||||
}
|
||||
}
|
||||
return "покупки", strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
// ListGrammars — stage 0 for the list (Vikunja #453).
|
||||
//
|
||||
// Both patterns match everything and the Build functions are the real filter,
|
||||
// the shape the wake-word act grammar already uses: the parsers above are the
|
||||
// definition of a list utterance and duplicating them as regexps would give
|
||||
// two answers to one question.
|
||||
//
|
||||
// Why stage 0 at all: an add and a read-back are deterministic and cheap, and
|
||||
// leaving them to the model means "добавь в список покупок молоко" lands as an
|
||||
// act or a fact on the turns the model has a bad day. The action handlers still
|
||||
// re-parse, so a list turn that arrives by any other route still works.
|
||||
func ListGrammars() []Grammar {
|
||||
anything := regexp.MustCompile(`(?s)^(.*)$`)
|
||||
return []Grammar{
|
||||
{
|
||||
Name: "list-query",
|
||||
Pattern: anything,
|
||||
Build: func(m []string) (Decision, bool) {
|
||||
if _, ok := ParseListQuery(m[1]); !ok {
|
||||
return Decision{}, false
|
||||
}
|
||||
return Decision{Stage: 0, Intent: IntentQuery, Confidence: 1.0}, true
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list-capture",
|
||||
Pattern: anything,
|
||||
Build: func(m []string) (Decision, bool) {
|
||||
text := m[1]
|
||||
_, add := ParseListCapture(text)
|
||||
_, clear := ParseListClear(text)
|
||||
if !add && !clear {
|
||||
return Decision{}, false
|
||||
}
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentNote,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Text: strings.TrimSpace(text)},
|
||||
}, true
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseListCaptureReadsListAndItem(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
list string
|
||||
item string
|
||||
}{
|
||||
{"добавь в список покупок молоко", "покупки", "молоко"},
|
||||
{"добавь в список молоко", "покупки", "молоко"},
|
||||
{"Добавь в покупки хлеб и яйца", "покупки", "хлеб и яйца"},
|
||||
{"запиши в список аптеки бинт", "аптека", "бинт"},
|
||||
{"добавь в список хозяйства лампочки.", "хозяйство", "лампочки"},
|
||||
{"add to the shopping list milk", "покупки", "milk"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := ParseListCapture(c.utterance)
|
||||
if !ok {
|
||||
t.Errorf("ParseListCapture(%q) did not claim it", c.utterance)
|
||||
continue
|
||||
}
|
||||
if got.List != c.list || got.Item != c.item {
|
||||
t.Errorf("ParseListCapture(%q) = %+v; want list %q item %q", c.utterance, got, c.list, c.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A marker with no item is not a capture, and an utterance that only mentions
|
||||
// shopping is not one either.
|
||||
func TestParseListCapturePasses(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"добавь в список покупок",
|
||||
"добавь в список",
|
||||
"молоко закончилось",
|
||||
"надо бы съездить в магазин",
|
||||
"добавь в задачи купить молоко",
|
||||
} {
|
||||
if got, ok := ParseListCapture(u); ok {
|
||||
t.Errorf("ParseListCapture(%q) claimed it as %+v", u, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListQueryNamesTheList(t *testing.T) {
|
||||
cases := []struct{ utterance, list string }{
|
||||
{"что в списке покупок?", "покупки"},
|
||||
{"что в списке", "покупки"},
|
||||
{"что мне купить", "покупки"},
|
||||
{"покажи список аптеки", "аптека"},
|
||||
{"what's on the list", "покупки"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
list, ok := ParseListQuery(c.utterance)
|
||||
if !ok {
|
||||
t.Errorf("ParseListQuery(%q) did not claim it", c.utterance)
|
||||
continue
|
||||
}
|
||||
if list != c.list {
|
||||
t.Errorf("ParseListQuery(%q) = %q; want %q", c.utterance, list, c.list)
|
||||
}
|
||||
}
|
||||
if _, ok := ParseListQuery("какие у меня задачи"); ok {
|
||||
t.Error("ParseListQuery claimed a task question")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListClearAndRemove(t *testing.T) {
|
||||
if list, ok := ParseListClear("всё купил"); !ok || list != "покупки" {
|
||||
t.Errorf("ParseListClear = %q, %v; want покупки, true", list, ok)
|
||||
}
|
||||
if list, ok := ParseListClear("очисти список аптеки"); !ok || list != "аптека" {
|
||||
t.Errorf("ParseListClear = %q, %v; want аптека, true", list, ok)
|
||||
}
|
||||
if _, ok := ParseListClear("купил молоко"); ok {
|
||||
t.Error("ParseListClear claimed a single item")
|
||||
}
|
||||
got, ok := ParseListRemove("вычеркни молоко")
|
||||
if !ok || got.Item != "молоко" || got.List != "покупки" {
|
||||
t.Errorf("ParseListRemove = %+v, %v; want молоко on покупки", got, ok)
|
||||
}
|
||||
if got, ok := ParseListRemove("убери из списка аптеки бинт"); !ok || got.Item != "бинт" || got.List != "аптека" {
|
||||
t.Errorf("ParseListRemove = %+v, %v; want бинт on аптека", got, ok)
|
||||
}
|
||||
if _, ok := ParseListRemove("вычеркни"); ok {
|
||||
t.Error("ParseListRemove claimed a marker with no item")
|
||||
}
|
||||
}
|
||||
@@ -210,7 +210,11 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time)
|
||||
d.Slots.HasKey = a.Key != ""
|
||||
case IntentReminder:
|
||||
d.Intent = IntentReminder
|
||||
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
||||
// No utterance fallback here, unlike every other intent below. The
|
||||
// model returning no text for a reminder means it found no subject,
|
||||
// and "напомни в 11" is not a subject. Leaving Text empty is what
|
||||
// lets the gate turn that into a question (Vikunja #383).
|
||||
d.Slots.Text = a.Text
|
||||
case IntentNote:
|
||||
d.Intent = IntentNote
|
||||
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
||||
|
||||
@@ -356,3 +356,35 @@ func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) {
|
||||
t.Fatalf("a fact the parser could key must not clarify: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// A reminder with a time and no subject must come back empty and gated, not
|
||||
// backfilled with the raw words. "напомни в 11" carries an hour and nothing to
|
||||
// say at that hour; parking the utterance in Text made the request look
|
||||
// complete, so the daemon set a reminder that fires saying "напомни в 11"
|
||||
// (Vikunja #383).
|
||||
func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"reminder"}`)
|
||||
d, err := r.Route(context.Background(), "напомни в 11", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
if d.Slots.Text != "" {
|
||||
t.Fatalf("subject backfilled from the utterance: %q", d.Slots.Text)
|
||||
}
|
||||
if !d.Clarify {
|
||||
t.Fatalf("a subjectless reminder was accepted, confidence %v", d.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate is about the subject, not about reminders in general: one that has
|
||||
// both halves still runs without a question.
|
||||
func TestLLMReminderWithSubjectIsNotGated(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||||
d, err := r.Route(context.Background(), "напомни в 11 позвонить маме", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
if d.Clarify {
|
||||
t.Fatalf("a complete reminder was sent back as a question: %+v", d.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,15 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
|
||||
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
|
||||
}
|
||||
}
|
||||
if d.Slots.Text == "" {
|
||||
// The extractor's Text is the raw utterance, which is the payload for a
|
||||
// note, a query or a chat turn but not for a reminder — there Text is the
|
||||
// subject, what she says at the hour. Backfilling it made Text impossible
|
||||
// to be empty, so StillMissing never reported SlotText and "О чём
|
||||
// напомнить?" was unaskable; the answer to a question she did manage to
|
||||
// ask then overwrote the whole request instead of filling one gap
|
||||
// (Vikunja #383). A reminder with no subject stays empty and is gated
|
||||
// below into a question.
|
||||
if d.Slots.Text == "" && d.Intent != IntentReminder {
|
||||
d.Slots.Text = ex.Text
|
||||
}
|
||||
// Stage stays 1: it says who decided the route, and that was the LLM.
|
||||
@@ -177,6 +185,12 @@ func (r *Router) gateLLMDecision(d *Decision) {
|
||||
if d.Intent == IntentAct && !d.Slots.HasFn && d.Confidence > llmThinConfidence {
|
||||
d.Confidence = llmThinConfidence
|
||||
}
|
||||
// A reminder with no subject: she knows when but not what to say then.
|
||||
// Setting it anyway fires an empty reminder at the hour, which reads as a
|
||||
// bug to him and cannot be repaired after the fact. Ask (Vikunja #383).
|
||||
if d.Intent == IntentReminder && d.Slots.Text == "" && d.Confidence > llmThinConfidence {
|
||||
d.Confidence = llmThinConfidence
|
||||
}
|
||||
if d.Confidence < r.threshold {
|
||||
d.Clarify = true
|
||||
}
|
||||
|
||||
@@ -182,6 +182,29 @@ func AgendaQueryGrammars() []Grammar {
|
||||
Pattern: regexp.MustCompile(`(?i)^\s*(что|чего|какие|сколько|во\s+сколько|когда)\s+у\s+меня(\s|[?!.]|$)`),
|
||||
Build: agendaQueryBuild,
|
||||
},
|
||||
{
|
||||
// A plan noun aimed at a named day, with no possessive to anchor
|
||||
// on: "какие планы на завтра", "что по делам в среду". The rule
|
||||
// above wants "у меня" and this phrasing never has it, so
|
||||
// "какие планы на завтра" answered "пока не умею" while "какие
|
||||
// планы на сегодня" worked (Vikunja #471). The day word is what
|
||||
// makes it an agenda question rather than a topic.
|
||||
Name: "plan-day-query",
|
||||
// Only "план" and "дел". A verb stem like "встреч" would take
|
||||
// "встречаемся в среду", which is him telling her something, not
|
||||
// asking.
|
||||
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
|
||||
Build: agendaQueryBuild,
|
||||
},
|
||||
{
|
||||
// A named event with no calendar word at all: "когда планёрка?",
|
||||
// "во сколько созвон". He is asking when something on his calendar
|
||||
// happens, and the noun is the only signal. Closed list, so "когда
|
||||
// битва при Ватерлоо" is still a world question.
|
||||
Name: "event-time-query",
|
||||
Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\s|[?!.]|$)`),
|
||||
Build: agendaQueryBuild,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +275,12 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||
return agendaQueryBuild(m)
|
||||
}
|
||||
|
||||
// dayWordPattern — the day words an agenda question can name. Weekdays appear
|
||||
// in the accusative and prepositional forms the questions actually use ("в
|
||||
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
||||
// than a fixed ending.
|
||||
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
|
||||
|
||||
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
||||
// the intent only: the utterance travels intact and the query chain's own
|
||||
// matchers decide the rest.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// List items — the fourth append-only shape (Vikunja #453).
|
||||
//
|
||||
// A list is a standing set of short strings under a tag: покупки, аптека,
|
||||
// хозяйство. It is not work and it is not a claim about the world, which is
|
||||
// why it is neither a task nor a fact. Nothing here is prioritised, nothing
|
||||
// nudges about it, and the digestion worker does not read it. The only two
|
||||
// things a list does are grow and shrink.
|
||||
//
|
||||
// The consequence that made it worth a table: because no predicate touches a
|
||||
// list item, several people adding to the same list at once cost nothing. There
|
||||
// is no ranking to disagree about and no lifecycle beyond crossed-off.
|
||||
const (
|
||||
// ListItemOpen — on the list.
|
||||
ListItemOpen = "open"
|
||||
// ListItemDone — bought, taken, crossed off.
|
||||
ListItemDone = "done"
|
||||
// ListItemDropped — removed without being got.
|
||||
ListItemDropped = "dropped"
|
||||
)
|
||||
|
||||
// DefaultList — the list a capture lands on when he names none. Almost every
|
||||
// spoken list item is groceries, and asking "в какой список?" for the common
|
||||
// case would be a nag.
|
||||
const DefaultList = "покупки"
|
||||
|
||||
// ListItem — one line on one list.
|
||||
type ListItem struct {
|
||||
ID int64
|
||||
CreatedTs time.Time
|
||||
List string
|
||||
Item string
|
||||
Source string
|
||||
Status string
|
||||
ResolvedTs *time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
ErrListItemNotFound = errors.New("store: list item not found")
|
||||
ErrListItemEmpty = errors.New("store: list item is empty")
|
||||
ErrListItemStatus = errors.New("store: invalid list item status")
|
||||
)
|
||||
|
||||
// NormalizeListName folds a list tag to its dedupe form. Lists are named out
|
||||
// loud, so "Покупки" and "покупки " are the same list.
|
||||
func NormalizeListName(s string) string {
|
||||
n := NormalizeTaskText(s)
|
||||
if n == "" {
|
||||
return DefaultList
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AddListItem puts an item on a list, or returns the existing row when the same
|
||||
// item is already on it. Created says which happened, so the caller can say
|
||||
// "уже есть" instead of pretending it wrote something.
|
||||
func (s *Store) AddListItem(ctx context.Context, li ListItem) (CaptureResult, error) {
|
||||
item := strings.TrimSpace(li.Item)
|
||||
if item == "" {
|
||||
return CaptureResult{}, ErrListItemEmpty
|
||||
}
|
||||
list := NormalizeListName(li.List)
|
||||
norm := NormalizeTaskText(item)
|
||||
created := li.CreatedTs
|
||||
if created.IsZero() {
|
||||
created = time.Now()
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO list_items (created_ts, list, item, norm, source, status)
|
||||
VALUES (?,?,?,?,?,?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
created.UnixMilli(), list, item, norm, li.Source, ListItemOpen)
|
||||
if err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("add list item: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("add list item: rows affected: %w", err)
|
||||
}
|
||||
if n > 0 {
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("add list item: last insert id: %w", err)
|
||||
}
|
||||
return CaptureResult{ID: id, Created: true}, nil
|
||||
}
|
||||
var id int64
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT id FROM list_items WHERE list = ? AND norm = ? AND status = ?`,
|
||||
list, norm, ListItemOpen).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return CaptureResult{}, ErrListItemNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("add list item: lookup: %w", err)
|
||||
}
|
||||
return CaptureResult{ID: id}, nil
|
||||
}
|
||||
|
||||
// ListItems reads one list in the order it was added. An empty status reads the
|
||||
// open items, which is what reading the list aloud means.
|
||||
func (s *Store) ListItems(ctx context.Context, list, status string) ([]ListItem, error) {
|
||||
if status == "" {
|
||||
status = ListItemOpen
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, created_ts, list, item, source, status, resolved_ts
|
||||
FROM list_items WHERE list = ? AND status = ?
|
||||
ORDER BY created_ts, id`,
|
||||
NormalizeListName(list), status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ListItem
|
||||
for rows.Next() {
|
||||
var (
|
||||
li ListItem
|
||||
created int64
|
||||
resolved sql.NullInt64
|
||||
)
|
||||
if err := rows.Scan(&li.ID, &created, &li.List, &li.Item, &li.Source, &li.Status, &resolved); err != nil {
|
||||
return nil, fmt.Errorf("list items: scan: %w", err)
|
||||
}
|
||||
li.CreatedTs = time.UnixMilli(created)
|
||||
if resolved.Valid {
|
||||
t := time.UnixMilli(resolved.Int64)
|
||||
li.ResolvedTs = &t
|
||||
}
|
||||
out = append(out, li)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list items: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetListItemStatus crosses an item off, or removes it. Moving an item that is
|
||||
// already resolved is not an error — crossing off twice is the same list.
|
||||
func (s *Store) SetListItemStatus(ctx context.Context, id int64, status string, at time.Time) error {
|
||||
if status != ListItemOpen && status != ListItemDone && status != ListItemDropped {
|
||||
return fmt.Errorf("%w: %q", ErrListItemStatus, status)
|
||||
}
|
||||
var resolved sql.NullInt64
|
||||
if status != ListItemOpen {
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
resolved = sql.NullInt64{Int64: at.UnixMilli(), Valid: true}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE id = ?`,
|
||||
status, resolved, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("set list item status: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("set list item status: rows affected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrListItemNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearList crosses off every open item on a list and reports how many. This is
|
||||
// "всё купил", which is one sentence and must not become one turn per item.
|
||||
func (s *Store) ClearList(ctx context.Context, list string, at time.Time) (int, error) {
|
||||
if at.IsZero() {
|
||||
at = time.Now()
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE list = ? AND status = ?`,
|
||||
ListItemDone, at.UnixMilli(), NormalizeListName(list), ListItemOpen)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("clear list: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("clear list: rows affected: %w", err)
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var listNow = time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestAddListItemDedupesTheOpenList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
first, err := s.AddListItem(ctx, ListItem{Item: "молоко", Source: "tap:voice", CreatedTs: listNow})
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if !first.Created {
|
||||
t.Fatal("the first молоко did not create a row")
|
||||
}
|
||||
again, err := s.AddListItem(ctx, ListItem{Item: " Молоко ", Source: "tap:voice", CreatedTs: listNow})
|
||||
if err != nil {
|
||||
t.Fatalf("add again: %v", err)
|
||||
}
|
||||
if again.Created {
|
||||
t.Error("молоко was added twice")
|
||||
}
|
||||
if again.ID != first.ID {
|
||||
t.Errorf("second add points at %d; want the existing %d", again.ID, first.ID)
|
||||
}
|
||||
if _, err := s.AddListItem(ctx, ListItem{Item: " "}); !errors.Is(err, ErrListItemEmpty) {
|
||||
t.Errorf("empty item: %v; want ErrListItemEmpty", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A crossed-off item does not block the next one: buying milk again next week
|
||||
// is a new line, the way saying an errand again is a new task.
|
||||
func TestCrossedOffItemComesBack(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
first, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow})
|
||||
if err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if err := s.SetListItemStatus(ctx, first.ID, ListItemDone, listNow); err != nil {
|
||||
t.Fatalf("cross off: %v", err)
|
||||
}
|
||||
next, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow.Add(time.Hour)})
|
||||
if err != nil {
|
||||
t.Fatalf("add after: %v", err)
|
||||
}
|
||||
if !next.Created || next.ID == first.ID {
|
||||
t.Errorf("second молоко reused row %d; want a new one", next.ID)
|
||||
}
|
||||
open, err := s.ListItems(ctx, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(open) != 1 || open[0].ID != next.ID {
|
||||
t.Errorf("open list %+v; want only the new row", open)
|
||||
}
|
||||
}
|
||||
|
||||
// Lists are separate stores under one table: the same word on two lists is two
|
||||
// items, and reading one never reads the other.
|
||||
func TestListsDoNotSeeEachOther(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if _, err := s.AddListItem(ctx, ListItem{List: "покупки", Item: "вода", CreatedTs: listNow}); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
if _, err := s.AddListItem(ctx, ListItem{List: "Аптека", Item: "вода", CreatedTs: listNow}); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
for _, c := range []struct{ list, want string }{
|
||||
{"покупки", "покупки"},
|
||||
{"аптека", "аптека"},
|
||||
{"", "покупки"},
|
||||
} {
|
||||
got, err := s.ListItems(ctx, c.list, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list %q: %v", c.list, err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("list %q has %d items; want 1", c.list, len(got))
|
||||
}
|
||||
if got[0].List != c.want {
|
||||
t.Errorf("list %q returned tag %q; want %q", c.list, got[0].List, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearListCrossesOffEverythingOpen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
for _, item := range []string{"молоко", "хлеб", "яйца"} {
|
||||
if _, err := s.AddListItem(ctx, ListItem{Item: item, CreatedTs: listNow}); err != nil {
|
||||
t.Fatalf("add %s: %v", item, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.AddListItem(ctx, ListItem{List: "аптека", Item: "бинт", CreatedTs: listNow}); err != nil {
|
||||
t.Fatalf("add: %v", err)
|
||||
}
|
||||
n, err := s.ClearList(ctx, "покупки", listNow)
|
||||
if err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Errorf("cleared %d; want 3", n)
|
||||
}
|
||||
left, err := s.ListItems(ctx, "покупки", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(left) != 0 {
|
||||
t.Errorf("%d items still open; want none", len(left))
|
||||
}
|
||||
done, err := s.ListItems(ctx, "покупки", ListItemDone)
|
||||
if err != nil {
|
||||
t.Fatalf("list done: %v", err)
|
||||
}
|
||||
if len(done) != 3 || done[0].ResolvedTs == nil {
|
||||
t.Errorf("done list %+v; want 3 rows carrying a resolved time", done)
|
||||
}
|
||||
other, err := s.ListItems(ctx, "аптека", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(other) != 1 {
|
||||
t.Error("clearing покупки touched аптека")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetListItemStatusRejectsWhatIsNotAStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.SetListItemStatus(ctx, 1, "куплено", listNow); !errors.Is(err, ErrListItemStatus) {
|
||||
t.Errorf("bad status: %v; want ErrListItemStatus", err)
|
||||
}
|
||||
if err := s.SetListItemStatus(ctx, 999, ListItemDone, listNow); !errors.Is(err, ErrListItemNotFound) {
|
||||
t.Errorf("missing row: %v; want ErrListItemNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,38 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
// list_tasks into something that writes without the row changing by one
|
||||
// byte. The fingerprint is the declared shape at approval time, so a
|
||||
// redefinition is a re-approval instead of a silent upgrade.
|
||||
`DELETE FROM facts
|
||||
WHERE key LIKE 'calendar_event_%'
|
||||
AND replace(substr(key, 25), '-', '') = '';`,
|
||||
// #18 — drop the calendar keys written while safeKey dropped Cyrillic
|
||||
// (Vikunja #443). Everything after the date prefix was punctuation, so
|
||||
// every Russian event on one day shared one key and only the last one
|
||||
// survived. Deleting rather than rewriting: a calendar fact is derived
|
||||
// data, the next poll writes the day again under keys that identify the
|
||||
// event, and the old rows would otherwise be recited as extra meetings.
|
||||
// The filter is exact — it keeps any key whose summary part still has a
|
||||
// letter or a digit in it.
|
||||
`CREATE TABLE IF NOT EXISTS list_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_ts INTEGER NOT NULL,
|
||||
list TEXT NOT NULL,
|
||||
item TEXT NOT NULL,
|
||||
norm TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','done','dropped')),
|
||||
resolved_ts INTEGER
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_list_items_live ON list_items (list, norm) WHERE status = 'open';
|
||||
CREATE INDEX IF NOT EXISTS idx_list_items_list ON list_items (list, status, created_ts);`,
|
||||
// #19 — standing lists (Vikunja #453). The fourth append-only shape, after
|
||||
// facts, notes and tasks, and the reason it is its own table rather than a
|
||||
// tag on tasks: milk on the shopping list is not work. Nothing prioritises
|
||||
// it, nothing nudges about it, and the prioritiser must not start counting
|
||||
// groceries as outstanding errands.
|
||||
//
|
||||
// The live-only unique index is the tasks one, per list: saying "молоко"
|
||||
// twice before the shop keeps one row, saying it again next week after the
|
||||
// last one was crossed off writes a new one.
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -47,3 +47,36 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
|
||||
t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Migration #18 clears the calendar keys written while safeKey dropped
|
||||
// Cyrillic. Those rows are indistinguishable from real events on read, so
|
||||
// leaving them would recite one meeting as several (Vikunja #443).
|
||||
func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
|
||||
rows := []string{
|
||||
"calendar_event_20260804_--", // "Встреча с Аней" under the old rule
|
||||
"calendar_event_20260804_", // a one-word Russian summary
|
||||
"calendar_event_20260804_Встреча-с-Аней", // the new format
|
||||
"calendar_event_20260804_Standup", // an ASCII summary, always fine
|
||||
}
|
||||
for _, key := range rows {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO facts (ts, kind, key, value, source, confidence) VALUES (0, 'env', ?, 'x', 'poll:caldav', 1.0)`,
|
||||
key); err != nil {
|
||||
t.Fatalf("seed %q: %v", key, err)
|
||||
}
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, migrations[17]); err != nil {
|
||||
t.Fatalf("migration 18: %v", err)
|
||||
}
|
||||
|
||||
var got int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM facts WHERE key LIKE 'calendar_event_%'`).Scan(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 2 {
|
||||
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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