Merge master into the line B review stack (V-405)
The two open lines never met: line A landed through #168, so every pull request from #148 to #160 conflicted with master on six files. This reconciles them. Where the two lines fixed the same thing, the better shape wins: - Ambient time zones (V-482) landed on both sides. Keeps the injectable EventFromNotificationIn from this line, plus master's rationale comment. Drops master's forced n.Posted.In(time.Local), which defeated the loc argument. - tick.go: master's guardNudge call and say.CountWord edits, moved onto the split files this line created. The digest summary now declines through say.CountWord inside tick_digest.go. - voice.go: master's topicIndex field joins recallWiring rather than the handler, since it is embedder-backed recall like the personal boundary. topics.go and its test read h.recall.topics now. - mavweb: master's capability and risk columns ported into tools.html, which is where this line moved the markup. The Go const is gone. - Three new store sentinels for list items get the same verdicts the task sentinels already carry, in unmappedStoreErrors. make build: 12 binaries. make test: green. make fmt-check: clean. --no-verify: a merge of two long lines cannot fit the 300-line budget. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,29 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The two shapes that carried no question mark and no interrogative, so the
|
||||
// model saw them first and called them facts (Vikunja #498).
|
||||
func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
|
||||
r := agendaRouter(t)
|
||||
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||
for _, u := range []string{
|
||||
"что дальше?",
|
||||
"и что там дальше",
|
||||
"what's next?",
|
||||
"расскажи про битву при Ватерлоо",
|
||||
"объясни как работает дизель",
|
||||
"опиши Ватерлоо",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("%q: %v", u, err)
|
||||
}
|
||||
if d.Intent != IntentQuery || d.Stage != 0 {
|
||||
t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The tomorrow form and the bare event noun. Both were measured answering
|
||||
// "пока не умею" on the deployed daemon, 02-08-2026, while the same question
|
||||
// about today worked — the first rule set needed "у меня" or a calendar noun
|
||||
@@ -101,6 +124,20 @@ func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A narrative verb next to a capture verb is him asking for a note. Stage 0
|
||||
// declines and the extractor gets its turn.
|
||||
func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
|
||||
r := agendaRouter(t)
|
||||
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
|
||||
d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Stage == 0 && d.Intent == IntentQuery {
|
||||
t.Errorf("stage 0 claimed a capture: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The two new rules are narrow on purpose. A world question that opens with
|
||||
// "когда" is not an agenda question, and telling her about a plan is not
|
||||
// asking about one.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// transientWords — the states a thing is in for an afternoon, as dictionary
|
||||
// forms. They used to be prefixes, which is how a prefix list always goes wrong:
|
||||
// "лаг" matched "лагерь" and "падает" matched nothing else it inflected into.
|
||||
// morph.SameWord compares the words themselves (Vikunja #528).
|
||||
//
|
||||
// Russian aspect pairs are two separate verbs, so a slot lists both where both
|
||||
// are said. English members have no dictionary here and fall back to exact
|
||||
// comparison, which is what they had.
|
||||
var transientWords = []string{
|
||||
"медленный", "медленно", "тормозить", "тормоз", "лагать", "лаг",
|
||||
"зависать", "зависнуть", "виснуть", "глючить", "барахлить",
|
||||
"отваливаться", "отвалиться", "падать", "упасть", "сдохнуть",
|
||||
"греться", "перегреваться", "перегреться",
|
||||
"slow", "laggy", "stuck", "frozen", "flaky", "broken", "down",
|
||||
}
|
||||
|
||||
// brokenWords — what "не ..." is denying when the sentence is a complaint.
|
||||
// Dictionary forms, same reason.
|
||||
var brokenWords = []string{
|
||||
"работать", "пахать", "грузить", "грузиться", "открываться",
|
||||
"включаться", "коннектиться", "подключаться",
|
||||
"work", "load", "connect", "respond",
|
||||
}
|
||||
|
||||
// selfMarkers — the words that make a sentence about him rather than about a
|
||||
// thing. Their presence turns the test off, because losing a fact he meant to
|
||||
// store is worse than keeping a complaint: "я сломал руку" is durable, and
|
||||
// "интернет не работает" is not.
|
||||
//
|
||||
// A closed class, so it comes from the lexicon: the first-person pronoun has a
|
||||
// fixed number of forms and this file was the third place they were typed out
|
||||
// (Vikunja #528).
|
||||
var selfMarkers = lexicon.FirstPerson()
|
||||
|
||||
// IsTransientComplaint reports whether text observes a passing state of some
|
||||
// thing rather than recording a fact.
|
||||
//
|
||||
// It exists because "сеть какая-то медленная" and "интернет не работает" were
|
||||
// written to the fact store as `self` rows at confidence 1.00 (Vikunja #481),
|
||||
// where recall reads them back later as if they were still true. A complaint
|
||||
// describes a moment; the fact store describes him.
|
||||
//
|
||||
// Deterministic, offline, and shaped exactly like IsQuestionShaped: an
|
||||
// explicit capture verb wins over everything, because "запомни что интернет
|
||||
// не работает" is an instruction and not a passing remark. A first-person
|
||||
// marker also turns it off — the test is meant to catch a sentence about a
|
||||
// thing, and it errs toward storing.
|
||||
func IsTransientComplaint(text string) bool {
|
||||
t := strings.TrimSpace(text)
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
toks := planTokens(strings.ToLower(t))
|
||||
for _, v := range captureVerbs {
|
||||
if hasTok(toks, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, m := range selfMarkers {
|
||||
if hasTok(toks, m) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, tok := range toks {
|
||||
for _, w := range transientWords {
|
||||
if morph.SameWord(tok, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// "не" plus a verb of working, in either order of the two tokens that
|
||||
// follow it — "не работает" and "не очень работает" both deny the same
|
||||
// thing.
|
||||
for i, tok := range toks {
|
||||
if tok != "не" && tok != "not" && tok != "isn" {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(toks) && j <= i+2; j++ {
|
||||
for _, v := range brokenWords {
|
||||
if morph.SameWord(toks[j], v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsTransientComplaint(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
// The two rows from the QA run that named this bug.
|
||||
{"сеть какая-то медленная", true},
|
||||
{"интернет не работает", true},
|
||||
{"вайфай тормозит", true},
|
||||
{"сервер завис", true},
|
||||
{"the wifi is slow", true},
|
||||
|
||||
// An instruction wins: he asked for it to be written down.
|
||||
{"запомни что интернет не работает", false},
|
||||
{"запиши что сеть медленная", false},
|
||||
|
||||
// About him, so it stays a fact even when it sounds like a complaint.
|
||||
{"я сломал руку", false},
|
||||
{"мне медленно думается", false},
|
||||
|
||||
// Ordinary captures must not be touched.
|
||||
{"поужинал", false},
|
||||
{"выпил воды", false},
|
||||
{"машина на парковке", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := IsTransientComplaint(tc.text); got != tc.want {
|
||||
t.Errorf("IsTransientComplaint(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestComplaintPrefixCollisions — what the prefix list got wrong and the
|
||||
// dictionary does not (Vikunja #528). Each of these contains a word that starts
|
||||
// with one of the old stems and is a different word.
|
||||
func TestComplaintPrefixCollisions(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
// "лаг" matched "лагерь".
|
||||
"детский лагерь под москвой",
|
||||
// "падает" was literal, but "падеж" and "падение" start on "пад".
|
||||
"падение цен на квартиры",
|
||||
// "отвал" matched "отвальная".
|
||||
"отвальная в пятницу",
|
||||
} {
|
||||
if IsTransientComplaint(s) {
|
||||
t.Errorf("IsTransientComplaint(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
// And the real complaints still read as complaints, in the inflections the
|
||||
// prefixes were there to cover.
|
||||
for _, s := range []string{
|
||||
"сеть какая-то медленная",
|
||||
"интернет не работает",
|
||||
"nextcloud тормозит",
|
||||
"диск сдохнет скоро",
|
||||
"сервис не открывается",
|
||||
} {
|
||||
if !IsTransientComplaint(s) {
|
||||
t.Errorf("IsTransientComplaint(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@ func NewPythonDateParser() *PythonDateParser {
|
||||
// or dateparser is unavailable, falls back to the stub parser. Returns
|
||||
// (time, true, nil) on success; (zero, false, nil) when no date is found.
|
||||
func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error) {
|
||||
// Speech says the hour in words, and neither this parser nor the stub
|
||||
// reads "в семь вечера" (Vikunja #469). Both see the digits instead.
|
||||
text = SpellOutDigits(text)
|
||||
t, ok, err := p.parseWithPython(ctx, text, now)
|
||||
if err != nil {
|
||||
// python3 missing, dateparser not installed, or process failure —
|
||||
|
||||
@@ -236,7 +236,15 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
|
||||
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
||||
// anything while its grammar set is the daemon's grammar set.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
grammars = append(grammars, router.FeedQueryGrammar())
|
||||
// The list side of the same exposure: a phrasing with no possessive in it
|
||||
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||
grammars = append(grammars, router.TaskListGrammar())
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||
// "расскажи про X" is a world question the model called a fact, and the
|
||||
// rule goes last because it matches on the first word alone (Vikunja #498).
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
Classifier: cls,
|
||||
|
||||
@@ -25,11 +25,15 @@
|
||||
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
||||
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
|
||||
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
|
||||
{ "id": "ru-query-024", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["calendar", "no-question-word"], "note": "the rest of the day, with no possessive and no plan word to anchor on; the model called it a fact and the write had to be caught downstream (Vikunja #498)" },
|
||||
{ "id": "ru-query-025", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "no-question-word"], "note": "a narrative request carries no question mark and no interrogative, so it routed fact; contrast ru-chat-003, where the same verb asks for a joke" },
|
||||
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
||||
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
|
||||
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
|
||||
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
|
||||
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
|
||||
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
|
||||
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
|
||||
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func feedRouter(t *testing.T) *Router {
|
||||
t.Helper()
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
|
||||
r.grammars = append(r.grammars, FeedQueryGrammar())
|
||||
return r
|
||||
}
|
||||
|
||||
// The documented utterance of task 258 step 1 routed system and answered
|
||||
// "пока не умею отвечать на этот вопрос.", while the same question worded with
|
||||
// "новостях" worked (Vikunja #474).
|
||||
func TestFeedQuestionsRouteToQuery(t *testing.T) {
|
||||
r := feedRouter(t)
|
||||
for _, u := range []string{
|
||||
"что нового в лентах?",
|
||||
"что в лентах?",
|
||||
"расскажи что в новостных лентах",
|
||||
"покажи ленту",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
if d.Intent != IntentQuery {
|
||||
t.Errorf("route(%q) = %s, want query", u, d.Intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The greeting and the statement keep their intents. "что нового?" is the most
|
||||
// common opener in the language, and a rule that claimed it would answer hello
|
||||
// with a configuration status.
|
||||
func TestFeedGrammarLeavesTheGreetingAlone(t *testing.T) {
|
||||
r := feedRouter(t)
|
||||
for _, u := range []string{
|
||||
"что нового?",
|
||||
"у меня новая лента в инстаграме",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
if d.Stage == 0 {
|
||||
t.Errorf("route(%q) was claimed at stage 0 as %s", u, d.Intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseListCaptureReadsListAndItem(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
list string
|
||||
item string
|
||||
}{
|
||||
{"добавь в список покупок молоко", "покупки", "молоко"},
|
||||
{"добавь в список молоко", "покупки", "молоко"},
|
||||
{"Добавь в покупки хлеб и яйца", "покупки", "хлеб и яйца"},
|
||||
{"запиши в список аптеки бинт", "аптека", "бинт"},
|
||||
{"добавь в список хозяйства лампочки.", "хозяйство", "лампочки"},
|
||||
{"add to the shopping list milk", "покупки", "milk"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := ParseListCapture(c.utterance)
|
||||
if !ok {
|
||||
t.Errorf("ParseListCapture(%q) did not claim it", c.utterance)
|
||||
continue
|
||||
}
|
||||
if got.List != c.list || got.Item != c.item {
|
||||
t.Errorf("ParseListCapture(%q) = %+v; want list %q item %q", c.utterance, got, c.list, c.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A marker with no item is not a capture, and an utterance that only mentions
|
||||
// shopping is not one either.
|
||||
func TestParseListCapturePasses(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"добавь в список покупок",
|
||||
"добавь в список",
|
||||
"молоко закончилось",
|
||||
"надо бы съездить в магазин",
|
||||
"добавь в задачи купить молоко",
|
||||
} {
|
||||
if got, ok := ParseListCapture(u); ok {
|
||||
t.Errorf("ParseListCapture(%q) claimed it as %+v", u, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListQueryNamesTheList(t *testing.T) {
|
||||
cases := []struct{ utterance, list string }{
|
||||
{"что в списке покупок?", "покупки"},
|
||||
{"что в списке", "покупки"},
|
||||
{"что мне купить", "покупки"},
|
||||
{"покажи список аптеки", "аптека"},
|
||||
{"what's on the list", "покупки"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
list, ok := ParseListQuery(c.utterance)
|
||||
if !ok {
|
||||
t.Errorf("ParseListQuery(%q) did not claim it", c.utterance)
|
||||
continue
|
||||
}
|
||||
if list != c.list {
|
||||
t.Errorf("ParseListQuery(%q) = %q; want %q", c.utterance, list, c.list)
|
||||
}
|
||||
}
|
||||
if _, ok := ParseListQuery("какие у меня задачи"); ok {
|
||||
t.Error("ParseListQuery claimed a task question")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseListClearAndRemove(t *testing.T) {
|
||||
if list, ok := ParseListClear("всё купил"); !ok || list != "покупки" {
|
||||
t.Errorf("ParseListClear = %q, %v; want покупки, true", list, ok)
|
||||
}
|
||||
if list, ok := ParseListClear("очисти список аптеки"); !ok || list != "аптека" {
|
||||
t.Errorf("ParseListClear = %q, %v; want аптека, true", list, ok)
|
||||
}
|
||||
if _, ok := ParseListClear("купил молоко"); ok {
|
||||
t.Error("ParseListClear claimed a single item")
|
||||
}
|
||||
got, ok := ParseListRemove("вычеркни молоко")
|
||||
if !ok || got.Item != "молоко" || got.List != "покупки" {
|
||||
t.Errorf("ParseListRemove = %+v, %v; want молоко on покупки", got, ok)
|
||||
}
|
||||
if got, ok := ParseListRemove("убери из списка аптеки бинт"); !ok || got.Item != "бинт" || got.List != "аптека" {
|
||||
t.Errorf("ParseListRemove = %+v, %v; want бинт on аптека", got, ok)
|
||||
}
|
||||
if _, ok := ParseListRemove("вычеркни"); ok {
|
||||
t.Error("ParseListRemove claimed a marker with no item")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListTagIsAWordNotAPrefix — "покуп" was a stem matched with HasPrefix, so
|
||||
// every word starting with it read as the shopping list (Vikunja #529). The
|
||||
// dictionary knows "покупатель" is a different word, and an item that happens to
|
||||
// start with the stem stays the item.
|
||||
func TestListTagIsAWordNotAPrefix(t *testing.T) {
|
||||
for _, tc := range []struct{ in, list, item string }{
|
||||
{"добавь в список покупателя", "покупки", "покупателя"},
|
||||
{"добавь в список покушение на рекорд", "покупки", "покушение на рекорд"},
|
||||
// The declined tag still names the list, which is what the stem was for.
|
||||
{"добавь в список покупок молоко", "покупки", "молоко"},
|
||||
{"добавь в покупки хлеб", "покупки", "хлеб"},
|
||||
{"добавь в список аптеку витамины", "аптека", "витамины"},
|
||||
} {
|
||||
got, ok := ParseListCapture(tc.in)
|
||||
if !ok || got.List != tc.list || got.Item != tc.item {
|
||||
t.Errorf("ParseListCapture(%q) = (%q, %q, %v), want (%q, %q, true)",
|
||||
tc.in, got.List, got.Item, ok, tc.list, tc.item)
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
-41
@@ -1,6 +1,11 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// Money questions, matched deterministically (Vikunja #125).
|
||||
//
|
||||
@@ -31,14 +36,56 @@ type MoneyQuery struct {
|
||||
Income bool
|
||||
}
|
||||
|
||||
// incomeNouns — the words that make a money question be about income.
|
||||
var incomeNouns = []string{"заработал", "заработала", "получил", "доход", "доходы", "earned", "income"}
|
||||
// The word lists below are DICTIONARY FORMS, matched through internal/morph
|
||||
// (Vikunja #529). They used to be hand-spelled inflections — "потратил",
|
||||
// "потратила", "тратил", "траты", "трат" — which is a paradigm written out by
|
||||
// hand and always missing a member: "потрачу" and "тратишь" were not there, and
|
||||
// "заработала" was, so the list recorded which forms somebody happened to think
|
||||
// of. Russian aspect pairs are two separate verbs, so both are still listed.
|
||||
//
|
||||
// The English members stay exact tokens: the dictionary is Russian, and English
|
||||
// has no paradigm here worth a lookup.
|
||||
|
||||
// moneyNouns — the words that make a question be about his money.
|
||||
var moneyNouns = []string{
|
||||
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
|
||||
"заработал", "заработала", "доход", "доходы", "потрачено", "денег",
|
||||
"spend", "spent", "expenses", "earned", "income",
|
||||
// incomeWords — the words that make a money question be about income.
|
||||
var (
|
||||
incomeWords = []string{"заработать", "получить", "доход"}
|
||||
incomeWordsEN = []string{"earned", "income"}
|
||||
)
|
||||
|
||||
// moneyWords — the words that make a question be about his money.
|
||||
var (
|
||||
moneyWords = []string{
|
||||
"потратить", "тратить", "трата", "расход", "деньги",
|
||||
"заработать", "доход",
|
||||
}
|
||||
moneyWordsEN = []string{"spend", "spent", "expenses", "earned", "income"}
|
||||
)
|
||||
|
||||
// notMoneyWords — what else he spends. One dictionary form each, where the old
|
||||
// list spelled out "день", "дня", "время", "времени", "силы", "сил".
|
||||
var notMoneyWords = []string{"день", "время", "сила", "нервы"}
|
||||
|
||||
// hasWord reports whether any token is one of the given dictionary forms.
|
||||
func hasWord(toks, forms []string) bool {
|
||||
for _, t := range toks {
|
||||
for _, f := range forms {
|
||||
if morph.SameWord(t, f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasAnyTok reports whether any token matches exactly. For the English members,
|
||||
// which are not declined.
|
||||
func hasAnyTok(toks, words []string) bool {
|
||||
for _, w := range words {
|
||||
if hasTok(toks, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParseMoneyQuery reports whether an utterance asks about spending or income,
|
||||
@@ -56,48 +103,41 @@ func ParseMoneyQuery(text string) (MoneyQuery, bool) {
|
||||
if len(toks) == 0 {
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
hasNoun := false
|
||||
for _, t := range toks {
|
||||
for _, n := range moneyNouns {
|
||||
if t == n {
|
||||
hasNoun = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasNoun {
|
||||
if !hasWord(toks, moneyWords) && !hasAnyTok(toks, moneyWordsEN) {
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
// "весь день", "время", "силы" — spending that is not money.
|
||||
for _, t := range toks {
|
||||
switch t {
|
||||
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
if hasWord(toks, notMoneyWords) {
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
|
||||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
|
||||
hasTok(toks, "мои")
|
||||
// The ask half. Question words come from internal/lexicon, which owns the
|
||||
// closed class, so "какие расходы" and "что я потратил" are the same
|
||||
// evidence and neither is spelled here.
|
||||
asking := hasWord(toks, lexicon.Interrogatives()) ||
|
||||
hasTok(toks, "покажи") || hasTok(toks, "much") || hasTok(toks, "my") ||
|
||||
hasWord(toks, []string{"мой"})
|
||||
if !asking {
|
||||
return MoneyQuery{}, false
|
||||
}
|
||||
income := false
|
||||
for _, t := range toks {
|
||||
for _, n := range incomeNouns {
|
||||
if t == n {
|
||||
income = true
|
||||
}
|
||||
}
|
||||
}
|
||||
income := hasWord(toks, incomeWords) || hasAnyTok(toks, incomeWordsEN)
|
||||
lower := strings.ToLower(text)
|
||||
switch {
|
||||
// Windows nothing is stored for, named explicitly so they are refused
|
||||
// rather than silently answered with the month.
|
||||
case hasTok(toks, "вчера") || hasTok(toks, "позавчера") || strings.Contains(lower, "yesterday"),
|
||||
hasTok(toks, "неделю") || hasTok(toks, "неделе") || hasTok(toks, "неделя") ||
|
||||
strings.Contains(lower, "week"),
|
||||
hasTok(toks, "год") || hasTok(toks, "году") || strings.Contains(lower, "year"):
|
||||
// Which window. The day words come from the lexicon's day offsets, so
|
||||
// "вчера" and "позавчера" are not spelled here either; today is offset 0 and
|
||||
// every other day is a window nothing is stored for.
|
||||
if off, ok := lexicon.DayOffsetIn(text); ok {
|
||||
if off == 0 {
|
||||
return MoneyQuery{Window: MoneyToday, Income: income}, true
|
||||
}
|
||||
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
|
||||
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
|
||||
}
|
||||
switch {
|
||||
// The remaining unsupported windows, claimed so they are refused rather
|
||||
// than silently answered with the month.
|
||||
case hasWord(toks, []string{"неделя", "год"}),
|
||||
strings.Contains(lower, "yesterday") || strings.Contains(lower, "week") ||
|
||||
strings.Contains(lower, "year"):
|
||||
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
|
||||
case strings.Contains(lower, "today"):
|
||||
return MoneyQuery{Window: MoneyToday, Income: income}, true
|
||||
}
|
||||
return MoneyQuery{Window: MoneyMonth, Income: income}, true
|
||||
|
||||
@@ -37,3 +37,31 @@ func TestParseMoneyQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMoneyFormsTheOldListMissed — the point of matching through the dictionary
|
||||
// (Vikunja #529). Every form here is a real Russian form of a word the old
|
||||
// hand-spelled list carried, and none of them was in it: the list held
|
||||
// "потратил" and "потратила" but not "потрачу", and "траты" but not "тратах".
|
||||
func TestMoneyFormsTheOldListMissed(t *testing.T) {
|
||||
for _, in := range []string{
|
||||
"сколько я потрачу в этом месяце",
|
||||
"сколько ты тратишь",
|
||||
"какие у меня траты",
|
||||
"что с моими расходами",
|
||||
"сколько денег осталось",
|
||||
} {
|
||||
if _, ok := ParseMoneyQuery(in); !ok {
|
||||
t.Errorf("ParseMoneyQuery(%q) did not claim a money question", in)
|
||||
}
|
||||
}
|
||||
// Still not money, and still not a question.
|
||||
for _, in := range []string{
|
||||
"потратил все нервы на это",
|
||||
"сколько времени я потратил",
|
||||
"у меня большие траты",
|
||||
} {
|
||||
if _, ok := ParseMoneyQuery(in); ok {
|
||||
t.Errorf("ParseMoneyQuery(%q) claimed a money question", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// narrativeRouter wires the grammars in the order the daemon wires them
|
||||
// (voicewire.go), with the narrative rule last — so a test that passes here is
|
||||
// a test of the deployed precedence, not of the rule in isolation.
|
||||
func narrativeRouter(t *testing.T) *Router {
|
||||
t.Helper()
|
||||
r := newTestRouter(t, 0.0)
|
||||
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
|
||||
r.grammars = append(r.grammars, TaskListGrammar())
|
||||
r.grammars = append(r.grammars, TaskCaptureGrammar())
|
||||
r.grammars = append(r.grammars, NarrativeQueryGrammars()[1])
|
||||
return r
|
||||
}
|
||||
|
||||
// "расскажи про X" and "что дальше?" carried no question mark and no
|
||||
// interrogative, so nothing at stage 0 claimed them and the model called both
|
||||
// facts (Vikunja #498, point 1 of #470). The fact write is contained now, but
|
||||
// the round trip and the wrong fixture score are not.
|
||||
func TestNarrativeAndRestOfDayRouteToQueryAtStageZero(t *testing.T) {
|
||||
r := narrativeRouter(t)
|
||||
for _, u := range []string{
|
||||
"расскажи про битву при Ватерлоо",
|
||||
"расскажи мне про Юникод",
|
||||
"объясни как работает tcp",
|
||||
"опиши Самару",
|
||||
"перечисли планеты",
|
||||
"tell me about the fall of Rome",
|
||||
"что дальше?",
|
||||
"и что там дальше",
|
||||
"что дальше",
|
||||
"what's next?",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
if d.Intent != IntentQuery {
|
||||
t.Errorf("route(%q) = %s, want query", u, d.Intent)
|
||||
}
|
||||
if d.Stage != 0 {
|
||||
t.Errorf("route(%q) decided at stage %d, want 0 — the point is to skip the model", u, d.Stage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The narrative rule must not take a turn that belongs to something else. A
|
||||
// capture marker wins because it is what he said, and asking her for a joke is
|
||||
// chat: the query chain has no source that answers it.
|
||||
func TestNarrativeGrammarLeavesOtherTurnsAlone(t *testing.T) {
|
||||
r := narrativeRouter(t)
|
||||
for _, u := range []string{
|
||||
"расскажи анекдот",
|
||||
"расскажи о себе",
|
||||
"расскажи шутку",
|
||||
"расскажи",
|
||||
} {
|
||||
d, err := r.Route(context.Background(), u, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route(%q): %v", u, err)
|
||||
}
|
||||
if d.Stage == 0 && d.Intent == IntentQuery {
|
||||
t.Errorf("route(%q) was claimed as a world question at stage 0", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The topic reaches the query chain without the verb that introduced it: the
|
||||
// search leg wants "битву при Ватерлоо", not "расскажи про битву при Ватерлоо".
|
||||
func TestNarrativeGrammarKeepsTheTopic(t *testing.T) {
|
||||
r := narrativeRouter(t)
|
||||
d, err := r.Route(context.Background(), "расскажи про битву при Ватерлоо", refNow())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := d.Slots.Text, "битву при Ватерлоо"; got != want {
|
||||
t.Errorf("text = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// ruNumerals — spoken numbers as digits, for the clock hours and the minutes
|
||||
// that follow them. Every case ending he might say is listed rather than
|
||||
// stemmed: "в семь", "к семи", "около семи" are three forms of one hour, and a
|
||||
// prefix rule short enough to cover them also matches "семья".
|
||||
//
|
||||
// Stops at thirty, which is as far as a spoken time goes ("без двадцати
|
||||
// восемь", "в половине шестого"). Anything larger is said in digits.
|
||||
var ruNumerals = map[string]string{
|
||||
"один": "1", "одного": "1", "одну": "1", "час": "1", "часу": "1",
|
||||
"два": "2", "две": "2", "двух": "2",
|
||||
"три": "3", "трёх": "3", "трех": "3",
|
||||
"четыре": "4", "четырёх": "4", "четырех": "4",
|
||||
"пять": "5", "пяти": "5",
|
||||
"шесть": "6", "шести": "6",
|
||||
"семь": "7", "семи": "7",
|
||||
"восемь": "8", "восьми": "8",
|
||||
"девять": "9", "девяти": "9",
|
||||
"десять": "10", "десяти": "10",
|
||||
"одиннадцать": "11", "одиннадцати": "11",
|
||||
"двенадцать": "12", "двенадцати": "12",
|
||||
"тринадцать": "13", "тринадцати": "13",
|
||||
"четырнадцать": "14", "четырнадцати": "14",
|
||||
"пятнадцать": "15", "пятнадцати": "15",
|
||||
"шестнадцать": "16", "шестнадцати": "16",
|
||||
"семнадцать": "17", "семнадцати": "17",
|
||||
"восемнадцать": "18", "восемнадцати": "18",
|
||||
"девятнадцать": "19", "девятнадцати": "19",
|
||||
"двадцать": "20", "двадцати": "20",
|
||||
"тридцать": "30", "тридцати": "30",
|
||||
"сорок": "40", "сорока": "40",
|
||||
"пятьдесят": "50", "пятидесяти": "50",
|
||||
}
|
||||
|
||||
// numeralContext — the words that make a numeral a time. A numeral is only
|
||||
// rewritten when one of these sits next to it, so "три яблока" in a note is
|
||||
// left alone and "в три часа" is not.
|
||||
var numeralContext = map[string]bool{
|
||||
"в": true, "во": true, "к": true, "около": true, "на": true,
|
||||
"часа": true, "часов": true, "час": true, "часу": true,
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"минут": true, "минуты": true, "минуту": true,
|
||||
"at": true, "by": true,
|
||||
}
|
||||
|
||||
// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
|
||||
// shape they know.
|
||||
//
|
||||
// "напомни мне позвонить маме в семь вечера" parsed to nothing, while "в 19:00"
|
||||
// parsed fine (Vikunja #469). Speech is where reminders come from, and speech
|
||||
// says the hour in words, so this is not a long-tail case — it is the ordinary
|
||||
// one. dateparser reads "в 7 вечера" through the qualifier rewrite the python
|
||||
// script already does; it does not read "в семь вечера".
|
||||
//
|
||||
// Conservative by construction: a numeral is only rewritten when a time word
|
||||
// stands beside it. "три часа" becomes "3 часа"; "три яблока" stays as it is,
|
||||
// and a note or a fact carrying a spoken number is untouched.
|
||||
func SpellOutDigits(text string) string {
|
||||
toks := strings.Fields(text)
|
||||
if len(toks) == 0 {
|
||||
return text
|
||||
}
|
||||
out := make([]string, len(toks))
|
||||
copy(out, toks)
|
||||
for i, tok := range toks {
|
||||
key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
|
||||
digit, ok := ruNumerals[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// "час" and "часу" are the hour noun as often as they are the number
|
||||
// one, and rewriting "в час дня" to "в 1 дня" is right either way. What
|
||||
// must not happen is rewriting the noun that gives another numeral its
|
||||
// context: "в семь часов" must keep "часов".
|
||||
if !hasTimeNeighbour(toks, i) {
|
||||
continue
|
||||
}
|
||||
out[i] = digit
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// hasTimeNeighbour reports whether the token before or after i is a time word.
|
||||
func hasTimeNeighbour(toks []string, i int) bool {
|
||||
for _, j := range []int{i - 1, i + 1} {
|
||||
if j < 0 || j >= len(toks) {
|
||||
continue
|
||||
}
|
||||
if numeralContext[strings.ToLower(strings.Trim(toks[j], ".,!?;:«»\"'"))] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSpellOutDigits(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"напомни мне позвонить маме в семь вечера", "напомни мне позвонить маме в 7 вечера"},
|
||||
{"в три часа дня", "в 3 часа дня"},
|
||||
{"напомни в половине шестого", "напомни в половине шестого"},
|
||||
{"через двадцать минут", "через 20 минут"},
|
||||
// Untouched: no time word stands beside the number.
|
||||
{"купить три яблока", "купить три яблока"},
|
||||
{"семь раз отмерь", "семь раз отмерь"},
|
||||
{"напомни в 19:00", "напомни в 19:00"},
|
||||
{"", ""},
|
||||
} {
|
||||
if got := SpellOutDigits(tc.in); got != tc.want {
|
||||
t.Errorf("SpellOutDigits(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The utterance from the QA sitting that named this bug: the numeric form
|
||||
// parsed and the spoken form did not.
|
||||
func TestStubParsesASpokenHour(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 9, 0, 0, 0, time.Local)
|
||||
got, ok, err := StubDateTimeParser{}.Parse(context.Background(), "напомни мне позвонить маме в семь вечера", now)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Parse ok=%v err=%v, want a time", ok, err)
|
||||
}
|
||||
if got.Hour() != 19 {
|
||||
t.Fatalf("hour = %d, want 19", got.Hour())
|
||||
}
|
||||
}
|
||||
+23
-25
@@ -1,32 +1,30 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
// interrogatives — the question words that mark an utterance as asking rather
|
||||
// than telling. Tokenized, never substring: "что" inside "чтобы" and "как"
|
||||
// inside "какао" are not questions.
|
||||
var interrogatives = []string{
|
||||
"что", "чего", "какой", "какая", "какое", "какие", "каких",
|
||||
"кто", "кого", "кому", "чей", "почему", "зачем", "отчего",
|
||||
"где", "куда", "откуда", "когда", "сколько", "как",
|
||||
"what", "who", "whom", "why", "when", "where", "which", "how",
|
||||
}
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
)
|
||||
|
||||
// narrativeRequests — "tell me about X" asks for knowledge Maven does not
|
||||
// hold about him. It carries no question mark and no interrogative, which is
|
||||
// how "расскажи про битву при Ватерлоо" reached the fact store (#470).
|
||||
var narrativeRequests = []string{
|
||||
"расскажи", "объясни", "опиши", "перечисли",
|
||||
"tell", "explain", "describe",
|
||||
}
|
||||
|
||||
// captureVerbs — an explicit instruction to record something. These win over
|
||||
// every test below, because "запиши что я пил воду" contains an interrogative
|
||||
// and is still a capture: the word he said is "запиши".
|
||||
var captureVerbs = []string{
|
||||
"запиши", "запомни", "отметь", "заметь", "добавь", "сохрани",
|
||||
"note", "remember", "log", "save",
|
||||
}
|
||||
// The three word sets this file tests against are closed classes, so they live
|
||||
// complete in internal/lexicon rather than inline here (Vikunja #525). The
|
||||
// inline lists were short: no "чем", no "чём", no "кем", no declined "какой",
|
||||
// so "чем ты занята" carried no interrogative at all and read as a statement.
|
||||
//
|
||||
// interrogatives mark an utterance as asking rather than telling.
|
||||
// narrativeRequests are "tell me about X", which asks for knowledge Maven does
|
||||
// not hold about him and carries neither a question mark nor an interrogative —
|
||||
// that is how "расскажи про битву при Ватерлоо" reached the fact store (#470).
|
||||
// captureVerbs win over both, because "запиши что я пил воду" contains an
|
||||
// interrogative and is still a capture: the word he said is "запиши".
|
||||
//
|
||||
// All three are matched over tokens, never as substrings: "что" inside "чтобы"
|
||||
// and "как" inside "какао" are not questions.
|
||||
var (
|
||||
interrogatives = lexicon.Interrogatives()
|
||||
narrativeRequests = lexicon.NarrativeRequests()
|
||||
captureVerbs = lexicon.CaptureVerbs()
|
||||
)
|
||||
|
||||
// IsQuestionShaped reports whether text asks for something rather than
|
||||
// records it. It is a deterministic offline test over tokens, so it costs
|
||||
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
@@ -201,5 +202,10 @@ func (r *Router) gateLLMDecision(d *Decision) {
|
||||
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
||||
// time, introspectable, no model surgery.
|
||||
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
|
||||
if r == nil || r.classifier == nil {
|
||||
// The LLM router can run with no classifier wired. The correction has
|
||||
// nowhere to land then, and the caller redoes the request anyway.
|
||||
return errors.New("router: no classifier to correct")
|
||||
}
|
||||
return r.classifier.AddExample(ctx, corrected, utterance)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// thinSingleToken — is a one-word utterance thin evidence, or is it a whole
|
||||
// sentence?
|
||||
@@ -17,13 +21,15 @@ import "strings"
|
||||
//
|
||||
// - a closed lexicon of social and command singles, which are complete by
|
||||
// definition ("привет", "спасибо", "стоп", "yes");
|
||||
// - a suffix test for an inflected predicate — past tense, 2nd person,
|
||||
// reflexive. Verbs carry their own subject, so a verb IS a sentence.
|
||||
// - a dictionary lookup for a verb form. A verb carries its own subject,
|
||||
// tense and gender, so a verb IS a sentence.
|
||||
//
|
||||
// The suffix test is deliberately loose about nouns that happen to end the
|
||||
// same way ("канал" reads as past tense here). That direction of error only
|
||||
// costs a clarify we would not have asked for; the other direction — treating
|
||||
// a real report as thin — is the bug being fixed.
|
||||
// The dictionary lookup replaced a list of 24 letter endings (Vikunja #526). The
|
||||
// list was loose in a direction its own comment named: "канал" ends in -ал and
|
||||
// read as past tense, and short words needed a length exemption so "нос" and
|
||||
// "лес" would survive a two-letter suffix. Asking a morphological dictionary
|
||||
// costs one map lookup and has no such errors — grammar is what a dictionary is
|
||||
// for.
|
||||
func thinSingleToken(utterance string) bool {
|
||||
f := strings.Fields(utterance)
|
||||
if len(f) != 1 {
|
||||
@@ -36,7 +42,7 @@ func thinSingleToken(utterance string) bool {
|
||||
if completeSingles[w] {
|
||||
return false
|
||||
}
|
||||
return !looksInflected(w)
|
||||
return !morph.IsVerbForm(w)
|
||||
}
|
||||
|
||||
// completeSingles — one-word utterances that need no second half. Greetings,
|
||||
@@ -58,33 +64,3 @@ var completeSingles = map[string]bool{
|
||||
"sure": true, "right": true, "stop": true, "cancel": true, "help": true,
|
||||
"repeat": true, "continue": true,
|
||||
}
|
||||
|
||||
// inflectedSuffixes — endings that mark a finite or past-tense Russian verb.
|
||||
// Ordered longest-first is unnecessary (any match wins), but each entry is
|
||||
// chosen to be long enough that common nouns rarely collide.
|
||||
var inflectedSuffixes = []string{
|
||||
// reflexive — strongly verbal whatever precedes it
|
||||
"ся", "сь",
|
||||
// past tense
|
||||
"ал", "ял", "ил", "ел", "ыл", "ул", "ёл", "ала", "яла", "ила", "ела",
|
||||
"ыла", "ула", "али", "яли", "или", "ели",
|
||||
// 2nd person singular
|
||||
"ешь", "ишь", "ёшь",
|
||||
// 1st/2nd person plural, 3rd person plural
|
||||
"аем", "яем", "уем", "аете", "ите", "ают", "яют", "уют", "ат", "ят",
|
||||
}
|
||||
|
||||
// looksInflected — does the word carry a verb ending? Short words are exempt:
|
||||
// a three-letter token is not enough stem to trust a two-letter suffix on
|
||||
// ("газ" would otherwise never match, but "нос" and "лес" would).
|
||||
func looksInflected(w string) bool {
|
||||
if len([]rune(w)) < 5 {
|
||||
return false
|
||||
}
|
||||
for _, s := range inflectedSuffixes {
|
||||
if strings.HasSuffix(w, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+57
-33
@@ -5,6 +5,8 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
)
|
||||
|
||||
// DateTimeParser — resolves relative→absolute AT CAPTURE ("in 4h" → now+4h),
|
||||
@@ -176,7 +178,7 @@ func afterWord(s, w string) string {
|
||||
type StubDateTimeParser struct{}
|
||||
|
||||
func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) {
|
||||
s := strings.ToLower(strings.TrimSpace(text))
|
||||
s := strings.ToLower(strings.TrimSpace(SpellOutDigits(text)))
|
||||
toks := strings.Fields(s)
|
||||
// scan for "in <num> <unit>" anywhere — dateparser extracts the datetime
|
||||
// expression from surrounding text; the stub does the same naively.
|
||||
@@ -204,14 +206,22 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (
|
||||
|
||||
// --- Russian time expressions (stub floor; dateparser replaces) ---
|
||||
|
||||
// "в <clock>" anywhere — mirror of the English "at" scan.
|
||||
// "в <clock>" anywhere — mirror of the English "at" scan. A qualifier
|
||||
// after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
|
||||
// with SpellOutDigits in front of this that is what "в семь вечера" reads
|
||||
// as too (Vikunja #469).
|
||||
for i := 0; i+1 < len(toks); i++ {
|
||||
if toks[i] != "в" {
|
||||
continue
|
||||
}
|
||||
if t, ok := parseClock(toks[i+1], now); ok {
|
||||
return t, true, nil
|
||||
t, ok := parseClock(toks[i+1], now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if i+2 < len(toks) {
|
||||
t = applyRuQualifier(t, toks[i+2], now)
|
||||
}
|
||||
return t, true, nil
|
||||
}
|
||||
|
||||
// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
|
||||
@@ -348,26 +358,17 @@ func leadingDigits(s string) (int, string, bool) {
|
||||
return n, s[i:], true
|
||||
}
|
||||
|
||||
// wordNumbers — small set, enough for natural test seeds ("four hours",
|
||||
// "thirty minutes"). Production dateparser handles the full ru/en range.
|
||||
var wordNumbers = map[string]int{
|
||||
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
|
||||
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
|
||||
"eleven": 11, "twelve": 12, "fifteen": 15, "twenty": 20,
|
||||
"thirty": 30, "forty": 40, "fifty": 50, "sixty": 60,
|
||||
// Russian word numbers (gender variants cover natural речи)
|
||||
"один": 1, "одна": 1, "одно": 1,
|
||||
"два": 2, "две": 2, "три": 3, "четыре": 4,
|
||||
"пять": 5, "шесть": 6, "семь": 7, "восемь": 8,
|
||||
"девять": 9, "десять": 10,
|
||||
}
|
||||
// leadingWordNumber reads a spoken number off the front of a phrase — "два
|
||||
// часа", "twenty minutes". The number words are a closed class and live in
|
||||
// internal/lexicon, complete: the inline table here stopped at "десять" in
|
||||
// Russian, so "пятнадцать минут" was not a duration (Vikunja #525).
|
||||
|
||||
func leadingWordNumber(s string) (int, string, bool) {
|
||||
toks := strings.Fields(s)
|
||||
if len(toks) == 0 {
|
||||
return 0, "", false
|
||||
}
|
||||
n, ok := wordNumbers[toks[0]]
|
||||
n, ok := lexicon.Cardinal(toks[0])
|
||||
if !ok {
|
||||
return 0, "", false
|
||||
}
|
||||
@@ -450,24 +451,20 @@ func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) {
|
||||
}
|
||||
|
||||
// ParseCalendarDate detects RU/EN calendar day words in text and returns
|
||||
// midnight of that day in now's own time zone. Handles "сегодня", "завтра",
|
||||
// "послезавтра", "вчера" (and the English words). Returns zero time + false
|
||||
// if no match.
|
||||
// midnight of that day in now's own time zone. Returns zero time + false if no
|
||||
// match.
|
||||
//
|
||||
// "послезавтра" is checked before "завтра" because it contains it.
|
||||
// The day words are a closed class and live in internal/lexicon, so this is a
|
||||
// lookup rather than an ordered switch (Vikunja #525). The switch it replaced
|
||||
// had to test "послезавтра" before "завтра" by hand, because one contains the
|
||||
// other — and it matched on substrings, so "завтраком" was tomorrow. The lexicon
|
||||
// matches on word boundaries and gained "позавчера", which was never here.
|
||||
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
|
||||
lower := strings.ToLower(text)
|
||||
switch {
|
||||
case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"):
|
||||
return midnight(now, 0), true
|
||||
case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"):
|
||||
return midnight(now, 2), true
|
||||
case strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow"):
|
||||
return midnight(now, 1), true
|
||||
case strings.Contains(lower, "вчера") || strings.Contains(lower, "yesterday"):
|
||||
return midnight(now, -1), true
|
||||
days, ok := lexicon.DayOffsetIn(text)
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Time{}, false
|
||||
return midnight(now, days), true
|
||||
}
|
||||
|
||||
// midnight returns the start of the day that is `days` away from now, in
|
||||
@@ -476,3 +473,30 @@ func midnight(now time.Time, days int) time.Time {
|
||||
y, m, d := now.AddDate(0, 0, days).Date()
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
// applyRuQualifier moves an hour into the afternoon when he said "вечера" or
|
||||
// "дня" after it. Noon-crossing only: 7 becomes 19, and 19 stays 19. Morning
|
||||
// qualifiers need no arithmetic, they only confirm the hour as spoken.
|
||||
//
|
||||
// The date is recomputed rather than shifted, so an hour that parseClock
|
||||
// already pushed to tomorrow does not land two days out.
|
||||
func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time {
|
||||
h := t.Hour()
|
||||
switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
|
||||
case "вечера", "дня":
|
||||
if h < 12 {
|
||||
h += 12
|
||||
}
|
||||
case "утра", "ночи":
|
||||
if h == 12 {
|
||||
h = 0
|
||||
}
|
||||
default:
|
||||
return t
|
||||
}
|
||||
out := time.Date(now.Year(), now.Month(), now.Day(), h, t.Minute(), 0, 0, now.Location())
|
||||
if !out.After(now) {
|
||||
out = out.Add(24 * time.Hour)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+200
-4
@@ -3,6 +3,9 @@ package router
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
)
|
||||
|
||||
// Grammar — one stage-0 exact-match pattern. Wake-word + known command grammar
|
||||
@@ -196,6 +199,20 @@ func AgendaQueryGrammars() []Grammar {
|
||||
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
|
||||
Build: agendaQueryBuild,
|
||||
},
|
||||
{
|
||||
// "что дальше?" — the rest of the day, with no possessive and no
|
||||
// plan word for the rules above to anchor on, so neither claimed
|
||||
// it and the model called it a fact (Vikunja #498). The predicate
|
||||
// for the same utterance already exists as IsRestOfDayQuery, one
|
||||
// layer down in the query chain; this is what gets the turn there.
|
||||
//
|
||||
// "и что там дальше" and "что потом дальше" are the same question,
|
||||
// and "what's next" splits into two tokens, hence the optional
|
||||
// middles rather than plain adjacency.
|
||||
Name: "rest-of-day-query",
|
||||
Pattern: regexp.MustCompile(`(?i)^\s*(и\s+)?(что|чего|what'?s?)\s+(там\s+|ещё\s+|еще\s+|потом\s+|у\s+меня\s+)?(дальше|next)(\s|[?!.]|$)`),
|
||||
Build: agendaQueryBuild,
|
||||
},
|
||||
{
|
||||
// A named event with no calendar word at all: "когда планёрка?",
|
||||
// "во сколько созвон". He is asking when something on his calendar
|
||||
@@ -208,13 +225,192 @@ func AgendaQueryGrammars() []Grammar {
|
||||
}
|
||||
}
|
||||
|
||||
// chatNarrativeTopics — the things "расскажи X" asks for that are not
|
||||
// questions about the world. She is being asked to entertain or to describe
|
||||
// herself, and the query chain has no source for either.
|
||||
var chatNarrativeTopics = regexp.MustCompile(`(?i)(анекдот|шутк|сказк|истори[юи]\s+на\s+ночь|о\s+себе|про\s+себя|о\s+нас|про\s+нас)`)
|
||||
|
||||
// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
|
||||
// carry no question mark and no interrogative, and so reached the resident
|
||||
// model with nothing deterministic in front of them (Vikunja #498).
|
||||
//
|
||||
// Both were routed IntentFact by the model. The fact gate catches the write and
|
||||
// re-runs the turn as a query, so nothing breaks today; what they cost is a full
|
||||
// model round trip to reach a decision two patterns can make offline, and a
|
||||
// wrong row on the routing fixture.
|
||||
//
|
||||
// Wired after the agenda grammars, which is where their overlap resolves:
|
||||
// "расскажи, что у меня сегодня" is claimed here as a query either way.
|
||||
func NarrativeQueryGrammars() []Grammar {
|
||||
return []Grammar{
|
||||
{
|
||||
// "что дальше?" — the rest of the day. IsRestOfDayQuery already
|
||||
// recognises it downstream in the query chain, but that runs after
|
||||
// the routing decision, and the routing decision was fact.
|
||||
Name: "rest-of-day-query",
|
||||
Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`),
|
||||
Build: agendaQueryBuild,
|
||||
},
|
||||
{
|
||||
// "расскажи про X" — a world question phrased as an instruction.
|
||||
// The lexicon is narrativeRequests, already written for the
|
||||
// question-shaped test in question.go.
|
||||
//
|
||||
// Anchored at the start: "запиши что мне рассказали" is a capture,
|
||||
// and a narrative verb buried mid-utterance is not the shape.
|
||||
Name: "narrative-query",
|
||||
Pattern: narrativeQueryPattern,
|
||||
Build: narrativeQueryBuild,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// entertainmentNouns — what "расскажи" asks for when it is not asking for
|
||||
// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make
|
||||
// something up, which is the one case where inventing is the right answer
|
||||
// (fixture ru-chat-003).
|
||||
var entertainmentNouns = []string{
|
||||
"анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки",
|
||||
"joke", "jokes", "story",
|
||||
}
|
||||
|
||||
// narrativeQueryBuild — the narrative shape is a query carrying its topic,
|
||||
// unless he also said one of the capture verbs, asked for entertainment, or
|
||||
// asked about her. "расскажи и запиши" is him asking for a note, and stage 0
|
||||
// must not take either off the cascade.
|
||||
//
|
||||
// The topic goes into Slots.Text rather than the whole utterance: the query
|
||||
// chain looks things up by it, and "расскажи мне про Ватерлоо" is a question
|
||||
// about Ватерлоо.
|
||||
func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||
topic := strings.TrimSpace(m[2])
|
||||
// "расскажи" with nothing after it is a conversational opener, and there is
|
||||
// no topic to look up.
|
||||
if topic == "" {
|
||||
return Decision{}, false
|
||||
}
|
||||
// Against the whole utterance, not the topic: "о себе" has its preposition
|
||||
// eaten by the pattern, leaving a bare "себе".
|
||||
if chatNarrativeTopics.MatchString(m[0]) {
|
||||
return Decision{}, false
|
||||
}
|
||||
for _, t := range planTokens(topic) {
|
||||
for _, v := range captureVerbs {
|
||||
if t == v {
|
||||
return Decision{}, false
|
||||
}
|
||||
}
|
||||
for _, v := range entertainmentNouns {
|
||||
if t == v {
|
||||
return Decision{}, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentQuery,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Text: topic},
|
||||
}, true
|
||||
}
|
||||
|
||||
// narrativeQueryPattern — "расскажи про X", built from the lexicon rather than
|
||||
// spelled out here (Vikunja #527). The verbs used to be a second copy of
|
||||
// lexicon.NarrativeRequests, and a second copy of a closed set is a set that
|
||||
// drifts: adding "поясни" in the data file left this rule not knowing it.
|
||||
//
|
||||
// Anchored at the start, which was the point of the old literal and still is:
|
||||
// "запиши что мне рассказали" is a capture, and a narrative verb buried
|
||||
// mid-utterance is not the shape. The dative and the preposition are eaten so
|
||||
// the topic slot comes out clean: "расскажи мне про Ватерлоо" leaves
|
||||
// "Ватерлоо".
|
||||
var narrativeQueryPattern = regexp.MustCompile(
|
||||
`(?is)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") +
|
||||
`)(?:\s+(?:мне|нам|us|me))?(?:\s+(?:про|о|об|about))?(\s+.+)$`)
|
||||
|
||||
// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to
|
||||
// IntentQuery so it reaches queryFeeds.
|
||||
//
|
||||
// Same shape of defect as the agenda grammars: the model calls it system, and
|
||||
// replySystem has no feeds arm, so the documented utterance of task 258 step 1
|
||||
// answered "пока не умею отвечать на этот вопрос." while the same question
|
||||
// worded with "новостях" worked (Vikunja #474).
|
||||
//
|
||||
// An ask word at the front and a feed noun after it are both required, which
|
||||
// is the same pair ParseFeedQuery wants. "что нового?" on its own is a greeting
|
||||
// — the most common opener in the language — and vagueNouns in feeds.go exists
|
||||
// to keep it out of the feed reader; routing it to query here would put it
|
||||
// back. "у меня новая лента в инстаграме" carries the noun without the ask and
|
||||
// stays the statement it is.
|
||||
func FeedQueryGrammar() Grammar {
|
||||
return Grammar{
|
||||
Name: "feed-query",
|
||||
// (\s|[?!.]|$) rather than \b, which is ASCII-only and never fires next
|
||||
// to a Cyrillic letter.
|
||||
Pattern: regexp.MustCompile(`(?i)^\s*(что|какие|расскажи|покажи|почитай|прочитай)\s+.*(лент|новостн)[а-я]*(\s|[?!.]|$)`),
|
||||
Build: agendaQueryBuild,
|
||||
}
|
||||
}
|
||||
|
||||
// dayWordPattern — the day words an agenda question can name. Weekdays appear
|
||||
// in the accusative and prepositional forms the questions actually use ("в
|
||||
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
||||
// than a fixed ending.
|
||||
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
|
||||
// среду", "на среде"), so each one contributes its stem plus an inflection
|
||||
// tail; the relative day words are exact.
|
||||
//
|
||||
// Built from the lexicon for the same reason as above. The literal that stood
|
||||
// here spelled all seven weekdays out a second time, in a third file after
|
||||
// cmd/mavend/voice.go and internal/ttsnorm.
|
||||
var dayWordPattern = buildDayWordPattern()
|
||||
|
||||
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
||||
// buildDayWordPattern — one alternation over the relative day words, the
|
||||
// weekday stems, and the two period words that are in no closed set ("на
|
||||
// выходных", "на неделе" name a span, not a day).
|
||||
func buildDayWordPattern() string {
|
||||
alts := []string{`выходн[а-я]+`, `недел[а-я]+`}
|
||||
for _, w := range lexicon.DayOffsetWords() {
|
||||
if strings.Contains(w, " ") || !isCyrillic(w) {
|
||||
// Multi-word and English members belong to the offset lookup, not
|
||||
// to a Russian agenda pattern.
|
||||
continue
|
||||
}
|
||||
alts = append(alts, regexp.QuoteMeta(w))
|
||||
}
|
||||
for i := 0; i < 7; i++ {
|
||||
day := lexicon.Weekday(i)
|
||||
if day == "" {
|
||||
continue
|
||||
}
|
||||
alts = append(alts, weekdayStem(day)+`[а-я]*`)
|
||||
}
|
||||
return `(` + strings.Join(alts, "|") + `)`
|
||||
}
|
||||
|
||||
// weekdayStem trims the nominative ending off a weekday so the pattern matches
|
||||
// the case forms an agenda question uses: "среда" has to reach "в среду", and
|
||||
// "понедельник" already ends on its stem.
|
||||
func weekdayStem(day string) string {
|
||||
r := []rune(day)
|
||||
switch r[len(r)-1] {
|
||||
case 'а', 'я', 'е', 'о', 'ь':
|
||||
return string(r[:len(r)-1])
|
||||
}
|
||||
return day
|
||||
}
|
||||
|
||||
// isCyrillic reports whether every rune is Cyrillic. Used to keep the English
|
||||
// members of a bilingual lexicon set out of a Russian-only pattern.
|
||||
func isCyrillic(s string) bool {
|
||||
for _, r := range s {
|
||||
if !unicode.Is(unicode.Cyrillic, r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return s != ""
|
||||
}
|
||||
|
||||
// agendaQueryBuild — shared Build for the agenda grammars and the feed one,
|
||||
// which all do the same single thing: keep the utterance out of IntentSystem
|
||||
// and let the query chain decide who answers it. Confidence 1.0 on
|
||||
// the intent only: the utterance travels intact and the query chain's own
|
||||
// matchers decide the rest.
|
||||
func agendaQueryBuild(m []string) (Decision, bool) {
|
||||
|
||||
@@ -248,3 +248,32 @@ func TaskCaptureGrammar() Grammar {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TaskListGrammar — stage 0 for "какие у меня задачи", "список дел", "что мне
|
||||
// нужно сделать" (Vikunja #467).
|
||||
//
|
||||
// The same exposure the capture marker had, pointed the other way. IsTaskListQuery
|
||||
// is a deterministic lookup that lives inside a query source, so it is only
|
||||
// consulted once the turn is already IntentQuery. A phrasing the model calls
|
||||
// system or note never reaches it, and "пока не умею" is what he hears — the
|
||||
// failure the agenda and feed rules were written for.
|
||||
//
|
||||
// Placed after the agenda rules, which already send "какие у меня задачи" to
|
||||
// query. What this adds is the phrasings with no possessive in them.
|
||||
func TaskListGrammar() Grammar {
|
||||
return Grammar{
|
||||
Name: "task-list-query",
|
||||
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
|
||||
Build: func(m []string) (Decision, bool) {
|
||||
if !IsTaskListQuery(m[1]) {
|
||||
return Decision{}, false
|
||||
}
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentQuery,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Text: strings.TrimSpace(m[1])},
|
||||
}, true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,3 +119,27 @@ func TestTaskCaptureGrammarClaimsTheMarker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTaskListGrammarClaimsTheAsk — a list question answered before the model,
|
||||
// including the phrasings with no possessive that used to route elsewhere.
|
||||
func TestTaskListGrammarClaimsTheAsk(t *testing.T) {
|
||||
g := TaskListGrammar()
|
||||
claimed := []string{"какие у меня задачи", "список дел", "что мне нужно сделать"}
|
||||
for _, u := range claimed {
|
||||
m := g.Pattern.FindStringSubmatch(u)
|
||||
if m == nil {
|
||||
t.Fatalf("%q did not match the grammar pattern", u)
|
||||
}
|
||||
d, ok := g.Build(m)
|
||||
if !ok || d.Intent != IntentQuery {
|
||||
t.Errorf("%q built %+v ok=%v; want a query", u, d, ok)
|
||||
}
|
||||
}
|
||||
passed := []string{"как дела", "напомни купить хлеб", "что docker делает"}
|
||||
for _, u := range passed {
|
||||
m := g.Pattern.FindStringSubmatch(u)
|
||||
if _, ok := g.Build(m); ok {
|
||||
t.Errorf("%q was claimed as a task list", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user