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. // // The four paths split on 05-08-2026, and the split is by what the caller needs // rather than by language (V-522). Reading a list back needs one bit — is this // about the list — so the seeds decide it, topicList through turnIsAbout, and // listQueryPrefixes below is the offline floor. The other three keep the tables // as the answer. Add and remove need 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 it stays on exact phrases: a false claim there // loses rows he cannot get back, which is not the trade a margin makes. // 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 }{ {"покупка", "покупки"}, {"продукт", "покупки"}, {"магазин", "покупки"}, {"аптека", "аптека"}, {"хозяйство", "хозяйство"}, } // taskListTags — the list names that belong to task capture, not here. One // dictionary form each, read the same way listTags are (Vikunja #520). // // "добавь в список" is a marker on both sides: task capture has it in // task_phrases.json and listCapturePrefixes has it below. ListGrammars is wired // before TaskCaptureGrammar, so the list claimed every one of them, and // takeListTag does not recognise "дел" as a list name — so "добавь в список дел // хлеб" filed a grocery item called "дел хлеб". The bare marker stays a grocery // item, because an unnamed list already defaults to покупки and the task side // always names its list. A named task list refuses here and falls through. var taskListTags = []string{"дело", "задача", "task", "todo", "todos"} // namesTaskList reports whether the remainder after a list marker names a task // list rather than one of the standing lists. func namesTaskList(rest string) bool { fields := strings.Fields(rest) // The list noun and the prepositions around it are skipped, so the three // callers can ask this before their own trimming: the remove path leaves // "из списка дел хлеб" and the capture path leaves "список дел хлеб". for len(fields) > 0 { head := strings.ToLower(strings.Trim(fields[0], listTrimCut)) if morph.SameWord(head, "список") || head == "list" || head == "of" || head == "the" || head == "из" || head == "со" || head == "в" { fields = fields[1:] continue } break } if len(fields) == 0 { return false } head := strings.ToLower(strings.Trim(fields[0], listTrimCut)) for _, w := range taskListTags { if morph.SameWord(head, w) || head == w { return true } } return false } 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 } // A named task list is task capture's, not the grocery list's (#520). if namesTaskList(rest) { 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 } if namesTaskList(rest) { return "", false } list, _ := takeListTag(rest) return list, true } // ListNamedIn reports which standing list an utterance names, anywhere in it, // defaulting to покупки when it names none. // // takeListTag is not enough for a read-back, because it reads the FRONT of a // remainder a prefix table has already eaten. The seeds claim a read-back // without eating anything (topicList, cmd/mavend/topics.go, V-522), so "что мне // нужно в аптеке" has to be scanned rather than trimmed. A list name is a noun // in the dictionary, so this is a lookup and decides nothing about meaning. func ListNamedIn(text string) string { for _, f := range strings.Fields(strings.ToLower(text)) { head := strings.Trim(f, listTrimCut) for _, s := range listTags { if morph.SameWord(head, s.word) { return s.list } } for _, s := range listTagsEN { if head == s.word { return s.list } } } return "покупки" } // 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 } if namesTaskList(rest) { 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 }, }, } }