reminders: confirm from the row, not from the sentence (V-507)

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 01:48:18 +04:00
parent 8a13d189bb
commit 69ecea19d5
4 changed files with 82 additions and 4 deletions
+23 -1
View File
@@ -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") + "."
}
+5 -2
View File
@@ -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 {
+51
View File
@@ -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)
}
}
+3 -1
View File
@@ -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"))
}
}