Merge pull request 'safeKey drops Cyrillic, so Russian calendar events on one day collide' (#119) from task/443-safekey-drops-cyrillic-so-russian-calend into master
This commit was merged in pull request #119.
This commit is contained in:
@@ -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('-')
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user