Entity reference: ask Nexus about every Latin run instead of picking the longest #163

Closed
claude wants to merge 45 commits from task/524-entity-reference-ask-nexus-about-every-l into task/476-bug-the-router-transliterates-latin-enti
3 changed files with 154 additions and 0 deletions
Showing only changes of commit 0990f32808 - Show all commits
+143
View File
@@ -0,0 +1,143 @@
package main
import (
"context"
"log"
"strings"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// Standing lists on the voice path (Vikunja #453).
//
// Three halves, mirroring what task capture already does: an add that runs at
// the top of actionNote, a read-back query source, and a crossing-off that runs
// on the same note path because "всё купил" is note-shaped.
//
// These read h.dataStore rather than the CoreAPI. A list is local to the core
// and nothing outside it writes one: the web UI has no list page, no reach
// files groceries, and the digestion worker does not read the table. When
// something outside mavend needs to add to a list, the ipc seam is what it
// grows through — the intake rules that CaptureTaskReq documents are about
// shared intake, and there is none here yet.
//
// Nothing here speaks unprompted. A list is answered when asked about.
// captureListFromNote claims the turn when the utterance adds to, clears, or
// crosses one item off a list. ("", false) hands the turn back to the note path.
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
if h.dataStore == nil {
return "", false
}
// Clearing is read before removing on purpose: "всё купил" and "купил
// молоко" start with the same word, and only the second one names an item.
if list, ok := router.ParseListClear(dec.Utterance); ok {
n, err := h.dataStore.ClearList(ctx, list, h.now())
if err != nil {
log.Printf("voice: clear list: %v", err)
return "не получилось обновить список.", true
}
if n == 0 {
return "в списке и так ничего не было.", true
}
return "вычеркнула всё, список пустой.", true
}
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
if reply, ok := h.removeListItem(ctx, cap); ok {
return reply, true
}
// Nothing on the list by that name. "купил новый ноутбук" is a note and
// must stay one, so the turn goes back rather than claiming a removal
// that removed nothing.
return "", false
}
cap, ok := router.ParseListCapture(dec.Utterance)
if !ok {
return "", false
}
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
List: cap.List,
Item: cap.Item,
Source: "tap:voice",
CreatedTs: h.now(),
})
if err != nil {
log.Printf("voice: add list item: %v", err)
return "не получилось добавить в список.", true
}
if !res.Created {
return cap.Item + " уже в списке.", true
}
return "добавила в список: " + cap.Item + ".", true
}
// removeListItem crosses one named item off. It reports false when the list
// holds nothing by that name, which is what keeps the marker words from
// swallowing ordinary notes.
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
items, err := h.dataStore.ListItems(ctx, cap.List, "")
if err != nil {
log.Printf("voice: list items: %v", err)
return "", false
}
want := store.NormalizeTaskText(cap.Item)
for _, li := range items {
if store.NormalizeTaskText(li.Item) != want {
continue
}
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
log.Printf("voice: cross off list item: %v", err)
return "не получилось обновить список.", true
}
return "вычеркнула: " + li.Item + ".", true
}
return "", false
}
// queryList — "что в списке покупок?", "что мне купить?".
//
// A query source, so it sits in querySources and either claims the turn or
// passes it on. It is before the recall sources for the reason every specific
// source is: the notes pass would otherwise answer a list question with
// whatever note is nearest.
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
list, ok := router.ParseListQuery(t.dec.Utterance)
if !ok || h.dataStore == nil {
return "", false
}
items, err := h.dataStore.ListItems(ctx, list, "")
if err != nil {
log.Printf("voice: list items: %v", err)
return "не получилось посмотреть список.", true
}
return formatListRU(list, items), true
}
// formatListRU reads a list aloud. One sentence, comma-separated, because a
// shopping list is heard in a shop and a numbered recital is unusable there.
func formatListRU(list string, items []store.ListItem) string {
name := "списке " + listGenitive(list)
if len(items) == 0 {
return "в " + name + " пусто."
}
names := make([]string, 0, len(items))
for _, li := range items {
names = append(names, li.Item)
}
return "в " + name + ": " + strings.Join(names, ", ") + "."
}
// listGenitive puts a list tag into the case "список <…>" needs. Russian
// declines the noun and she must not say "в списке покупки".
func listGenitive(list string) string {
switch list {
case "покупки":
return "покупок"
case "аптека":
return "аптеки"
case "хозяйство":
return "хозяйства"
}
return list
}
+6
View File
@@ -17,6 +17,12 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
return reply
}
// A standing list is neither work nor recall (Vikunja #453). Checked here
// for the same reason and at the same cost: before the embedding is paid
// for, and it passes the turn straight back when no marker matches.
if reply, ok := h.captureListFromNote(ctx, dec); ok {
return reply
}
// embed the note text with the same model the classifier uses, persist
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
// facts — no predicate reads it (spec's two-memory split).
+5
View File
@@ -85,6 +85,11 @@ var querySources = []querySource{
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
// Next to "tasks" and for the same reason: "что мне купить?" is a question
// about the shopping list, and the recall pass would otherwise answer it
// from an old note about the shop. Its matcher needs an explicit list
// marker, so "надо бы съездить в магазин" is untouched.
{name: "list", answer: (*reactiveHandler).queryList},
{name: "money", answer: (*reactiveHandler).queryMoney},
// Before the recall sources and before general knowledge: "что нового?" is
// a question about the feeds she reads, and general knowledge would answer