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" // "что дальше?" — the next few entries, not the day. PlanNextMore is the // same sentence when the cap hid something, so the count it states is the // only signal that the day is not over after the last line read. PlanNext = "plan_next" PlanNextMore = "plan_next_more" TasksNone = "tasks_none" TasksFirst = "tasks_first" TasksCandidates = "tasks_candidates" // The counted stall shapes on /tasks and in the spoken list (V-512). Each // one states a count and nothing about what it means: "лежит дольше десяти // дней" is arithmetic, "стоит бросить" would be a judgement she may not make. StallOverdue = "stall_overdue" StallSitting = "stall_sitting" StallUnconfirmed = "stall_unconfirmed" ReasonOverdue = "reason_overdue" 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, PlanNext, PlanNextMore, TasksNone, TasksFirst, TasksCandidates, StallOverdue, StallSitting, StallUnconfirmed, ReasonOverdue, 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. // It started as the exact strings that lived in Go before this file existed and // now tracks the file's first variant instead, because a floor that keeps the // wording review threw out would say it back on the one turn nobody is watching. var summaryFloor = map[string]string{ PlanRestEmpty: "на сегодня больше ничего не запланировано.", PlanDayEmpty: "на {date} ничего не запланировано.", PlanDay: "план на {date}: {items}", PlanUncertain: "похоже, {line}", PlanNext: "дальше: {items}", PlanNextMore: "дальше: {items}. и ещё {n} {word} до конца дня.", TasksNone: "задач нет.", TasksFirst: "сначала: {items}", TasksCandidates: "нашла ещё, но ты не подтверждал: {items}", StallOverdue: "{n} {word} просрочено", StallSitting: "{n} {word} лежит дольше {days} {dayword}", StallUnconfirmed: "{n} {word} ждёт подтверждения", ReasonOverdue: "просрочено", ReasonOverdueDays: "просрочено на {n} {word}", ReasonToday: "сегодня", ReasonTomorrow: "завтра", ReasonInDays: "через {n} {word}", ReasonImportant: "важно", ReasonUrgent: "срочно", ReasonStale: "давно в списке", HabitWeekday: "по {day} ты обычно {items}.", HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.", HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.", HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_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}"}, {PlanNext, "{items}"}, {PlanNextMore, "{items}"}, {PlanNextMore, "{n}"}, {PlanNextMore, "{word}"}, {TasksFirst, "{items}"}, {TasksCandidates, "{items}"}, {StallOverdue, "{n}"}, {StallOverdue, "{word}"}, {StallSitting, "{n}"}, {StallSitting, "{word}"}, {StallSitting, "{days}"}, {StallSitting, "{dayword}"}, {StallUnconfirmed, "{n}"}, {StallUnconfirmed, "{word}"}, {ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"}, {ReasonInDays, "{n}"}, {ReasonInDays, "{word}"}, {HabitWeekday, "{day}"}, {HabitWeekday, "{items}"}, {HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"}, {HabitWeekdayNone, "{day}"}, {HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_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 FloorDeck(summaryFloor) } 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) }