From e023638135b3e83086d8697f06463878e02e23a3 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:45:13 +0400 Subject: [PATCH] router: parse list capture, read-back and crossing off (V-453) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same posture as task capture 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 and stays a note. The list tag is matched by stem, because Russian declines it: "список покупок", "в покупки" and "в покупках" are one list. ListGrammars puts both halves at stage 0, so an add and a read-back never depend on the model having a good turn. --- internal/router/list.go | 247 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 internal/router/list.go diff --git a/internal/router/list.go b/internal/router/list.go new file mode 100644 index 0000000..499775a --- /dev/null +++ b/internal/router/list.go @@ -0,0 +1,247 @@ +package router + +import ( + "regexp" + "strings" +) + +// 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. + +// listStems — the lists he can name, by the stem every case form shares. +// Russian declines the tag ("список покупок", "в покупки", "в покупках"), so +// matching a stem is what makes those the same list. +var listStems = []struct{ stem, list string }{ + {"покуп", "покупки"}, + {"продукт", "покупки"}, + {"магазин", "покупки"}, + {"аптек", "аптека"}, + {"хозяйств", "хозяйство"}, + {"shopping", "покупки"}, + {"groceries", "покупки"}, + {"pharmacy", "аптека"}, +} + +// 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. + if head == "список" || head == "списке" || head == "списка" || head == "list" { + fields = fields[1:] + if len(fields) == 0 { + return "покупки", "" + } + head = strings.ToLower(strings.Trim(fields[0], listTrimCut)) + } + for _, s := range listStems { + if strings.HasPrefix(head, s.stem) { + 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 + }, + }, + } +}