diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index f4fd334..8bf2361 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -379,6 +379,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // question and must keep reaching replySystem, while "что у меня сегодня" // is an agenda question and must not. grammars = append(grammars, router.AgendaQueryGrammars()...) + // After the agenda rules: "расскажи, что у меня сегодня" is an agenda + // question first and a narrative request second (Vikunja #498). + grammars = append(grammars, router.NarrativeQueryGrammars()...) grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ Grammars: grammars, diff --git a/internal/router/agenda_test.go b/internal/router/agenda_test.go index 4ef3407..7ee8420 100644 --- a/internal/router/agenda_test.go +++ b/internal/router/agenda_test.go @@ -75,3 +75,40 @@ 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) + } + } +} + +// 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) + } +} diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index 9adf2c2..89dd17c 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -236,6 +236,7 @@ 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.NarrativeQueryGrammars()...) grammars = append(grammars, router.ReminderGrammar()) return router.New(router.Config{ Grammars: grammars, diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 6c765f0..2de1e25 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -28,6 +28,8 @@ { "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"] }, diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 615a4fc..b02a3be 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -185,6 +185,73 @@ func AgendaQueryGrammars() []Grammar { } } +// 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: regexp.MustCompile(`(?i)^\s*(расскажи|объясни|опиши|перечисли|tell|explain|describe)(\s+(.*))?$`), + 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 unless he also said one +// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him +// asking for a note, and stage 0 must not take either off the cascade. +func narrativeQueryBuild(m []string) (Decision, bool) { + rest := "" + if len(m) > 3 { + rest = m[3] + } + for _, t := range planTokens(rest) { + for _, v := range captureVerbs { + if t == v { + return Decision{}, false + } + } + for _, v := range entertainmentNouns { + if t == v { + return Decision{}, false + } + } + } + return agendaQueryBuild(m) +} + // agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on // the intent only: the utterance travels intact and the query chain's own // matchers decide the rest.