diff --git a/cmd/mavend/actions_list.go b/cmd/mavend/actions_list.go new file mode 100644 index 0000000..e7e7d61 --- /dev/null +++ b/cmd/mavend/actions_list.go @@ -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 +} diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go index c7c20a5..b552ddd 100644 --- a/cmd/mavend/actions_note.go +++ b/cmd/mavend/actions_note.go @@ -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). diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c349d2d..b0efef5 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -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