From 079cf689aaf0611c38bf64d1c2ffadef0fcdd418 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 22:53:01 +0400 Subject: [PATCH] router: agenda questions belong to query, not to system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "что у меня сегодня" and "что у меня в календаре сегодня" both routed IntentSystem on the deployed daemon, and replySystem has no agenda arm, so both answered "пока не умею". The calendar source that can answer them lives in the query chain and was never reached. The fixture has said query since ru-query-019 was written; the daemon disagreed with the fixture and the daemon was wrong. AgendaQueryGrammars routes them at stage 0, after the clock rules so "какой сегодня день" keeps reaching replySystem. Intent only — which source claims the turn stays the query chain's decision. This is what made the follow-up continuation look like it only worked for "what day is it". It did: the query half inherited an intent whose handler could not answer, so both halves came back "пока не умею". Measured on the 77-case RU fixture: full accuracy 70.1% → 72.7%, intent-only 75.3% → 77.9%, calendar 0/2 → 2/2, clarify counts unchanged. The eval harness wires the new grammars too, or the fixture would stop being a measurement of the daemon. Go's \b is ASCII-only and never fires after a Cyrillic letter, which the first version of the pattern learned the hard way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX --- CLAUDE.md | 9 ++++ cmd/mavend/voicewire.go | 4 ++ internal/router/agenda_test.go | 77 +++++++++++++++++++++++++++++++ internal/router/eval/eval_test.go | 3 ++ internal/router/stage0.go | 56 ++++++++++++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 internal/router/agenda_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 5de2a65..0ad7e27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,15 @@ tests are offline and cost nothing. Re-measured: **false clarifies 3 → 2, inte 75.3%, full accuracy unchanged at 70.1%, missed clarify still 1.** The two remaining false clarifies are the act-with-no-allowlisted-fn arm of the gate, not this rule. +Agenda questions taken off the model, 01-08-2026. `AgendaQueryGrammars` (`stage0.go`, wired +after the clock rules in `buildRouter`) routes "что у меня сегодня", "во сколько у меня +встреча" and anything naming a calendar to `IntentQuery` at stage 0. They were going to +`IntentSystem`, where `replySystem` has no agenda arm and answered "пока не умею" — the +fixture had said `query` since ru-query-019 was written. Measured: **full accuracy 70.1% → +72.7%, intent-only 75.3% → 77.9%, calendar 0/2 → 2/2**, clarify counts unchanged. Note that +Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an +explicit `(\s|[?!.]|$)`. + ## LLM output contract All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index b5657e3..ddbb722 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -317,6 +317,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, seedClassifier(cls) grammars := router.DefaultGrammars(acts) grammars = append(grammars, router.SystemTimeDateGrammars()...) + // After the time/date rules on purpose: "какой сегодня день" is a clock + // question and must keep reaching replySystem, while "что у меня сегодня" + // is an agenda question and must not. + grammars = append(grammars, router.AgendaQueryGrammars()...) 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 new file mode 100644 index 0000000..4ef3407 --- /dev/null +++ b/internal/router/agenda_test.go @@ -0,0 +1,77 @@ +package router + +import ( + "context" + "testing" +) + +// agendaRouter wires both grammar sets in the order the daemon wires them +// (voicewire.go): the clock rules first, the agenda rules after, so a test +// that passes here is a test of the deployed precedence. +func agendaRouter(t *testing.T) *Router { + t.Helper() + r := newTestRouter(t, 0.0) + r.grammars = append(r.grammars, SystemTimeDateGrammars()...) + r.grammars = append(r.grammars, AgendaQueryGrammars()...) + return r +} + +// An agenda question is answered from the calendar, which lives in the query +// chain. Routed to system it reaches replySystem, which has no agenda arm and +// says "пока не умею" — seen on the deployed daemon, 01-08-2026. +func TestAgendaQuestionsRouteToQuery(t *testing.T) { + r := agendaRouter(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 clock rules keep their utterances. They are registered first and the +// agenda patterns do not match them, so both statements have to hold. +func TestAgendaGrammarsLeaveTheClockAlone(t *testing.T) { + r := agendaRouter(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 != IntentSystem { + t.Errorf("route(%q) = %s, want system", u, d.Intent) + } + } +} + +// The agenda pattern is anchored and needs the possessive, so an ordinary +// statement that happens to contain "у меня" is not swallowed. +func TestAgendaGrammarSparesStatements(t *testing.T) { + r := agendaRouter(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 by the agenda grammar", u) + } + } +} diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index 6f0a1a6..9adf2c2 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -233,6 +233,9 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter } grammars := router.DefaultGrammars(acts) grammars = append(grammars, router.SystemTimeDateGrammars()...) + // 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.ReminderGrammar()) return router.New(router.Config{ Grammars: grammars, diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 5c120ba..615a4fc 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -140,6 +140,62 @@ func SystemTimeDateGrammars() []Grammar { } } +// AgendaQueryGrammars — stage-0 grammars for "what have I got on" questions, +// routed to IntentQuery so they reach the query chain (queryDayPlan, +// queryCalendar) instead of replySystem. +// +// This exists because the model puts them in IntentSystem. Measured on the +// deployed daemon 01-08-2026: "что у меня сегодня" and "что у меня в календаре +// сегодня" both routed system, and replySystem has no agenda arm, so both +// answered "пока не умею". The fixture has said query since ru-query-019 was +// written ("the clock/date system rule must not swallow it"); the daemon +// disagreed with the fixture and the daemon was wrong. +// +// Routing, not answering. These set the intent and nothing else — which source +// in the query chain claims the turn stays the chain's decision, and a +// question with no date still falls through queryCalendar to recall. +// +// Deliberately not folded into SystemTimeDateGrammars: those exist to send +// utterances TO system, these exist to keep utterances OUT of it, and one +// function returning both would read as a list of clock rules. +func AgendaQueryGrammars() []Grammar { + return []Grammar{ + { + // An explicit calendar noun is unambiguous wherever it appears: + // "что в календаре на завтра", "покажи расписание на среду". + Name: "calendar-query", + Pattern: regexp.MustCompile(`(?i)(календар|расписани|повестк)`), + Build: agendaQueryBuild, + }, + { + // The agenda phrasing with no calendar noun. Anchored at the start + // and requiring the possessive, so it reads as a question about his + // day: "что у меня сегодня", "что у меня стоит на послезавтра". + // "у меня кончилась вода" is a fact and does not match. + Name: "agenda-query", + // (\s|[?!.]|$) rather than \b: Go's \b is ASCII-only, so it does + // not see a boundary after a Cyrillic letter and the pattern + // silently never fires. + // "во сколько у меня встреча" is the same agenda question with a + // clock word in front, and the clock word is what sent it to + // system (fixture ru-query-013). + Pattern: regexp.MustCompile(`(?i)^\s*(что|чего|какие|сколько|во\s+сколько|когда)\s+у\s+меня(\s|[?!.]|$)`), + Build: agendaQueryBuild, + }, + } +} + +// 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. +func agendaQueryBuild(m []string) (Decision, bool) { + return Decision{ + Stage: 0, + Intent: IntentQuery, + Confidence: 1.0, + }, true +} + // timeQueryBuild — Build for the time-query grammar. Returns ok=false for // elapsed/duration queries ("сколько времени прошло", "сколько времени // осталось", "сколько времени до") so they fall through to the classifier.