a08403087f
internal/router/list.go was the last file on the sweep, and the answer is a split rather than one mechanism. What the four paths need is different, and the V-529 comment in the file already had half of the argument. Reading a list back needs one bit — is this about the list — so topicList joins the subjects in cmd/mavend/topics.go and queryList calls turnIsAbout. listQueryPrefixes stays as the offline floor. Which list he named is a noun in the dictionary either way, through the new router.ListNamedIn, which scans the whole utterance: the seeds claim a read-back without eating a prefix, so "что мне нужно в аптеке" has nothing for takeListTag to read the front of. The other three keep their phrase tables, and the header says why. Add and remove have to know WHERE the item starts, and a cosine over a whole utterance does not say which byte the milk begins at. Clear deletes the list, so a false claim loses rows he cannot get back — that is not the trade a margin makes. Measured on TestONNXTopics, four held-out cases added: 27/27, no case regressed. Two existing margins moved by under a hundredth because the new seeds became the runner-up, both still far clear of topicMargin.
155 lines
5.5 KiB
Go
155 lines
5.5 KiB
Go
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
|
|
}
|
|
|
|
// listFloor — the keyword test behind topicList, in the shape turnIsAbout takes.
|
|
func listFloor(u string) bool {
|
|
_, ok := router.ParseListQuery(u)
|
|
return ok
|
|
}
|
|
|
|
// 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) {
|
|
if h.dataStore == nil {
|
|
return "", false
|
|
}
|
|
// The seeds decide the subject and listQueryPrefixes is the floor behind
|
|
// them (V-522). Which list he named is a noun lookup either way.
|
|
if !h.turnIsAbout(ctx, t, topicList, listFloor) {
|
|
return "", false
|
|
}
|
|
list := router.ListNamedIn(t.dec.Utterance)
|
|
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
|
|
}
|