From bad3fa40354816a1f95f279c0136c6c3758d2980 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:44:18 +0400 Subject: [PATCH] say: load the summaries family (V-506) Same nil-safe shape as the four families in phraser: a floor holding the exact literals that lived in Go, a load-time placeholder check on every entry whose job is to read the aggregate back, and S/IsS for the callers and their tests. No call site moved yet. --- internal/say/summary.go | 191 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 internal/say/summary.go diff --git a/internal/say/summary.go b/internal/say/summary.go new file mode 100644 index 0000000..3b03f3a --- /dev/null +++ b/internal/say/summary.go @@ -0,0 +1,191 @@ +package say + +// The summary sentences — what she says around aggregated data: the morning +// plan, the ranked task list, and the habits read back out of behaviour records. +// +// Fifth family on the deck, and the first one outside internal/phraser. It +// lives here because its three callers — internal/morning, internal/tasks and +// internal/memory — sit under phraser in the import graph and cannot reach it. +// +// The "I have not seen enough yet" sentences are the load-bearing ones. Three +// days of taps and a year of them produce the same "обычно ты ...", and only one +// of those is worth believing, so the empty cases say she has not seen a +// pattern rather than that he has none. + +import ( + _ "embed" + "log" + "math/rand" + "sync" +) + +//go:embed summary_ru_v1.json +var summaryJSON []byte + +// SummarySchemaVersion — this family's own version. +const SummarySchemaVersion = 1 + +// The entry keys. +const ( + PlanRestEmpty = "plan_rest_empty" + PlanDayEmpty = "plan_day_empty" + PlanDay = "plan_day" + PlanUncertain = "plan_uncertain" + + TasksNone = "tasks_none" + TasksFirst = "tasks_first" + TasksCandidates = "tasks_candidates" + + ReasonOverdue = "reason_overdue" + ReasonOverdueDay = "reason_overdue_day" + ReasonOverdueDays = "reason_overdue_days" + ReasonToday = "reason_today" + ReasonTomorrow = "reason_tomorrow" + ReasonInDays = "reason_in_days" + ReasonImportant = "reason_important" + ReasonUrgent = "reason_urgent" + ReasonStale = "reason_stale" + + HabitWeekday = "habit_weekday" + HabitWeekdaySame = "habit_weekday_same" + HabitWeekdayNone = "habit_weekday_none" + HabitWeekendBoth = "habit_weekend_both" + HabitWeekendSat = "habit_weekend_sat" + HabitWeekendSun = "habit_weekend_sun" + HabitWeekendSame = "habit_weekend_same" + HabitWeekendNone = "habit_weekend_none" + HabitOverall = "habit_overall" + HabitOverallNone = "habit_overall_none" + HabitSpanToday = "habit_span_today" + HabitSpanDays = "habit_span_days" + HabitUnglossed = "habit_unglossed" + HabitAt = "habit_at" +) + +var summaryKeys = []string{ + PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain, + TasksNone, TasksFirst, TasksCandidates, + ReasonOverdue, ReasonOverdueDay, ReasonOverdueDays, ReasonToday, ReasonTomorrow, + ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale, + HabitWeekday, HabitWeekdaySame, HabitWeekdayNone, + HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone, + HabitOverall, HabitOverallNone, HabitSpanToday, HabitSpanDays, + HabitUnglossed, HabitAt, +} + +// summaryFloor — the literal each key falls back to when the file is unusable. +// These are the exact strings that lived in Go before this file existed. +var summaryFloor = RegisterFloor(map[string]string{ + PlanRestEmpty: "на сегодня больше ничего не запланировано.", + PlanDayEmpty: "на {date} ничего не запланировано.", + PlanDay: "план на {date}: {items}.", + PlanUncertain: "похоже, {line}", + + TasksNone: "задач нет.", + TasksFirst: "сначала: {items}.", + TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.", + + ReasonOverdue: "просрочено", + ReasonOverdueDay: "просрочено на день", + ReasonOverdueDays: "просрочено на {n} дн.", + ReasonToday: "сегодня", + ReasonTomorrow: "завтра", + ReasonInDays: "через {n} дн.", + ReasonImportant: "важно", + ReasonUrgent: "срочно", + ReasonStale: "давно в списке", + + HabitWeekday: "по {day} ты обычно {items}.", + HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", + HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.", + HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {sun}.", + HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям ничего постоянного.", + HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам ничего постоянного.", + HabitWeekendSame: "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", + HabitWeekendNone: "по выходным я пока не вижу у тебя ничего постоянного.", + HabitOverall: "обычно ты {items} — {span}.", + HabitOverallNone: "я ещё не набрала достаточно записей, чтобы говорить о привычках.", + HabitSpanToday: "по записям за сегодня", + HabitSpanDays: "по записям за последние {n} {word}", + HabitUnglossed: "отмечаешь «{key}»", + HabitAt: "{gloss} около {time}", +}) + +// Summaries picks a hand-written Russian summary sentence. Safe for concurrent +// use. +type Summaries struct{ d *Deck } + +// LoadSummaries reads the embedded file. Pass a source to make the picking +// reproducible in tests; nil seeds from the clock. +func LoadSummaries(src rand.Source) (*Summaries, error) { + d, err := Load(summaryJSON, SummarySchemaVersion, summaryKeys, summaryFloor, src) + if err != nil { + return nil, err + } + // The entries that exist to read the aggregate back. A variant without the + // placeholder would summarise the data by dropping it. + for _, req := range []struct{ key, ph string }{ + {PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"}, + {PlanUncertain, "{line}"}, + {TasksFirst, "{items}"}, {TasksCandidates, "{items}"}, + {ReasonOverdueDays, "{n}"}, {ReasonInDays, "{n}"}, + {HabitWeekday, "{day}"}, {HabitWeekday, "{items}"}, + {HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"}, + {HabitWeekdayNone, "{day}"}, + {HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{sun}"}, + {HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"}, + {HabitWeekendSame, "{items}"}, + {HabitOverall, "{items}"}, {HabitOverall, "{span}"}, + {HabitSpanDays, "{n}"}, {HabitSpanDays, "{word}"}, + {HabitUnglossed, "{key}"}, {HabitAt, "{gloss}"}, {HabitAt, "{time}"}, + } { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { + return nil, err + } + } + return &Summaries{d: d}, nil +} + +// deck reads through a nil *Summaries, which is the unloadable-file case. +func (s *Summaries) deck() *Deck { + if s == nil { + return nil + } + return s.d +} + +// Say returns one line for key, with the values filled into the frame. +func (s *Summaries) Say(key string, vars map[string]string) string { + return s.deck().Text(key, vars) +} + +// Variants returns every line the file can produce, for the persona scorer. +func (s *Summaries) Variants() []string { return s.deck().Variants() } + +var ( + summaryOnce sync.Once + summaries *Summaries +) + +// DefaultSummaries returns the shared instance, loading it on first use. A +// broken file logs once and leaves a nil *Summaries, which still answers from +// summaryFloor. +func DefaultSummaries() *Summaries { + summaryOnce.Do(func() { + s, err := LoadSummaries(nil) + if err != nil { + log.Printf("say: summary lines unavailable, using the built-in ones: %v", err) + return + } + summaries = s + }) + return summaries +} + +// S — one summary sentence, the way every caller says it. +func S(key string, vars map[string]string) string { return DefaultSummaries().Say(key, vars) } + +// IsS reports whether text is a line key could have produced, for the tests. +func IsS(key string, vars map[string]string, text string) bool { + return DefaultSummaries().deck().Matches(key, vars, text) +}