8c36e7ef84
A clock time read aloud now inflects its nouns and drops its leading zeros.
The old rewrite said "часов" for every hour and "минут" for every minute, so
21:00 came out as "21 часов" and 14:00 as "14 часов 00 минут". Russian
inflects a noun after a numeral and internal/say already owns that rule, so
spokenTime calls say.CountWord for both halves and omits the minutes when
there are none. 21:00 is "21 час", 22:02 is "22 часа 2 минуты", 14:00 is
"14 часов".
internal/persona held its own copies of the twelve months and the seven
weekdays. Both are closed classes and both already live in internal/lexicon,
which is where cmd/mavend/ruwords.go sent its copy. The block now reads
lexicon.Weekday and lexicon.MonthGenitive and carries no word list of its own.
LoadSummaries asserted that stall_sitting keeps its two counts and not the two
count words beside them. A variant dropping {word} or {dayword} would have
loaded and spoken a bare number. Both are required now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
8.4 KiB
Go
209 lines
8.4 KiB
Go
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"
|
|
|
|
// 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,
|
|
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}",
|
|
|
|
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}"},
|
|
{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)
|
|
}
|