From 77206f298e06b557a81e07fd277bf7b98b747a02 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 04:29:37 +0400 Subject: [PATCH 1/5] router: answer the task-list ask at stage 0 too, and mirror it in the fixture (V-467) The capture half landed with the grammar in 87d1761. This is the exposure the task asked to check for: IsTaskListQuery is a deterministic lookup that only runs once the turn is already a query, so a phrasing the model calls system never reaches it. The eval fixture was also missing both grammars, which is only worth having while it is the daemon's grammar set. --- cmd/mavend/voicewire.go | 3 +++ internal/router/eval/eval_test.go | 4 ++++ internal/router/task.go | 29 +++++++++++++++++++++++++++++ internal/router/task_test.go | 24 ++++++++++++++++++++++++ 4 files changed, 60 insertions(+) diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 7bc4ccd..23229b1 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -385,6 +385,9 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, // Same reason as the agenda rules, for the feeds: "что нового в лентах?" // routed system and answered "пока не умею" (Vikunja #474). 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()) // Last, and it matches any utterance shape — its Build is the filter. An // explicit capture marker beats the model, which called it an act and diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index 89fad15..f8f0571 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -237,7 +237,11 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter // 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()) return router.New(router.Config{ Grammars: grammars, Classifier: cls, diff --git a/internal/router/task.go b/internal/router/task.go index 96b0e2e..160cc1d 100644 --- a/internal/router/task.go +++ b/internal/router/task.go @@ -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 + }, + } +} diff --git a/internal/router/task_test.go b/internal/router/task_test.go index 83e5ffd..ead243b 100644 --- a/internal/router/task_test.go +++ b/internal/router/task_test.go @@ -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) + } + } +} From 1fe03f7a51f715f47adb44dfb800a062d850a340 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 11:37:14 +0400 Subject: [PATCH 2/5] calendar: a notification's 14:30 is 14:30 here (V-482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay that posts its instant as `2026-08-02T09:00:00Z` handed the wall clock inside the text that same zone, so «созвон в 14:30» was built as 14:30 UTC and read back as 18:30 on this UTC+4 box. Every ambient event landed late by the deploy's own offset, and correct on a UTC box, which is why no test caught it. Posted is an instant and carries a zone. The clock reading is a wall clock and carries none, so it resolves against the daemon's zone now. The tests pin time.Local to +04 in TestMain, so the four hours show up on a UTC runner too. Co-Authored-By: Claude Opus 5 --- internal/calendar/ambient.go | 9 ++++++ internal/calendar/ambient_test.go | 52 +++++++++++++++++++++++++------ 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/internal/calendar/ambient.go b/internal/calendar/ambient.go index 6721b0d..e7cc3af 100644 --- a/internal/calendar/ambient.go +++ b/internal/calendar/ambient.go @@ -68,10 +68,19 @@ var dayWords = map[string]int{ // word ("завтра", "tomorrow") when the notification carries one, and the result // is refused if it lands more than ambientPastGrace in the past. A bare start // time gets DefaultReminderDuration. +// +// The clock reading is read in the daemon's zone (Vikunja #482). Posted is an +// instant and carries an offset; "созвон в 14:30" is a wall clock and carries +// none, so the zone has to come from somewhere else. A relay that posts +// "2026-08-02T09:00:00Z" used to make that 14:30 UTC, which stored an 18:30 +// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a +// UTC box. The owner's phone and the box share a zone, so the box's zone is the +// honest reading of a bare wall clock. func EventFromNotification(n Notification) (Event, bool) { if n.Posted.IsZero() { return Event{}, false } + n.Posted = n.Posted.In(time.Local) line := strings.TrimSpace(n.Title + " " + n.Text) start, end, ok := parseTimeRange(line) if !ok { diff --git a/internal/calendar/ambient_test.go b/internal/calendar/ambient_test.go index 5484ac2..cdebaf5 100644 --- a/internal/calendar/ambient_test.go +++ b/internal/calendar/ambient_test.go @@ -1,12 +1,22 @@ package calendar import ( + "os" "testing" "time" ) +// A bare clock reading in a notification is read in the daemon's zone, so every +// test here needs a known one. UTC+4 is the deploy's (Europe/Samara) and it is +// the offset the 18:30 bug was measured at, so a regression shows up as four +// hours rather than as nothing at all on a UTC runner. +func TestMain(m *testing.M) { + time.Local = time.FixedZone("+04", 4*3600) + os.Exit(m.Run()) +} + func TestEventFromNotification(t *testing.T) { - posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600)) + posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local) tests := []struct { name string @@ -98,10 +108,10 @@ func TestEventFromNotification(t *testing.T) { if !ev.End.After(ev.Start) { t.Errorf("end %v must be after start %v", ev.End, ev.Start) } - // The event lands on the day the phone showed it, in the phone's - // location — not shifted into UTC. - if ev.Start.Location() != posted.Location() { - t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location()) + // The event lands on the day the phone showed it, in the daemon's + // zone — the clock reading is a wall clock, not an instant. + if ev.Start.Location() != time.Local { + t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local) } if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 { t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d) @@ -115,8 +125,7 @@ func TestEventFromNotification(t *testing.T) { // the meeting twelve hours in the past and filed it under today in FactKey. A // wrong meeting stored is worse than nothing stored. func TestEventFromNotificationDayWords(t *testing.T) { - loc := time.FixedZone("+04", 4*3600) - evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc) + evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local) tests := []struct { name string @@ -191,7 +200,7 @@ func TestEventFromNotificationDayWords(t *testing.T) { func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) { ev, ok := EventFromNotification(Notification{ Title: "Завтра Планёрка 09:00", - Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC), + Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local), }) if !ok { t.Fatal("expected an event") @@ -201,6 +210,31 @@ func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) { } } +// Vikunja #482. A relay that posts its instant as UTC used to hand the wall +// clock inside the text the same zone, so "созвон в 14:30" was stored as 14:30Z +// and read back as 18:30 on a UTC+4 box — late by exactly the deploy's offset, +// and correct-looking on a UTC one. Nobody writes a notification meaning 14:30Z. +func TestEventFromNotificationReadsTheClockAsLocalTime(t *testing.T) { + ev, ok := EventFromNotification(Notification{ + Package: "com.slack", + Title: "Standup", + Text: "созвон в 14:30", + Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local + }) + if !ok { + t.Fatal("expected an event") + } + if got := ev.Start.Format("15:04"); got != "14:30" { + t.Errorf("start = %s, want 14:30 local", got) + } + if ev.Start.Location() != time.Local { + t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local) + } + if got, want := FactKey(ev), "calendar_event_20260802_Standup"; got != want { + t.Errorf("fact key = %q, want %q", got, want) + } +} + func TestEventFromNotificationNeedsPostedAt(t *testing.T) { if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok { t.Error("a notification with no posted_at has no date to sit on") @@ -211,7 +245,7 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) { func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) { ev, ok := EventFromNotification(Notification{ Title: "Планёрка 10:00-10:30", - Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC), + Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.Local), }) if !ok { t.Fatal("expected an event") From 82bd160c0de5280beab6e810ba51eca21910a892 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 11:38:51 +0400 Subject: [PATCH 3/5] phraser: the startup timeout is a config field, and the arm has a test (V-323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 60s wait for llama-server's listen line was hardcoded, so the last arm of the startup race could not be tested without waiting a real minute, and a box where a cold 1.7B loads off spinning disk had no way to raise it. Config.StartupTimeout, defaulted to 60s. The test drives the arm at 200ms against a fake server that never listens, and asserts the child is killed and reaped — that arm leaks a llama-server still loading a model otherwise. startLlamaProc 90.9% → 96.0%, package 76.9% → 77.6%. Co-Authored-By: Claude Opus 5 --- internal/phraser/llmphraser.go | 23 +++++++++++++++++++++-- internal/phraser/spawn_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 2c5e28d..1fcfd4f 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -85,6 +85,15 @@ type Config struct { NCtx int Timeout time.Duration + // StartupTimeout bounds the wait for llama-server to print the address it + // listens on. A config field and not a constant because the box may + // legitimately need longer: a cold 1.7B loading off a spinning disk can + // outrun a minute, and until this existed that returned "server did not + // start within 60s" with no way to raise it. + // + // 0 ⇒ defaultStartupTimeout. + StartupTimeout time.Duration + // CacheRAMMiB bounds llama-server's prompt cache, which is what actually ate // this box. Measured on homesrv 2026-08-03: the server's own default limit is // 8192 MiB, it stores the full KV state of every idle slot it evicts (112 kiB @@ -134,6 +143,8 @@ func DefaultConfig(modelPath string) Config { // 512 MiB caps total RSS near 1 GB and still holds several recent prompts. CacheRAMMiB: 512, Timeout: 30 * time.Second, + + StartupTimeout: defaultStartupTimeout, } } @@ -260,7 +271,15 @@ func llamaArgs(cfg Config) []string { return args } +// defaultStartupTimeout — the wait for llama-server's listen line when Config +// does not set one. A cold model load off disk is the slow part. +const defaultStartupTimeout = 60 * time.Second + func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) { + startupTimeout := cfg.StartupTimeout + if startupTimeout <= 0 { + startupTimeout = defaultStartupTimeout + } p := &llamaProc{} cmd := exec.CommandContext(ctx, cfg.BinPath, llamaArgs(cfg)...) // Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by @@ -338,8 +357,8 @@ func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) { return fail(fmt.Errorf("llm: server output: %w; last output: %s", err, tail.String())) case <-ctx.Done(): return fail(ctx.Err()) - case <-time.After(60 * time.Second): - return fail(fmt.Errorf("llm: server did not start within 60s; last output: %s", tail.String())) + case <-time.After(startupTimeout): + return fail(fmt.Errorf("llm: server did not start within %s; last output: %s", startupTimeout, tail.String())) } } diff --git a/internal/phraser/spawn_test.go b/internal/phraser/spawn_test.go index 636a7e9..e89b7f8 100644 --- a/internal/phraser/spawn_test.go +++ b/internal/phraser/spawn_test.go @@ -181,6 +181,37 @@ exit 1`) t.Fatalf("err = %v, want context.Canceled", err) } }) + + // The last arm of the startup race, and the one most likely to leak: a + // llama-server still loading a model is alive, so giving up on it without + // killing and reaping it orphans a process holding the GPU. Testable at all + // because Config.StartupTimeout replaced a hardcoded 60s (Vikunja #323). + t.Run("startup timeout", func(t *testing.T) { + pidPath := filepath.Join(t.TempDir(), "pid") + bin := fakeLlama(t, fmt.Sprintf(`echo $$ > %s +while : ; do sleep 1 ; done`, pidPath)) + cfg := testCfg(bin) + cfg.StartupTimeout = 200 * time.Millisecond + + _, err := startLlamaProc(context.Background(), cfg) + if err == nil || !strings.Contains(err.Error(), "did not start within 200ms") { + t.Fatalf("err = %v, want the startup-timeout arm naming the timeout", err) + } + + raw, readErr := os.ReadFile(pidPath) + if readErr != nil { + t.Fatalf("fake server never recorded its pid: %v", readErr) + } + pid, convErr := strconv.Atoi(strings.TrimSpace(string(raw))) + if convErr != nil { + t.Fatalf("pid file = %q: %v", raw, convErr) + } + // Killed, and reaped: a zombie still answers signal 0, so this asserts + // the Wait ran too. + if err := syscall.Kill(pid, 0); err == nil { + t.Errorf("llama-server %d survived the startup timeout", pid) + } + }) } func TestNewLLMPhraserSpawns(t *testing.T) { From a2081d82277ca799847cc573ec052e27575cb33e Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 11:45:02 +0400 Subject: [PATCH 4/5] =?UTF-8?q?router:=20stage=200=20claims=20"=D1=87?= =?UTF-8?q?=D1=82=D0=BE=20=D0=B4=D0=B0=D0=BB=D1=8C=D1=88=D0=B5"=20and=20"?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=81=D0=BA=D0=B0=D0=B6=D0=B8=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=20X"=20(V-498)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither utterance carries a question mark or an interrogative, so nothing at stage 0 claimed them and the model called both facts. The write is contained — actions_fact refuses a question-shaped fact and re-runs the turn as a query — but every one of these paid a full model round trip to reach a decision two regexes can make, and the fixture scored the routing as wrong. rest-of-day-query joins the agenda grammars: the predicate for the utterance already existed as IsRestOfDayQuery, one layer down in the query chain, and this is what gets the turn there. NarrativeQueryGrammar reads the same narrativeRequests lexicon IsQuestionShaped reads, and declines the topics that are chat rather than world questions — a joke, a bedtime story, herself. It is wired last, so an explicit capture marker still wins. Fixture: ru-query-024 and ru-query-025, both passing. Classifier + ONNX baseline 56/80 (70.0%) → 58/82 (70.7%), no case regressed and no new false clarify. The LLM arm is unmeasured here — no llama-server in this run. The mavweb auth test posted its instant as "Z", which the #482 fix now reads in the daemon's zone, making the clock inside the text stale by the test box's own offset. It carries the local offset now. Co-Authored-By: Claude Opus 5 --- cmd/mavend/voicewire.go | 4 ++ cmd/mavweb/ambient_test.go | 8 ++- internal/router/eval/eval_test.go | 3 + internal/router/eval/ru_routing_v1.json | 2 + internal/router/narrative_test.go | 85 +++++++++++++++++++++++++ internal/router/stage0.go | 63 ++++++++++++++++++ 6 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 internal/router/narrative_test.go 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. // From c5e993fc5518084f2ac40d99988b62213d8bd182 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 11:46:12 +0400 Subject: [PATCH 5/5] docs: record the two stage 0 shapes and what they measured (V-498) Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 779e26b..5017e72 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -189,6 +189,18 @@ fixture had said `query` since ru-query-019 was written. Measured: **full accura Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an explicit `(\s|[?!.]|$)`. +Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside +`AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar` +(`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про +X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model +called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a +latency and fixture defect, not a correctness one. The narrative rule reads the same +`narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a +joke, a bedtime story, herself — because the query chain has no source that answers those. +New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) → +58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no +llama-server in that run), so judge it again before quoting a cascade number. + ## LLM output contract All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and