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.