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.
This commit is contained in:
2026-08-04 01:44:18 +04:00
parent d819fc09f0
commit bad3fa4035
+191
View File
@@ -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)
}