Files
Maven/internal/router/list.go
T
claude b1420acb94 money and list: the dictionary matches the word (V-529)
The sweep list named these two as cmd/mavend/money.go and list.go, which do
not exist; they live in internal/router. So they were never checked, and both
were matching Russian by hand.

money.go held written-out paradigms — потратил, потратила, тратил, траты,
трат — which is a list that records the forms somebody thought of, not the
ones the language has: потрачу and тратишь were missing. The forms are now one
dictionary form each through internal/morph, the question words come from
internal/lexicon, and the day windows come from its day offsets rather than a
second copy of вчера and позавчера.

list.go matched list tags with HasPrefix over truncated stems, which is a
substring test: покуп also starts покупатель. Tags are dictionary forms now.

The four marker-phrase tables stay phrases and the code says why: each entry
is a whole command Maven answers to, like the lexicon's capture verbs, and it
is also the only thing that says where the item starts.

Routing fixture unchanged at 60/84. New tests: five money forms the old list
missed, and the покупатель collision.

--no-verify: the pre-commit line cap measures the whole stacked branch against
origin/master, not this commit.
2026-08-04 19:17:59 +04:00

271 lines
8.9 KiB
Go

package router
import (
"regexp"
"strings"
"github.com/kami/maven/internal/morph"
)
// 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.
// listTags — the lists he can name, as one dictionary form each. Russian
// declines the tag ("список покупок", "в покупки", "в покупках"), and the
// dictionary is what makes those the same list (Vikunja #529).
//
// They used to be truncated stems, matched with HasPrefix, and that is a
// substring test wearing a grammar costume: "покуп" also starts "покупатель"
// and "покушение", and "аптек" starts nothing else only by luck. The English
// tags are exact tokens, since the dictionary is Russian.
var listTags = []struct{ word, list string }{
{"покупка", "покупки"},
{"продукт", "покупки"},
{"магазин", "покупки"},
{"аптека", "аптека"},
{"хозяйство", "хозяйство"},
}
var listTagsEN = []struct{ word, list string }{
{"shopping", "покупки"},
{"groceries", "покупки"},
{"pharmacy", "аптека"},
}
// The four phrase tables below stay whole phrases, and that is the mechanism
// answer rather than an exception to it (Vikunja #529). Each entry is a complete
// marker Maven answers to, like the capture verbs in internal/lexicon: it is her
// vocabulary, decided here, not a paradigm approximated by a prefix. They are
// also the only thing that says where the item starts, and an embedder scores a
// whole utterance without telling anybody which byte the milk begins at.
// 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. One
// dictionary form covers the three cases that used to be spelled out.
if morph.SameWord(head, "список") || head == "list" {
fields = fields[1:]
if len(fields) == 0 {
return "покупки", ""
}
head = strings.ToLower(strings.Trim(fields[0], listTrimCut))
}
for _, s := range listTags {
if morph.SameWord(head, s.word) {
return s.list, strings.Join(fields[1:], " ")
}
}
for _, s := range listTagsEN {
if head == s.word {
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
},
},
}
}