diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go index ce2a632..1d5410e 100644 --- a/cmd/mavend/actions_reminder.go +++ b/cmd/mavend/actions_reminder.go @@ -24,7 +24,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio return "не получилось разобрать время напоминания." } } - payload := `{"text":` + jsonString(dec.Utterance) + `}` + // The body is what she says at the hour, so the marker and the time come + // out of it: the fire time is already a column, and "напомни" is an + // instruction that has been carried out (Vikunja #469). + payload := `{"text":` + jsonString(reminderBody(dec.Utterance, dec.Slots.Text)) + `}` if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil { log.Printf("voice: create reminder: %v", err) return "не получилось поставить напоминание." diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index db1f140..6708d5b 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -497,8 +497,12 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) { if !strings.Contains(reminders[0].Payload, "маме") { t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload) } - if !strings.Contains(reminders[0].Payload, "11") { - t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload) + // The hour is the fire time, not a word in the body: the body is what she + // says at the hour, and the time expression is stripped out of it + // (Vikunja #469). Clobbering the parked request would show up here as a + // reminder that fires at some other time than the one he asked for. + if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) { + t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC()) } } diff --git a/cmd/mavend/reminderbody.go b/cmd/mavend/reminderbody.go new file mode 100644 index 0000000..19ea305 --- /dev/null +++ b/cmd/mavend/reminderbody.go @@ -0,0 +1,53 @@ +package main + +import ( + "regexp" + "strings" +) + +// reminderMarker — the words that open a reminder. Stripped because they are +// the instruction, not the thing to say at the hour. +var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`) + +// reminderTimeWords — the time expressions a reminder carries, removed from +// the body because the fire time is already a column. Ordered longest-first +// where two could match the same words, so "через полтора часа" does not leave +// "полтора" behind. +// +// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word +// boundaries here are written out as whitespace or an end of string — the same +// trap the agenda grammars hit. +var reminderTimeWords = []*regexp.Regexp{ + regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`), +} + +// reminderBody is what she says at the hour. +// +// The whole utterance used to be stored, so /reminders read "напомни завтра в +// 9 утра выпить таблетки" where it should read "выпить таблетки", and the +// agenda recited the marker back at him (Vikunja #469). The fire time is +// already a column, and the marker is an instruction that was carried out. +// +// Falls back to the fuller text whenever stripping would leave nothing: an +// empty body is a reminder that fires and says nothing, which is worse than a +// wordy one. +func reminderBody(utterance, text string) string { + body := strings.TrimSpace(text) + if body == "" { + body = strings.TrimSpace(utterance) + } + stripped := reminderMarker.ReplaceAllString(body, "") + for _, re := range reminderTimeWords { + stripped = re.ReplaceAllString(stripped, " ") + } + stripped = strings.TrimSpace(strings.Join(strings.Fields(stripped), " ")) + stripped = strings.Trim(stripped, " ,;:—-") + if stripped == "" { + return body + } + return stripped +} diff --git a/cmd/mavend/reminderbody_test.go b/cmd/mavend/reminderbody_test.go new file mode 100644 index 0000000..3597237 --- /dev/null +++ b/cmd/mavend/reminderbody_test.go @@ -0,0 +1,21 @@ +package main + +import "testing" + +func TestReminderBody(t *testing.T) { + for _, tc := range []struct{ utterance, text, want string }{ + // The row from the QA sitting: the whole utterance was the body. + {"напомни завтра в 9 утра выпить таблетки", "завтра в 9 утра выпить таблетки", "выпить таблетки"}, + {"напомни мне позвонить маме в семь вечера", "позвонить маме в 7 вечера", "позвонить маме"}, + {"напомни через полчаса проверить бэкап", "через полчаса проверить бэкап", "проверить бэкап"}, + {"remind me to call mom at 7pm", "to call mom at 7pm", "to call mom"}, + // Nothing left after stripping ⇒ keep what there was. A reminder that + // fires and says nothing is worse than a wordy one. + {"напомни завтра", "завтра", "завтра"}, + {"", "", ""}, + } { + if got := reminderBody(tc.utterance, tc.text); got != tc.want { + t.Errorf("reminderBody(%q, %q) = %q, want %q", tc.utterance, tc.text, got, tc.want) + } + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index 9d8368c..14aca14 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -946,6 +946,51 @@ func deliveryRows(as []ipc.DeliveryAttempt) []deliveryRow { return out } +// reminderRow is one line on /reminders, with the payload unwrapped and both +// timestamps already in his clock. +// +// The page rendered `{{.Payload}}` and the UTC instant, so a reminder read +// `{"text":"выпить таблетки"}` and fired an hour off what he was told +// (Vikunja #469). Neither is a formatting nicety: the envelope is an internal +// shape he never chose, and a time on a page he reads is the time on his wall. +type reminderRow struct { + Created string + Fires string + Status string + Text string +} + +// reminderText unwraps the {"text":...} payload the router writes. +// +// A copy of store.ReminderText rather than a call to it, because mavweb is one +// of the pure-Go daemons and internal/store carries the CGO sqlite driver. The +// ipc DTO is decoupled from the store on purpose, so the unwrap belongs to +// whoever renders it. Payload that is not that shape is shown as he said it. +func reminderText(payload string) string { + var m map[string]any + if err := json.Unmarshal([]byte(payload), &m); err == nil { + if t, ok := m["text"]; ok { + if s, isStr := t.(string); isStr && s != "" { + return s + } + } + } + return strings.TrimSpace(payload) +} + +func reminderRows(rs []ipc.Reminder) []reminderRow { + out := make([]reminderRow, 0, len(rs)) + for _, r := range rs { + out = append(out, reminderRow{ + Created: r.CreatedTs.Local().Format("02 Jan 15:04"), + Fires: r.FireTs.Local().Format("02 Jan 15:04"), + Status: r.Status, + Text: reminderText(r.Payload), + }) + } + return out +} + func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "reminders disabled (no -core)", http.StatusServiceUnavailable) @@ -959,7 +1004,7 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminders}); err != nil { + if err := remindersTmpl.Execute(w, map[string]any{"Reminders": reminderRows(reminders)}); err != nil { log.Printf("reminders template: %v", err) } } diff --git a/cmd/mavweb/reminders.html b/cmd/mavweb/reminders.html index e0f6bb6..e28ef22 100644 --- a/cmd/mavweb/reminders.html +++ b/cmd/mavweb/reminders.html @@ -3,10 +3,10 @@ {{if .Reminders}}
{{range .Reminders}} - - + + - +{{end}}
createdfiresstatuswhat
{{.CreatedTs.Format "02 Jan 15:04"}}{{.FireTs.Format "02 Jan 15:04"}}{{.Created}}{{.Fires}} {{.Status}}{{.Payload}}{{.Text}}
{{else}}
diff --git a/cmd/mavweb/reminders_test.go b/cmd/mavweb/reminders_test.go new file mode 100644 index 0000000..0fa4384 --- /dev/null +++ b/cmd/mavweb/reminders_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" +) + +// The page showed the storage envelope and the UTC instant (Vikunja #469). +func TestReminderRowsUnwrapAndLocalise(t *testing.T) { + fire := time.Date(2026, 8, 4, 18, 30, 0, 0, time.UTC) + rows := reminderRows([]ipc.Reminder{{ + CreatedTs: fire.Add(-time.Hour), + FireTs: fire, + Status: "pending", + Payload: `{"text":"выпить таблетки"}`, + }}) + if len(rows) != 1 { + t.Fatalf("rows = %d, want 1", len(rows)) + } + if rows[0].Text != "выпить таблетки" { + t.Errorf("Text = %q, want the words without the envelope", rows[0].Text) + } + if want := fire.Local().Format("02 Jan 15:04"); rows[0].Fires != want { + t.Errorf("Fires = %q, want %q", rows[0].Fires, want) + } + if strings.Contains(rows[0].Text, "{") { + t.Errorf("Text still carries JSON: %q", rows[0].Text) + } +} + +// A payload that is not the envelope is his own words, so it is shown as it is. +func TestReminderTextKeepsPlainPayload(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {`{"text":"позвонить маме"}`, "позвонить маме"}, + {" полить цветы ", "полить цветы"}, + {`{"body":"nope"}`, `{"body":"nope"}`}, + {"", ""}, + } { + if got := reminderText(tc.in); got != tc.want { + t.Errorf("reminderText(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/router/dateparser.go b/internal/router/dateparser.go index c51db10..ad94848 100644 --- a/internal/router/dateparser.go +++ b/internal/router/dateparser.go @@ -81,6 +81,9 @@ func NewPythonDateParser() *PythonDateParser { // or dateparser is unavailable, falls back to the stub parser. Returns // (time, true, nil) on success; (zero, false, nil) when no date is found. func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error) { + // Speech says the hour in words, and neither this parser nor the stub + // reads "в семь вечера" (Vikunja #469). Both see the digits instead. + text = SpellOutDigits(text) t, ok, err := p.parseWithPython(ctx, text, now) if err != nil { // python3 missing, dateparser not installed, or process failure — diff --git a/internal/router/numwords.go b/internal/router/numwords.go new file mode 100644 index 0000000..dbce260 --- /dev/null +++ b/internal/router/numwords.go @@ -0,0 +1,97 @@ +package router + +import "strings" + +// ruNumerals — spoken numbers as digits, for the clock hours and the minutes +// that follow them. Every case ending he might say is listed rather than +// stemmed: "в семь", "к семи", "около семи" are three forms of one hour, and a +// prefix rule short enough to cover them also matches "семья". +// +// Stops at thirty, which is as far as a spoken time goes ("без двадцати +// восемь", "в половине шестого"). Anything larger is said in digits. +var ruNumerals = map[string]string{ + "один": "1", "одного": "1", "одну": "1", "час": "1", "часу": "1", + "два": "2", "две": "2", "двух": "2", + "три": "3", "трёх": "3", "трех": "3", + "четыре": "4", "четырёх": "4", "четырех": "4", + "пять": "5", "пяти": "5", + "шесть": "6", "шести": "6", + "семь": "7", "семи": "7", + "восемь": "8", "восьми": "8", + "девять": "9", "девяти": "9", + "десять": "10", "десяти": "10", + "одиннадцать": "11", "одиннадцати": "11", + "двенадцать": "12", "двенадцати": "12", + "тринадцать": "13", "тринадцати": "13", + "четырнадцать": "14", "четырнадцати": "14", + "пятнадцать": "15", "пятнадцати": "15", + "шестнадцать": "16", "шестнадцати": "16", + "семнадцать": "17", "семнадцати": "17", + "восемнадцать": "18", "восемнадцати": "18", + "девятнадцать": "19", "девятнадцати": "19", + "двадцать": "20", "двадцати": "20", + "тридцать": "30", "тридцати": "30", + "сорок": "40", "сорока": "40", + "пятьдесят": "50", "пятидесяти": "50", +} + +// numeralContext — the words that make a numeral a time. A numeral is only +// rewritten when one of these sits next to it, so "три яблока" in a note is +// left alone and "в три часа" is not. +var numeralContext = map[string]bool{ + "в": true, "во": true, "к": true, "около": true, "на": true, + "часа": true, "часов": true, "час": true, "часу": true, + "утра": true, "вечера": true, "дня": true, "ночи": true, + "минут": true, "минуты": true, "минуту": true, + "at": true, "by": true, +} + +// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the +// shape they know. +// +// "напомни мне позвонить маме в семь вечера" parsed to nothing, while "в 19:00" +// parsed fine (Vikunja #469). Speech is where reminders come from, and speech +// says the hour in words, so this is not a long-tail case — it is the ordinary +// one. dateparser reads "в 7 вечера" through the qualifier rewrite the python +// script already does; it does not read "в семь вечера". +// +// Conservative by construction: a numeral is only rewritten when a time word +// stands beside it. "три часа" becomes "3 часа"; "три яблока" stays as it is, +// and a note or a fact carrying a spoken number is untouched. +func SpellOutDigits(text string) string { + toks := strings.Fields(text) + if len(toks) == 0 { + return text + } + out := make([]string, len(toks)) + copy(out, toks) + for i, tok := range toks { + key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'")) + digit, ok := ruNumerals[key] + if !ok { + continue + } + // "час" and "часу" are the hour noun as often as they are the number + // one, and rewriting "в час дня" to "в 1 дня" is right either way. What + // must not happen is rewriting the noun that gives another numeral its + // context: "в семь часов" must keep "часов". + if !hasTimeNeighbour(toks, i) { + continue + } + out[i] = digit + } + return strings.Join(out, " ") +} + +// hasTimeNeighbour reports whether the token before or after i is a time word. +func hasTimeNeighbour(toks []string, i int) bool { + for _, j := range []int{i - 1, i + 1} { + if j < 0 || j >= len(toks) { + continue + } + if numeralContext[strings.ToLower(strings.Trim(toks[j], ".,!?;:«»\"'"))] { + return true + } + } + return false +} diff --git a/internal/router/numwords_test.go b/internal/router/numwords_test.go new file mode 100644 index 0000000..f16d854 --- /dev/null +++ b/internal/router/numwords_test.go @@ -0,0 +1,38 @@ +package router + +import ( + "context" + "testing" + "time" +) + +func TestSpellOutDigits(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"напомни мне позвонить маме в семь вечера", "напомни мне позвонить маме в 7 вечера"}, + {"в три часа дня", "в 3 часа дня"}, + {"напомни в половине шестого", "напомни в половине шестого"}, + {"через двадцать минут", "через 20 минут"}, + // Untouched: no time word stands beside the number. + {"купить три яблока", "купить три яблока"}, + {"семь раз отмерь", "семь раз отмерь"}, + {"напомни в 19:00", "напомни в 19:00"}, + {"", ""}, + } { + if got := SpellOutDigits(tc.in); got != tc.want { + t.Errorf("SpellOutDigits(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// The utterance from the QA sitting that named this bug: the numeric form +// parsed and the spoken form did not. +func TestStubParsesASpokenHour(t *testing.T) { + now := time.Date(2026, 8, 2, 9, 0, 0, 0, time.Local) + got, ok, err := StubDateTimeParser{}.Parse(context.Background(), "напомни мне позвонить маме в семь вечера", now) + if err != nil || !ok { + t.Fatalf("Parse ok=%v err=%v, want a time", ok, err) + } + if got.Hour() != 19 { + t.Fatalf("hour = %d, want 19", got.Hour()) + } +} diff --git a/internal/router/slots.go b/internal/router/slots.go index 95998a2..6a9e701 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -176,7 +176,7 @@ func afterWord(s, w string) string { type StubDateTimeParser struct{} func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) { - s := strings.ToLower(strings.TrimSpace(text)) + s := strings.ToLower(strings.TrimSpace(SpellOutDigits(text))) toks := strings.Fields(s) // scan for "in " anywhere — dateparser extracts the datetime // expression from surrounding text; the stub does the same naively. @@ -204,14 +204,22 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) ( // --- Russian time expressions (stub floor; dateparser replaces) --- - // "в " anywhere — mirror of the English "at" scan. + // "в " anywhere — mirror of the English "at" scan. A qualifier + // after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and + // with SpellOutDigits in front of this that is what "в семь вечера" reads + // as too (Vikunja #469). for i := 0; i+1 < len(toks); i++ { if toks[i] != "в" { continue } - if t, ok := parseClock(toks[i+1], now); ok { - return t, true, nil + t, ok := parseClock(toks[i+1], now) + if !ok { + continue } + if i+2 < len(toks) { + t = applyRuQualifier(t, toks[i+2], now) + } + return t, true, nil } // "через " / "через " (bare = 1) / "через полчаса". @@ -476,3 +484,30 @@ func midnight(now time.Time, days int) time.Time { y, m, d := now.AddDate(0, 0, days).Date() return time.Date(y, m, d, 0, 0, 0, 0, now.Location()) } + +// applyRuQualifier moves an hour into the afternoon when he said "вечера" or +// "дня" after it. Noon-crossing only: 7 becomes 19, and 19 stays 19. Morning +// qualifiers need no arithmetic, they only confirm the hour as spoken. +// +// The date is recomputed rather than shifted, so an hour that parseClock +// already pushed to tomorrow does not land two days out. +func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time { + h := t.Hour() + switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") { + case "вечера", "дня": + if h < 12 { + h += 12 + } + case "утра", "ночи": + if h == 12 { + h = 0 + } + default: + return t + } + out := time.Date(now.Year(), now.Month(), now.Day(), h, t.Minute(), 0, 0, now.Location()) + if !out.After(now) { + out = out.Add(24 * time.Hour) + } + return out +}