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