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/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. 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) + } +}