Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 947506c7b8 | |||
| 6c67e61962 | |||
| 0990f32808 | |||
| d41878c2b1 | |||
| e023638135 | |||
| 0d52344d27 | |||
| 5bd303788b | |||
| afac8fb670 | |||
| 908d92a7e8 | |||
| 43f2c37538 | |||
| 6d3f5b5b01 | |||
| eda1112f3b |
@@ -40,7 +40,6 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -59,14 +58,10 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
|
||||
// Conversational: build history from dialogue session (prior user turns)
|
||||
// and let the LLM respond from general knowledge + context.
|
||||
history := h.chatHistory()
|
||||
// The phraser hands back its own fallback text alongside the error, so the
|
||||
// turn survives a dead server and the failure still reaches the log.
|
||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||
if err != nil {
|
||||
log.Printf("voice: chat: %v", err)
|
||||
}
|
||||
if reply == "" {
|
||||
return phraser.ChatFallback
|
||||
return "поговорили."
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Standing lists on the voice path (Vikunja #453).
|
||||
//
|
||||
// Three halves, mirroring what task capture already does: an add that runs at
|
||||
// the top of actionNote, a read-back query source, and a crossing-off that runs
|
||||
// on the same note path because "всё купил" is note-shaped.
|
||||
//
|
||||
// These read h.dataStore rather than the CoreAPI. A list is local to the core
|
||||
// and nothing outside it writes one: the web UI has no list page, no reach
|
||||
// files groceries, and the digestion worker does not read the table. When
|
||||
// something outside mavend needs to add to a list, the ipc seam is what it
|
||||
// grows through — the intake rules that CaptureTaskReq documents are about
|
||||
// shared intake, and there is none here yet.
|
||||
//
|
||||
// Nothing here speaks unprompted. A list is answered when asked about.
|
||||
|
||||
// captureListFromNote claims the turn when the utterance adds to, clears, or
|
||||
// crosses one item off a list. ("", false) hands the turn back to the note path.
|
||||
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||
if h.dataStore == nil {
|
||||
return "", false
|
||||
}
|
||||
// Clearing is read before removing on purpose: "всё купил" and "купил
|
||||
// молоко" start with the same word, and only the second one names an item.
|
||||
if list, ok := router.ParseListClear(dec.Utterance); ok {
|
||||
n, err := h.dataStore.ClearList(ctx, list, h.now())
|
||||
if err != nil {
|
||||
log.Printf("voice: clear list: %v", err)
|
||||
return "не получилось обновить список.", true
|
||||
}
|
||||
if n == 0 {
|
||||
return "в списке и так ничего не было.", true
|
||||
}
|
||||
return "вычеркнула всё, список пустой.", true
|
||||
}
|
||||
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
|
||||
if reply, ok := h.removeListItem(ctx, cap); ok {
|
||||
return reply, true
|
||||
}
|
||||
// Nothing on the list by that name. "купил новый ноутбук" is a note and
|
||||
// must stay one, so the turn goes back rather than claiming a removal
|
||||
// that removed nothing.
|
||||
return "", false
|
||||
}
|
||||
cap, ok := router.ParseListCapture(dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
|
||||
List: cap.List,
|
||||
Item: cap.Item,
|
||||
Source: "tap:voice",
|
||||
CreatedTs: h.now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: add list item: %v", err)
|
||||
return "не получилось добавить в список.", true
|
||||
}
|
||||
if !res.Created {
|
||||
return cap.Item + " уже в списке.", true
|
||||
}
|
||||
return "добавила в список: " + cap.Item + ".", true
|
||||
}
|
||||
|
||||
// removeListItem crosses one named item off. It reports false when the list
|
||||
// holds nothing by that name, which is what keeps the marker words from
|
||||
// swallowing ordinary notes.
|
||||
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
|
||||
items, err := h.dataStore.ListItems(ctx, cap.List, "")
|
||||
if err != nil {
|
||||
log.Printf("voice: list items: %v", err)
|
||||
return "", false
|
||||
}
|
||||
want := store.NormalizeTaskText(cap.Item)
|
||||
for _, li := range items {
|
||||
if store.NormalizeTaskText(li.Item) != want {
|
||||
continue
|
||||
}
|
||||
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
|
||||
log.Printf("voice: cross off list item: %v", err)
|
||||
return "не получилось обновить список.", true
|
||||
}
|
||||
return "вычеркнула: " + li.Item + ".", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// queryList — "что в списке покупок?", "что мне купить?".
|
||||
//
|
||||
// A query source, so it sits in querySources and either claims the turn or
|
||||
// passes it on. It is before the recall sources for the reason every specific
|
||||
// source is: the notes pass would otherwise answer a list question with
|
||||
// whatever note is nearest.
|
||||
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
list, ok := router.ParseListQuery(t.dec.Utterance)
|
||||
if !ok || h.dataStore == nil {
|
||||
return "", false
|
||||
}
|
||||
items, err := h.dataStore.ListItems(ctx, list, "")
|
||||
if err != nil {
|
||||
log.Printf("voice: list items: %v", err)
|
||||
return "не получилось посмотреть список.", true
|
||||
}
|
||||
return formatListRU(list, items), true
|
||||
}
|
||||
|
||||
// formatListRU reads a list aloud. One sentence, comma-separated, because a
|
||||
// shopping list is heard in a shop and a numbered recital is unusable there.
|
||||
func formatListRU(list string, items []store.ListItem) string {
|
||||
name := "списке " + listGenitive(list)
|
||||
if len(items) == 0 {
|
||||
return "в " + name + " пусто."
|
||||
}
|
||||
names := make([]string, 0, len(items))
|
||||
for _, li := range items {
|
||||
names = append(names, li.Item)
|
||||
}
|
||||
return "в " + name + ": " + strings.Join(names, ", ") + "."
|
||||
}
|
||||
|
||||
// listGenitive puts a list tag into the case "список <…>" needs. Russian
|
||||
// declines the noun and she must not say "в списке покупки".
|
||||
func listGenitive(list string) string {
|
||||
switch list {
|
||||
case "покупки":
|
||||
return "покупок"
|
||||
case "аптека":
|
||||
return "аптеки"
|
||||
case "хозяйство":
|
||||
return "хозяйства"
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
||||
|
||||
func listHandler(t *testing.T) *reactiveHandler {
|
||||
t.Helper()
|
||||
return &reactiveHandler{dataStore: newTestStore(t), now: listNow}
|
||||
}
|
||||
|
||||
func say(t *testing.T, h *reactiveHandler, utterance string) (string, bool) {
|
||||
t.Helper()
|
||||
return h.captureListFromNote(context.Background(), router.Decision{
|
||||
Intent: router.IntentNote, Utterance: utterance,
|
||||
})
|
||||
}
|
||||
|
||||
func TestListCaptureAddsAndReadsBack(t *testing.T) {
|
||||
h := listHandler(t)
|
||||
for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} {
|
||||
if reply, ok := say(t, h, u); !ok {
|
||||
t.Fatalf("%q was not claimed (reply %q)", u, reply)
|
||||
}
|
||||
}
|
||||
if reply, ok := say(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") {
|
||||
t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok)
|
||||
}
|
||||
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что в списке покупок?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the list question was not claimed")
|
||||
}
|
||||
if !strings.Contains(answer, "молоко") || !strings.Contains(answer, "хлеб") {
|
||||
t.Errorf("answer %q; want both items", answer)
|
||||
}
|
||||
if strings.Contains(answer, "списке покупки") {
|
||||
t.Errorf("answer %q declines the list name wrong", answer)
|
||||
}
|
||||
}
|
||||
|
||||
// An utterance with no list marker is a note and must stay one, whichever half
|
||||
// of the parser it brushes against.
|
||||
func TestListCapturePassesOrdinaryNotes(t *testing.T) {
|
||||
h := listHandler(t)
|
||||
for _, u := range []string{
|
||||
"молоко закончилось",
|
||||
"надо бы съездить в магазин",
|
||||
"купил новый ноутбук",
|
||||
"добавь в список покупок",
|
||||
} {
|
||||
if reply, ok := say(t, h, u); ok {
|
||||
t.Errorf("%q was claimed as a list turn: %q", u, reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCrossOffOneItemAndThenAll(t *testing.T) {
|
||||
h := listHandler(t)
|
||||
for _, u := range []string{
|
||||
"добавь в список покупок молоко",
|
||||
"добавь в список покупок хлеб",
|
||||
"добавь в список аптеки бинт",
|
||||
} {
|
||||
if _, ok := say(t, h, u); !ok {
|
||||
t.Fatalf("%q was not claimed", u)
|
||||
}
|
||||
}
|
||||
reply, ok := say(t, h, "вычеркни молоко")
|
||||
if !ok || !strings.Contains(reply, "молоко") {
|
||||
t.Fatalf("cross off replied %q, %v", reply, ok)
|
||||
}
|
||||
open, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(open) != 1 || open[0].Item != "хлеб" {
|
||||
t.Fatalf("open list %+v; want only хлеб", open)
|
||||
}
|
||||
if reply, ok := say(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") {
|
||||
t.Errorf("clear replied %q, %v", reply, ok)
|
||||
}
|
||||
open, err = h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(open) != 0 {
|
||||
t.Errorf("%d items still open after всё купил", len(open))
|
||||
}
|
||||
// The other list is untouched, and it is read back on its own.
|
||||
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "покажи список аптеки"},
|
||||
})
|
||||
if !ok || !strings.Contains(answer, "бинт") {
|
||||
t.Errorf("аптека answer %q, %v; want бинт", answer, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryListSaysWhenItIsEmpty(t *testing.T) {
|
||||
h := listHandler(t)
|
||||
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что мне купить?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the list question was not claimed")
|
||||
}
|
||||
if !strings.Contains(answer, "пусто") {
|
||||
t.Errorf("empty answer %q; want it to say so", answer)
|
||||
}
|
||||
if _, ok := h.queryList(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||
}); ok {
|
||||
t.Error("the list source claimed a task question")
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 0 answers a list turn without the model: the grammars route it, and the
|
||||
// action handlers re-parse what the grammar matched.
|
||||
func TestListGrammarsRouteWithoutTheModel(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want router.Intent
|
||||
}{
|
||||
{"добавь в список покупок молоко", router.IntentNote},
|
||||
{"что в списке покупок?", router.IntentQuery},
|
||||
{"всё купил", router.IntentNote},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var got router.Intent
|
||||
claimed := false
|
||||
for _, g := range router.ListGrammars() {
|
||||
m := g.Pattern.FindStringSubmatch(c.utterance)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if dec, ok := g.Build(m); ok {
|
||||
got, claimed = dec.Intent, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !claimed {
|
||||
t.Errorf("no list grammar claimed %q", c.utterance)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%q routed to %v; want %v", c.utterance, got, c.want)
|
||||
}
|
||||
}
|
||||
for _, g := range router.ListGrammars() {
|
||||
m := g.Pattern.FindStringSubmatch("напомни купить молоко завтра")
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := g.Build(m); ok {
|
||||
t.Errorf("grammar %s claimed a reminder", g.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListStoreSourceIsVoice(t *testing.T) {
|
||||
h := listHandler(t)
|
||||
if _, ok := say(t, h, "добавь в список покупок молоко"); !ok {
|
||||
t.Fatal("not claimed")
|
||||
}
|
||||
items, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(items) != 1 || items[0].Source != "tap:voice" {
|
||||
t.Errorf("stored %+v; want one row from tap:voice", items)
|
||||
}
|
||||
if items[0].Status != store.ListItemOpen {
|
||||
t.Errorf("status %q; want open", items[0].Status)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,12 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||
return reply
|
||||
}
|
||||
// A standing list is neither work nor recall (Vikunja #453). Checked here
|
||||
// for the same reason and at the same cost: before the embedding is paid
|
||||
// for, and it passes the turn straight back when no marker matches.
|
||||
if reply, ok := h.captureListFromNote(ctx, dec); ok {
|
||||
return reply
|
||||
}
|
||||
// embed the note text with the same model the classifier uses, persist
|
||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||
// facts — no predicate reads it (spec's two-memory split).
|
||||
|
||||
@@ -85,6 +85,11 @@ var querySources = []querySource{
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
||||
// about the shopping list, and the recall pass would otherwise answer it
|
||||
// from an old note about the shop. Its matcher needs an explicit list
|
||||
// marker, so "надо бы съездить в магазин" is untouched.
|
||||
{name: "list", answer: (*reactiveHandler).queryList},
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// a question about the feeds she reads, and general knowledge would answer
|
||||
@@ -445,13 +450,7 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||
// stored.
|
||||
if hit.Meta["type"] == "note" {
|
||||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
|
||||
switch {
|
||||
case perr != nil:
|
||||
// Reading the note back verbatim beats the phraser's own fallback,
|
||||
// which only wraps the same text in "вот что я нашла:".
|
||||
log.Printf("voice: recall phrase: %v", perr)
|
||||
case reply != "":
|
||||
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
||||
return reply, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,3 +465,39 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the subject question: his answer must fill the empty slot,
|
||||
// not replace the request. Slots.Text used to be the whole raw utterance for
|
||||
// every intent, so the branch that fills a text slot could only ever overwrite
|
||||
// (Vikunja #383). Here the parked request holds the hour and the answer holds
|
||||
// what to say at it, and the reminder that lands has both.
|
||||
func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
at := h.now().Add(2 * time.Hour)
|
||||
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder,
|
||||
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
if !handled {
|
||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||||
}
|
||||
if reply == clarifyGaveUp {
|
||||
t.Fatalf("a good answer must not drop the request: %q", reply)
|
||||
}
|
||||
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
if err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
|
||||
}
|
||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||||
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
||||
}
|
||||
if !strings.Contains(reminders[0].Payload, "11") {
|
||||
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
)
|
||||
|
||||
// The persona checks, run before she speaks (Vikunja #399).
|
||||
//
|
||||
// RunChecks and RunTalkChecks only ever ran from the eval package, so
|
||||
// everything the fixtures measured was offline knowledge: we could say "about
|
||||
// one reply in three is broken" and still ship every one of them. This runs the
|
||||
// cheap half of that on the live path, and replaces a failing message with the
|
||||
// deterministic floor.
|
||||
//
|
||||
// Which checks: the unambiguous string tests only — feminine self-reference,
|
||||
// how she addresses him, and a leaked-reasoning test. Not length, which is
|
||||
// path-specific, and not ontopic, which compares against fragments the fixture
|
||||
// supplies and runtime does not have. Not hisgender either — see guardSpoken.
|
||||
//
|
||||
// No retry. A retry doubles the latency on the exact turn that is already going
|
||||
// badly, and on the nudge path the moment has passed.
|
||||
//
|
||||
// The known cost, written down because it is real: a wrongly flagged good reply
|
||||
// is replaced by a flatter stub one. That is the right trade — a stub sentence
|
||||
// is dull, a leaked reasoning trace is broken — but it means these checks can
|
||||
// no longer be tuned for sensitivity alone.
|
||||
|
||||
// checkLeak — the name reported when the model's scaffolding reaches the text.
|
||||
const checkLeak = "leak"
|
||||
|
||||
// leakPatterns — reasoning and protocol that belongs to the model, not to him.
|
||||
// The resident model is a Thinking variant, so an unclosed reasoning block is
|
||||
// the failure mode, not a hypothetical (Vikunja #398).
|
||||
var leakPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)<\s*/?\s*think`),
|
||||
regexp.MustCompile(`(?i)thinking\s*(process|:)`),
|
||||
regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
|
||||
// Raw contract JSON: the parser already unwraps a good one, so a body that
|
||||
// still carries the keys is one it could not read.
|
||||
regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
|
||||
// The persona block quoted back at him.
|
||||
regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
|
||||
}
|
||||
|
||||
// checkPersonaLeak reports whether the model's own scaffolding is in the text.
|
||||
func checkPersonaLeak(body string) (string, bool) {
|
||||
for _, re := range leakPatterns {
|
||||
if m := re.FindString(body); m != "" {
|
||||
return "leaked " + strings.TrimSpace(m), false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// personaRejects counts what the guard caught, by check name, so the real
|
||||
// production rate is knowable rather than inferred from the fixture.
|
||||
var personaRejects = struct {
|
||||
mu sync.Mutex
|
||||
by map[string]int
|
||||
}{by: map[string]int{}}
|
||||
|
||||
func personaRejectCounts() map[string]int {
|
||||
personaRejects.mu.Lock()
|
||||
defer personaRejects.mu.Unlock()
|
||||
out := make(map[string]int, len(personaRejects.by))
|
||||
for k, v := range personaRejects.by {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// guardSpoken checks a phrased message. It returns the failed check and false
|
||||
// when the message must not be said; path names the caller, for the log.
|
||||
//
|
||||
// An empty message passes: the caller already treats that as a failure and
|
||||
// falls back on its own, and reporting it as a persona breach would put a
|
||||
// misleading line in the count.
|
||||
func guardSpoken(path, body string) (string, bool) {
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return "", true
|
||||
}
|
||||
if detail, ok := checkPersonaLeak(body); !ok {
|
||||
return rejectSpoken(path, checkLeak, detail, body), false
|
||||
}
|
||||
// Feminine and address only. HisGender is not run here: it reads a
|
||||
// sentence-initial feminine verb with no pronoun — "записала, что ты выпил
|
||||
// воды" — as a woman being addressed, when it is her own correct
|
||||
// self-reference. Offline that is a point of score; on this path it would
|
||||
// replace a good reply with a stub one on every fact she confirms.
|
||||
for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
|
||||
if !r.Pass {
|
||||
return rejectSpoken(path, r.Name, r.Detail, body), false
|
||||
}
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
|
||||
// rejectSpoken logs what she nearly said and counts it. The whole text, not a
|
||||
// prefix: the point of the log line is that the failure can be read back later
|
||||
// and argued with.
|
||||
func rejectSpoken(path, check, detail, body string) string {
|
||||
personaRejects.mu.Lock()
|
||||
personaRejects.by[check]++
|
||||
personaRejects.mu.Unlock()
|
||||
log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
|
||||
return check
|
||||
}
|
||||
|
||||
// guardNudge checks a phrased nudge and falls back to the deterministic floor
|
||||
// when it fails. The nudge path, unlike the reply path, cannot ask again: the
|
||||
// tick has already decided she speaks, so the choice is the floor's wording or
|
||||
// a broken sentence.
|
||||
func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
|
||||
if _, ok := guardSpoken("nudge", pn.Body); ok {
|
||||
return pn
|
||||
}
|
||||
stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
|
||||
if err != nil {
|
||||
// The Stub is templates over the candidate and does not fail. If it
|
||||
// somehow does, the model's text is still what the rule decided to
|
||||
// say, and saying nothing is the worse outcome.
|
||||
return pn
|
||||
}
|
||||
return stub
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
)
|
||||
|
||||
func TestGuardPassesWhatSheShouldSay(t *testing.T) {
|
||||
good := []string{
|
||||
"записала: купить хлеб.",
|
||||
"поняла, напомню в 11:00.",
|
||||
"ты не пил воду с утра.",
|
||||
"я рада, что получилось.",
|
||||
"",
|
||||
}
|
||||
for _, body := range good {
|
||||
if check, ok := guardSpoken("test", body); !ok {
|
||||
t.Errorf("guardSpoken(%q) rejected on %s", body, check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardStopsWhatSheShouldNot(t *testing.T) {
|
||||
bad := []struct {
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"<think>он просил воду</think> попей воды.", checkLeak},
|
||||
{"Thinking Process: он давно не пил.", checkLeak},
|
||||
{`{"response": "попей воды", "mood": "neutral"}`, checkLeak},
|
||||
{"я напомнил тебе про воду.", "feminine"},
|
||||
{"вы давно не пили воду.", "address"},
|
||||
}
|
||||
for _, c := range bad {
|
||||
check, ok := guardSpoken("test", c.body)
|
||||
if ok {
|
||||
t.Errorf("guardSpoken(%q) let it through", c.body)
|
||||
continue
|
||||
}
|
||||
if check != c.want {
|
||||
t.Errorf("guardSpoken(%q) failed on %s; want %s", c.body, check, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuardCountsWhatItCaught(t *testing.T) {
|
||||
before := personaRejectCounts()[checkLeak]
|
||||
if _, ok := guardSpoken("test", "<think>…"); ok {
|
||||
t.Fatal("a leaked reasoning block was let through")
|
||||
}
|
||||
if after := personaRejectCounts()[checkLeak]; after != before+1 {
|
||||
t.Errorf("leak count %d; want %d", after, before+1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardNudgeFallsBackToTheFloor — a broken nudge is replaced by the
|
||||
// deterministic wording, not dropped and not retried.
|
||||
func TestGuardNudgeFallsBackToTheFloor(t *testing.T) {
|
||||
cand := loop.Candidate{Rule: loop.Rule{Name: "water"}}
|
||||
bad := delivery.PhrasedNudge{Candidate: cand, Body: "Thinking Process: он не пил.", Mood: "neutral"}
|
||||
got := guardNudge(bad, cand)
|
||||
if got.Body == bad.Body {
|
||||
t.Fatal("the broken nudge was delivered unchanged")
|
||||
}
|
||||
if strings.TrimSpace(got.Body) == "" {
|
||||
t.Fatal("the nudge was dropped rather than re-worded")
|
||||
}
|
||||
good := delivery.PhrasedNudge{Candidate: cand, Body: "попей воды.", Mood: "neutral"}
|
||||
if guardNudge(good, cand).Body != good.Body {
|
||||
t.Error("a good nudge was replaced")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -176,6 +176,9 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
t.queueNudge(ctx, cand, state, now)
|
||||
} else {
|
||||
pn, err := t.phraser.PhraseNudge(ctx, *cand)
|
||||
if err == nil {
|
||||
pn = guardNudge(pn, *cand)
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase nudge %s: %v", cand.Rule.Name, err)
|
||||
} else {
|
||||
|
||||
@@ -379,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,
|
||||
|
||||
@@ -54,11 +54,7 @@ func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance stri
|
||||
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
||||
return ""
|
||||
case err != nil:
|
||||
// The resident phraser answers this call with its fallback text and the
|
||||
// error together. Drop the text: these callers hold the passage itself
|
||||
// and read it back better than "вот что я нашла: <passage>" does.
|
||||
log.Printf("voice: %s: phrase: %v", name, err)
|
||||
return ""
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
+17
-3
@@ -24,7 +24,12 @@ type runner struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ready bool
|
||||
http *http.Client
|
||||
// yielding — stop() has sent the signal and the exit that follows is ours.
|
||||
// llama-server aborts on SIGTERM (its static teardown throws, upstream
|
||||
// ggml-org/llama.cpp), so a routine yield and a real crash produce the same
|
||||
// "signal: aborted" and used to log identically (Vikunja #491).
|
||||
yielding bool
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newRunner(bin string, args []string, readyURL string) *runner {
|
||||
@@ -70,13 +75,18 @@ func (r *runner) start() error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
r.cmd, r.ready = cmd, false
|
||||
r.cmd, r.ready, r.yielding = cmd, false, false
|
||||
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
r.mu.Lock()
|
||||
r.cmd, r.ready = nil, false
|
||||
yielded := r.yielding
|
||||
r.cmd, r.ready, r.yielding = nil, false, false
|
||||
r.mu.Unlock()
|
||||
if yielded {
|
||||
log.Printf("mavgpud: llama-server stopped, card yielded (%v)", err)
|
||||
return
|
||||
}
|
||||
log.Printf("mavgpud: llama-server exited: %v", err)
|
||||
}()
|
||||
return nil
|
||||
@@ -90,6 +100,10 @@ func (r *runner) stop(grace time.Duration) {
|
||||
r.mu.Lock()
|
||||
cmd := r.cmd
|
||||
r.ready = false
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
// The exit that follows is ours, not a crash.
|
||||
r.yielding = true
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeServer writes an executable standing in for llama-server: it ignores
|
||||
// SIGTERM the way the real one effectively does — by dying messily rather than
|
||||
// cleanly — and reports a non-zero status.
|
||||
func fakeServer(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "fake-llama-server")
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// A deliberate stop is a yield, and the log has to say so.
|
||||
//
|
||||
// llama-server aborts inside its own static teardown on SIGTERM, so the exit
|
||||
// status of a routine yield is identical to that of a real crash. Reading the
|
||||
// mavgpud log, the two were indistinguishable (Vikunja #491).
|
||||
func TestStopMarksTheExitAsAYield(t *testing.T) {
|
||||
r := newRunner(fakeServer(t, "while : ; do sleep 1 ; done"), nil, "")
|
||||
if err := r.start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
r.mu.Lock()
|
||||
if r.yielding {
|
||||
t.Error("a freshly started server is already marked as yielding")
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
r.stop(2 * time.Second)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if !r.running() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("the child outlived stop")
|
||||
}
|
||||
|
||||
// Stopping when nothing is running must not arm the flag for the next child.
|
||||
// The next exit after that would be a real crash logged as a yield.
|
||||
func TestStopWithNoChildDoesNotArmTheFlag(t *testing.T) {
|
||||
r := newRunner("/nonexistent", nil, "")
|
||||
r.stop(10 * time.Millisecond)
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.yielding {
|
||||
t.Error("stop armed the yield flag with no child running")
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ RestartSec=5
|
||||
# llama-server on SIGTERM, so give it longer than stop_grace to do that.
|
||||
KillSignal=SIGTERM
|
||||
TimeoutStopSec=60
|
||||
# llama-server aborts inside its own static teardown on SIGTERM, so every
|
||||
# routine yield used to write a multi-gigabyte core into systemd-coredump
|
||||
# (Vikunja #491). Yielding is meant to happen several times a day.
|
||||
LimitCORE=0
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ""}
|
||||
|
||||
@@ -78,12 +78,6 @@ type TalkCase struct {
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// TalkSchemaVersion — the version this loader understands. Separate from the
|
||||
// nudge fixture's SchemaVersion: the two fixtures have different shapes and
|
||||
// change on different days, and one shared constant would force a bump on the
|
||||
// fixture that did not move.
|
||||
const TalkSchemaVersion = 1
|
||||
|
||||
// TalkFixture — the versioned envelope, same gating as Fixture.
|
||||
type TalkFixture struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
@@ -98,8 +92,8 @@ func LoadTalk() (TalkFixture, error) {
|
||||
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
||||
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
||||
}
|
||||
if f.SchemaVersion != TalkSchemaVersion {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
|
||||
if f.SchemaVersion != SchemaVersion {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
if len(f.Cases) == 0 {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
||||
|
||||
@@ -142,13 +142,19 @@ func TestLLMTalkBaseline(t *testing.T) {
|
||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||
defer p.Close()
|
||||
|
||||
// The model id names the run in the report. Since Vikunja #397 every path
|
||||
// returns its errors, so a server that dies mid-run shows up in the Errors
|
||||
// column instead of scoring as bad phrasing — the before-and-after probe that
|
||||
// used to stand in for that is gone.
|
||||
// Unreachable server is fatal here, not a logged warning, and that differs
|
||||
// from the nudge test on purpose. PhraseNudge returns its errors, so a dead
|
||||
// server there shows up honestly in the Errors column. PhraseChat and
|
||||
// PhraseQuery do NOT: they swallow every failure and return a canned string
|
||||
// ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths
|
||||
// a dead server produces a full report with 0 errors and a terrible score —
|
||||
// a number that looks like bad phrasing and is really no phrasing at all.
|
||||
// Refusing to score without a confirmed model is the only guard available
|
||||
// until the phraser reports its failures (Vikunja #397).
|
||||
model, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatalf("no model at %s: %v", base, err)
|
||||
t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+
|
||||
"and would report a plausible-looking result off a dead server", base, err)
|
||||
}
|
||||
t.Logf("scoring model %s at %s", model, base)
|
||||
|
||||
@@ -163,11 +169,10 @@ func TestLLMTalkBaseline(t *testing.T) {
|
||||
}
|
||||
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
||||
|
||||
// A run where nothing was phrased is not a low score, it is no measurement.
|
||||
if rep.Errors == rep.Total {
|
||||
t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result")
|
||||
}
|
||||
if rep.Errors > 0 {
|
||||
t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total)
|
||||
// And again afterwards: the run takes minutes, and a server that died or got
|
||||
// OOM-killed halfway through would leave the first cases scored and the rest
|
||||
// silently canned. Checking only at the start would not catch that.
|
||||
if _, err := llm.ModelID(ctx, base); err != nil {
|
||||
t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
|
||||
// PhraseQuery keep the turn alive with canned text — ChatFallback, "не знаю.",
|
||||
// "вот что я нашла: …" — and every one of those is also a legitimate reply, so
|
||||
// the text alone cannot say which happened. The error is the only signal, and
|
||||
// before Vikunja #397 it was dropped: the talk scorer reported a full run with
|
||||
// zero errors off a server that answered nothing.
|
||||
func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
p := NewLLMPhraserAt(srv.URL, Config{})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
call func() (string, error)
|
||||
want string
|
||||
}{
|
||||
{"chat", func() (string, error) {
|
||||
return p.PhraseChat(context.Background(), "как дела", nil)
|
||||
}, ChatFallback},
|
||||
{"knowledge", func() (string, error) {
|
||||
return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
|
||||
}, "не знаю."},
|
||||
{"evidence", func() (string, error) {
|
||||
return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
|
||||
}, "вот что я нашла: два литра"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := c.call()
|
||||
if err == nil {
|
||||
t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing")
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("fallback text = %q, want %q — the daemon still has to say something", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An empty answer is a failure too: the server is up and produced no tokens,
|
||||
// which is not an answer and must not score as one.
|
||||
func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
p := NewLLMPhraserAt(srv.URL, Config{})
|
||||
|
||||
got, err := p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
|
||||
if err == nil {
|
||||
t.Fatal("an empty response scored as an answer")
|
||||
}
|
||||
if got != "не знаю." {
|
||||
t.Errorf("fallback text = %q, want \"не знаю.\"", got)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("error = %v; want it to name the empty response", err)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -27,11 +26,6 @@ import (
|
||||
|
||||
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
||||
|
||||
// errEmptyResponse — the server answered and said nothing. Separate from a
|
||||
// transport failure: the model is up and produced no tokens, which is still not
|
||||
// an answer and must not score as one.
|
||||
var errEmptyResponse = errors.New("phraser: empty response from the model")
|
||||
|
||||
type LLMPhraser struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
@@ -434,11 +428,8 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
|
||||
}
|
||||
|
||||
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
|
||||
// compose a natural answer. On any LLM error it returns the fallback text —
|
||||
// "вот что я нашла: <notes>", or "не знаю." with no notes — and the error
|
||||
// together. The daemon uses the text and keeps the turn alive; a caller that is
|
||||
// measuring counts the failure. Until Vikunja #397 the error was dropped, so a
|
||||
// dead server scored as bad phrasing.
|
||||
// compose a natural answer. Falls back to "вот что я нашла: <notes>" on any
|
||||
// LLM error — better to give the raw data than silence.
|
||||
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
|
||||
// Blank sources are no sources. A caller that hands over one empty string —
|
||||
// a page that fetched to nothing, a snippet trimmed away — used to take the
|
||||
@@ -448,15 +439,13 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
if len(notes) == 0 {
|
||||
sys, prompt := p.knowledgePrompt(utterance)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||
if err != nil {
|
||||
return "не знаю.", fmt.Errorf("phrase query (knowledge): %w", err)
|
||||
}
|
||||
if resp == "" {
|
||||
return "не знаю.", errEmptyResponse
|
||||
if err != nil || resp == "" {
|
||||
return "не знаю.", nil
|
||||
}
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
return "не знаю.", fmt.Errorf("phrase query (knowledge): %w", perr)
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
return "не знаю.", nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
@@ -468,12 +457,13 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if err != nil || perr != nil {
|
||||
// Read the notes out rather than ship a broken fragment.
|
||||
cause := err
|
||||
if cause == nil {
|
||||
cause = perr
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
}
|
||||
return "вот что я нашла: " + strings.Join(notes, "; "),
|
||||
fmt.Errorf("phrase query (evidence): %w", cause)
|
||||
if len(notes) == 1 {
|
||||
return "вот что я нашла: " + notes[0], nil
|
||||
}
|
||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
@@ -482,9 +472,8 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
}
|
||||
|
||||
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
|
||||
// message array from dialogue history + the current user utterance. On any LLM
|
||||
// error it returns both ChatFallback and the error, on the same rule as
|
||||
// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
|
||||
// message array from dialogue history + the current user utterance. Falls back
|
||||
// to a simple greeting on any LLM error — better to say something than nothing.
|
||||
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
||||
sys := chatSystemPrompt(p.cfg.ContextBlock)
|
||||
msgs := []chatMsg{
|
||||
@@ -501,11 +490,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
|
||||
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
||||
if err != nil {
|
||||
return ChatFallback, fmt.Errorf("phrase chat: %w", err)
|
||||
log.Printf("phraser: PhraseChat: %v", err)
|
||||
return "поговорили.", nil
|
||||
}
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
return ChatFallback, fmt.Errorf("phrase chat: %w", perr)
|
||||
log.Printf("phraser: PhraseChat: %v", perr)
|
||||
return "поговорили.", nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
|
||||
@@ -66,17 +66,11 @@ type Stub struct{}
|
||||
// NewStub builds the floor phraser. no config — the Stub is stateless.
|
||||
func NewStub() *Stub { return &Stub{} }
|
||||
|
||||
// ChatFallback — what she says on the chat path when the model gave her
|
||||
// nothing to say. It replaced "поговорили.", which reads as a summary of a
|
||||
// conversation that did not happen. Said out loud this one is an admission,
|
||||
// which is what it is.
|
||||
const ChatFallback = "даже не знаю, что сказать."
|
||||
|
||||
// PhraseChat returns a stub reply — the LLMPhraser replaces this with a
|
||||
// prompted response from the model. The history parameter is accepted but
|
||||
// ignored at the stub level (the production impl uses it for multi-turn).
|
||||
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
|
||||
return ChatFallback, nil
|
||||
return "поговорили.", nil
|
||||
}
|
||||
|
||||
// PhraseQuery returns a deterministic summary of the best matching notes.
|
||||
|
||||
@@ -215,12 +215,10 @@ func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) {
|
||||
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
|
||||
t.Errorf("acquire error = %v; want ErrNoBackend", aerr)
|
||||
}
|
||||
// Phrasing degrades to its fallback instead of failing the turn, and since
|
||||
// Vikunja #397 it reports the error next to that fallback so a measuring
|
||||
// caller can tell "no model" from "bad phrasing".
|
||||
// Phrasing degrades to its fallback instead of failing the turn.
|
||||
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||
if !errors.Is(err, ErrNoBackend) {
|
||||
t.Errorf("PhraseChat error = %v; want ErrNoBackend alongside the fallback", err)
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseChat after a total failure returned an error: %v", err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Error("PhraseChat returned empty; the fallback must still say something")
|
||||
|
||||
@@ -75,3 +75,47 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tomorrow form and the bare event noun. Both were measured answering
|
||||
// "пока не умею" on the deployed daemon, 02-08-2026, while the same question
|
||||
// about today worked — the first rule set needed "у меня" or a calendar noun
|
||||
// and these phrasings carry neither (Vikunja #471).
|
||||
func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,9 +182,38 @@ 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user