From 43f2c37538f5c8002d43f8df8d8bbb12bbbd8558 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 02:52:51 +0400 Subject: [PATCH 1/2] router: stage 0 claims the other days and the named event (V-471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "какие планы на сегодня" worked and "какие планы на завтра" answered "пока не умею": the agenda rule needs "у меня" or a calendar noun, and that phrasing carries neither. "когда планёрка?" had the same shape. Two rules. One takes a plan noun aimed at a named day, one takes a closed list of event nouns after "когда"/"во сколько". Both route intent only, so the query chain still decides which source answers. classifier+onnx over the fixture: 55/79, 69.6% full, with the two new cases passing and no case moving the other way. --- internal/router/agenda_test.go | 44 +++++++++++++++++++++++++ internal/router/eval/ru_routing_v1.json | 2 ++ internal/router/stage0.go | 29 ++++++++++++++++ 3 files changed, 75 insertions(+) diff --git a/internal/router/agenda_test.go b/internal/router/agenda_test.go index 4ef3407..9789c90 100644 --- a/internal/router/agenda_test.go +++ b/internal/router/agenda_test.go @@ -75,3 +75,47 @@ func TestAgendaGrammarSparesStatements(t *testing.T) { } } } + +// The tomorrow form and the bare event noun. Both were measured answering +// "пока не умею" on the deployed daemon, 02-08-2026, while the same question +// about today worked — the first rule set needed "у меня" or a calendar noun +// and these phrasings carry neither (Vikunja #471). +func TestAgendaCoversOtherDaysAndNamedEvents(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 two new rules are narrow on purpose. A world question that opens with +// "когда" is not an agenda question, and telling her about a plan is not +// asking about one. +func TestAgendaGrammarsLeaveTheWorldAlone(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 { + t.Errorf("route(%q) was claimed at stage 0 as %s", u, d.Intent) + } + } +} diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 6c765f0..2f6004e 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -23,6 +23,8 @@ { "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] }, { "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] }, { "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-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/stage0.go b/internal/router/stage0.go index 615a4fc..eafc89a 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -182,9 +182,38 @@ func AgendaQueryGrammars() []Grammar { Pattern: regexp.MustCompile(`(?i)^\s*(что|чего|какие|сколько|во\s+сколько|когда)\s+у\s+меня(\s|[?!.]|$)`), Build: agendaQueryBuild, }, + { + // A plan noun aimed at a named day, with no possessive to anchor + // on: "какие планы на завтра", "что по делам в среду". The rule + // above wants "у меня" and this phrasing never has it, so + // "какие планы на завтра" answered "пока не умею" while "какие + // планы на сегодня" worked (Vikunja #471). The day word is what + // makes it an agenda question rather than a topic. + Name: "plan-day-query", + // Only "план" and "дел". A verb stem like "встреч" would take + // "встречаемся в среду", which is him telling her something, not + // asking. + Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`), + Build: agendaQueryBuild, + }, + { + // A named event with no calendar word at all: "когда планёрка?", + // "во сколько созвон". He is asking when something on his calendar + // happens, and the noun is the only signal. Closed list, so "когда + // битва при Ватерлоо" is still a world question. + Name: "event-time-query", + Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\s|[?!.]|$)`), + Build: agendaQueryBuild, + }, } } +// dayWordPattern — the day words an agenda question can name. Weekdays appear +// in the accusative and prepositional forms the questions actually use ("в +// среду", "на среде"), which is why the stems carry an inflection tail rather +// than a fixed ending. +const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)` + // 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. From 908d92a7e80c921d0eb8368577fc33387042ba21 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 02:56:09 +0400 Subject: [PATCH 2/2] calendar: a Russian summary keeps its letters in the fact key (V-443) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit safeKey kept ASCII only, so "Встреча с Аней" and "Обед с мамой" both reduced to "--" and shared one key on one day. The second event of the day overwrote the first, silently, and his calendar is Russian. Letters and digits in any script now pass. Migration #18 deletes the rows written under the old rule instead of rewriting them: a calendar fact is derived, the next poll writes the day again, and a stale row reads as an extra meeting. --- internal/calendar/calendar.go | 15 ++++++++++---- internal/calendar/calendar_test.go | 19 +++++++++++++++++ internal/store/migrations.go | 11 ++++++++++ internal/store/migrations_test.go | 33 ++++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 4 deletions(-) diff --git a/internal/calendar/calendar.go b/internal/calendar/calendar.go index 829a9e0..cb62305 100644 --- a/internal/calendar/calendar.go +++ b/internal/calendar/calendar.go @@ -18,6 +18,7 @@ import ( "sort" "strings" "time" + "unicode" ) // Fact sources. A calendar event reaches the store as a @@ -153,14 +154,20 @@ func Overlapping(events []Event, from, to time.Time) []Event { return out } -// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics -// and dashes). Non-Latin summaries collapse to their punctuation, which is why -// the day prefix carries the identity and this only disambiguates within a day. +// safeKey makes a summary safe to use inside a fact key: letters and digits in +// any script, plus dashes, with space and underscore folded to a dash. +// +// It kept ASCII only until 04-08-2026, and dropped everything else. His +// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to +// "--" and produced the same key on the same day — the second event of the day +// silently overwrote the first (Vikunja #443). Letting the letters through is +// what makes the key identify the event. Migration #18 drops the keys written +// under the old rule; they are re-derived on the next poll. func safeKey(s string) string { var b strings.Builder for _, r := range s { switch { - case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-': + case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-': b.WriteRune(r) case r == ' ' || r == '_': b.WriteRune('-') diff --git a/internal/calendar/calendar_test.go b/internal/calendar/calendar_test.go index a7ffdbb..e701176 100644 --- a/internal/calendar/calendar_test.go +++ b/internal/calendar/calendar_test.go @@ -139,6 +139,9 @@ func TestSafeKey(t *testing.T) { {"Hello_World", "Hello-World"}, {"special@#$chars!!", "specialchars"}, {"ALL_CAPS_123", "ALL-CAPS-123"}, + // His calendar is Russian. These reduced to "--" and "--" (Vikunja #443). + {"Встреча с Аней", "Встреча-с-Аней"}, + {"Обед с мамой", "Обед-с-мамой"}, } for _, tt := range tests { if got := safeKey(tt.in); got != tt.want { @@ -263,3 +266,19 @@ func TestSourceTrust(t *testing.T) { t.Errorf("Sources() = %v", Sources()) } } + +// Two Russian events on one day must not share a key. They did: safeKey kept +// ASCII only, so both summaries collapsed to their spaces and the second event +// overwrote the first in the store (Vikunja #443). +func TestFactKeyDistinguishesRussianEventsOnOneDay(t *testing.T) { + day := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) + a := Event{Summary: "Встреча с Аней", Start: day.Add(10 * time.Hour), End: day.Add(11 * time.Hour)} + b := Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(14 * time.Hour)} + if FactKeyIn(a, time.UTC) == FactKeyIn(b, time.UTC) { + t.Fatalf("both events keyed as %q", FactKeyIn(a, time.UTC)) + } + // The day prefix still has to survive, because the store range-scans on it. + if !strings.HasPrefix(FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) { + t.Fatalf("key %q lost the day prefix %q", FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) + } +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index a266efe..8a94f64 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -208,6 +208,17 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2 // list_tasks into something that writes without the row changing by one // byte. The fingerprint is the declared shape at approval time, so a // redefinition is a re-approval instead of a silent upgrade. + `DELETE FROM facts + WHERE key LIKE 'calendar_event_%' + AND replace(substr(key, 25), '-', '') = '';`, + // #18 — drop the calendar keys written while safeKey dropped Cyrillic + // (Vikunja #443). Everything after the date prefix was punctuation, so + // every Russian event on one day shared one key and only the last one + // survived. Deleting rather than rewriting: a calendar fact is derived + // data, the next poll writes the day again under keys that identify the + // event, and the old rows would otherwise be recited as extra meetings. + // The filter is exact — it keeps any key whose summary part still has a + // letter or a digit in it. } // migrate applies every migration with a number greater than the DB's current diff --git a/internal/store/migrations_test.go b/internal/store/migrations_test.go index e561413..e0b2663 100644 --- a/internal/store/migrations_test.go +++ b/internal/store/migrations_test.go @@ -47,3 +47,36 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) { t.Fatalf("after re-migrate user_version = %d, want %d", v, want) } } + +// Migration #18 clears the calendar keys written while safeKey dropped +// Cyrillic. Those rows are indistinguishable from real events on read, so +// leaving them would recite one meeting as several (Vikunja #443). +func TestCollapsedCalendarKeysAreDropped(t *testing.T) { + ctx := context.Background() + s := newTestStore(t) + + rows := []string{ + "calendar_event_20260804_--", // "Встреча с Аней" under the old rule + "calendar_event_20260804_", // a one-word Russian summary + "calendar_event_20260804_Встреча-с-Аней", // the new format + "calendar_event_20260804_Standup", // an ASCII summary, always fine + } + for _, key := range rows { + if _, err := s.db.ExecContext(ctx, + `INSERT INTO facts (ts, kind, key, value, source, confidence) VALUES (0, 'env', ?, 'x', 'poll:caldav', 1.0)`, + key); err != nil { + t.Fatalf("seed %q: %v", key, err) + } + } + if _, err := s.db.ExecContext(ctx, migrations[17]); err != nil { + t.Fatalf("migration 18: %v", err) + } + + var got int + if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM facts WHERE key LIKE 'calendar_event_%'`).Scan(&got); err != nil { + t.Fatal(err) + } + if got != 2 { + t.Fatalf("%d calendar rows left, want the 2 that identify their event", got) + } +}