diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 23229b1..b95edc6 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -394,6 +394,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // rewrote the task text (Vikunja #467). After the rules above because a // marker never collides with a clock or agenda question. grammars = append(grammars, router.TaskCaptureGrammar()) + // After the capture marker, so "запиши" still wins over "расскажи", and + // last overall because it matches on the first word alone: "расскажи про + // X" is a world question the model called a fact (Vikunja #498). + grammars = append(grammars, router.NarrativeQueryGrammar()) return router.New(router.Config{ Grammars: grammars, Classifier: cls, diff --git a/cmd/mavweb/ambient_test.go b/cmd/mavweb/ambient_test.go index 1636a1b..8ebadf7 100644 --- a/cmd/mavweb/ambient_test.go +++ b/cmd/mavweb/ambient_test.go @@ -139,7 +139,13 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) { } func TestHandleAmbientAuth(t *testing.T) { - body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}` + // posted_at carries the local offset, and the clock reading inside the text + // sits twenty minutes after it. A bare "Z" here would make the reading + // stale by the test machine's own offset and the handler would answer 202 + // no-meeting, which says nothing about the auth this test is checking + // (Vikunja #482). + posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local) + body := fmt.Sprintf(`{"title":"Планёрка 10:00","posted_at":%q}`, posted.Format(time.RFC3339)) newReq := func(hdr, val string) *http.Request { r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body)) diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index f8f0571..6234ce5 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -242,6 +242,9 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter 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.NarrativeQueryGrammar()) return router.New(router.Config{ Grammars: grammars, Classifier: cls, diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 44b90e8..995a9a7 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -25,6 +25,8 @@ { "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" }, diff --git a/internal/router/narrative_test.go b/internal/router/narrative_test.go new file mode 100644 index 0000000..541534f --- /dev/null +++ b/internal/router/narrative_test.go @@ -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, NarrativeQueryGrammar()) + 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) + } +} diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 8b04d58..b976f1d 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -196,6 +196,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,6 +222,55 @@ 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+нас)`) + +// NarrativeQueryGrammar — stage-0 rule for "расскажи про X", "объясни X", +// "опиши X", routed to IntentQuery. +// +// It carries no question mark and no interrogative, so the model called +// "расскажи про битву при Ватерлоо" a fact and tried to store the answer it +// invented (Vikunja #470, point 1). The write is contained now — actions_fact +// refuses a question-shaped write and re-runs the turn as a query — but every +// such utterance still paid a model round trip to reach a decision one regex +// can make, and the fixture still scored the routing as wrong (Vikunja #498). +// +// The lexicon is narrativeRequests in question.go, which IsQuestionShaped +// already uses. One list, two callers: a word that marks an utterance as +// asking must not mark it here and not there. +// +// Routing, not answering. Which source claims the turn is still the query +// chain's decision, and the personal boundary still sits where it sat. +func NarrativeQueryGrammar() Grammar { + return Grammar{ + Name: "narrative-query", + // (\s|[?!.]|$) rather than \b: Go's \b is ASCII-only and never fires + // after a Cyrillic letter, so the pattern would silently never match. + Pattern: regexp.MustCompile(`(?is)^\s*(` + strings.Join(narrativeRequests, "|") + `)(?:\s+(?:мне|нам|us|me))?(?:\s+(?:про|о|об|about))?(\s+.+)$`), + Build: func(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 + } + return Decision{ + Stage: 0, + Intent: IntentQuery, + Confidence: 1.0, + Slots: Slots{Text: topic}, + }, true + }, + } +} + // FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to // IntentQuery so it reaches queryFeeds. //