e023638135
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.
248 lines
7.9 KiB
Go
248 lines
7.9 KiB
Go
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
|
|
},
|
|
},
|
|
}
|
|
}
|