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 diff --git a/cmd/mavend/voicewire.go b/cmd/mavend/voicewire.go index 7bc4ccd..b95edc6 100644 --- a/cmd/mavend/voicewire.go +++ b/cmd/mavend/voicewire.go @@ -385,12 +385,19 @@ 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 // 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/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") 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) { diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index 89fad15..6234ce5 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -237,7 +237,14 @@ 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()) + // "расскажи про 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. // 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) + } + } +}