router: parse list capture, read-back and crossing off (V-453)
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.
This commit is contained in:
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user