From 69ecea19d5f18bd4f2d9c9f13eaf1b58e5b1d3ba Mon Sep 17 00:00:00 2001 From: claude Date: Wed, 5 Aug 2026 01:48:18 +0400 Subject: [PATCH] reminders: confirm from the row, not from the sentence (V-507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The confirmation was phrased by the replier off Slots.Text, so it named whatever hour the utterance contained — including one the parser rejected or read differently. He heard 'напомню в семь' with no row at seven, and stopped thinking about it. actionReminder now phrases it itself from the stored fire time, so the sentence and the row cannot disagree. Deterministic: the one sentence that must match a database row is not one to hand to a 1.7B. Also fixes formatTime, which had t.Format("2 января") — Go reads that as a literal, so every fact older than a day read as January. The month comes from internal/lexicon now, which is where months live. Co-Authored-By: Claude Opus 5 --- cmd/mavend/actions_reminder.go | 24 +++++++++++++- cmd/mavend/reactive_notes_test.go | 7 ++-- cmd/mavend/reminder_confirm_test.go | 51 +++++++++++++++++++++++++++++ cmd/mavend/ruwords.go | 4 ++- 4 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 cmd/mavend/reminder_confirm_test.go diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go index a2c11a2..7ff97ed 100644 --- a/cmd/mavend/actions_reminder.go +++ b/cmd/mavend/actions_reminder.go @@ -2,8 +2,11 @@ package main import ( "context" + "fmt" "log" + "time" + "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -33,5 +36,24 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio log.Printf("voice: create reminder: %v", err) return phraser.Ack(phraser.FailReminder, nil) } - return "" + // Phrased from the row, never from the utterance (Vikunja #507). The + // replier only ever saw Slots.Text, so it named whatever hour the sentence + // contained — including one the parser had rejected or read differently. + // A confirmation naming an hour no row holds is worse than a clarify, + // because he stops thinking about it. + return reminderConfirm(dec.Slots.Time, h.now()) +} + +// reminderConfirm — the confirmation for a reminder that exists, naming the +// stored fire time. Deterministic on purpose: the one sentence that must match +// a database row is not one to hand to a 1.7B. +func reminderConfirm(fire, now time.Time) string { + when := dayPrefix(now, fire) + if when == "это" { + // Further out than the day words reach — say the date instead of a + // word that would be wrong. + date := fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month()))) + return "хорошо, напомню " + date + " в " + fire.Format("15:04") + "." + } + return "хорошо, напомню " + when + " в " + fire.Format("15:04") + "." } diff --git a/cmd/mavend/reactive_notes_test.go b/cmd/mavend/reactive_notes_test.go index 22bedc3..811e706 100644 --- a/cmd/mavend/reactive_notes_test.go +++ b/cmd/mavend/reactive_notes_test.go @@ -44,9 +44,12 @@ func TestReactiveNotesReminders(t *testing.T) { HasTime: true, }, } + // The confirmation is phrased from the row now (Vikunja #507), so it + // names the stored hour rather than leaving the replier to read one + // out of the sentence. reply := h.applyAction(ctx, dec) - if reply != "" { - t.Errorf("expected empty reply from applyAction, got %q", reply) + if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want { + t.Errorf("reply = %q, want %q", reply, want) } reminders, err := st.ListReminders(ctx, 10) if err != nil { diff --git a/cmd/mavend/reminder_confirm_test.go b/cmd/mavend/reminder_confirm_test.go new file mode 100644 index 0000000..ade67c3 --- /dev/null +++ b/cmd/mavend/reminder_confirm_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +// A reminder confirmation is the one sentence that must match a database row. +// It used to be phrased by the replier from Slots.Text, which meant it named +// whatever hour the sentence contained — including an hour the parser had +// rejected or read differently (Vikunja #507). + +func TestReminderConfirmNamesTheStoredHour(t *testing.T) { + now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC) + got := reminderConfirm(now.Add(10*time.Hour), now) // 19:00 today + if !strings.Contains(got, "19:00") { + t.Fatalf("confirmation = %q, want the stored 19:00 in it", got) + } + if !strings.Contains(got, "сегодня") { + t.Fatalf("confirmation = %q, want it to say сегодня", got) + } +} + +func TestReminderConfirmUsesADateBeyondTheDayWords(t *testing.T) { + // dayPrefix answers "это" past послезавтра, and "напомню это в 09:00" is + // not a sentence. A date is. + now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC) + got := reminderConfirm(now.Add(10*24*time.Hour), now) + if strings.Contains(got, "это") { + t.Fatalf("confirmation = %q, want a date rather than the fallback day word", got) + } + if !strings.Contains(got, "15 августа") { + t.Fatalf("confirmation = %q, want the date in it", got) + } +} + +func TestReminderConfirmIsFeminineAndInformal(t *testing.T) { + // The persona checks the phrasing eval enforces apply here too, and this + // sentence never passes through a phraser. + now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC) + got := reminderConfirm(now.Add(time.Hour), now) + for _, bad := range []string{"вы", "ваш", "напомнил ", "рад "} { + if strings.Contains(strings.ToLower(got), bad) { + t.Fatalf("confirmation = %q contains %q", got, bad) + } + } + if !strings.HasPrefix(got, "хорошо, напомню") { + t.Fatalf("confirmation = %q, want it to open with the promise", got) + } +} diff --git a/cmd/mavend/ruwords.go b/cmd/mavend/ruwords.go index c254be8..0fe4a84 100644 --- a/cmd/mavend/ruwords.go +++ b/cmd/mavend/ruwords.go @@ -165,6 +165,8 @@ func formatTime(t time.Time) string { n := int(diff.Hours()) return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов")) default: - return t.Format("2 января 15:04") + // Not t.Format("2 января …"): Go reads that as a literal, so every + // fact older than a day used to read as January (Vikunja #507). + return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04")) } }