diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go index 8ed3260..20cc614 100644 --- a/internal/memory/behavior.go +++ b/internal/memory/behavior.go @@ -3,8 +3,11 @@ package memory import ( "fmt" "sort" + "strconv" "strings" "time" + + "github.com/kami/maven/internal/say" ) // Behavioural memory — "what do I usually do?" (Vikunja #254). @@ -337,13 +340,14 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string { day := weekdayRU[int(wd)%7] acts := p.Weekly[wd] if len(acts) > 0 { - return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts)) + return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)}) } if len(p.Everyday) > 0 { - return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.", - day, joinActivities(p.Everyday)) + return say.S(say.HabitWeekdaySame, map[string]string{ + "day": day, "items": joinActivities(p.Everyday), + }) } - return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day) + return say.S(say.HabitWeekdayNone, map[string]string{"day": day}) } // FormatWeekendRU reads back what distinguishes Saturday and Sunday. @@ -355,19 +359,17 @@ func (p Profile) FormatWeekendRU() string { sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday] switch { case len(sat) > 0 && len(sun) > 0: - return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.", - joinActivities(sat), joinActivities(sun)) + return say.S(say.HabitWeekendBoth, map[string]string{ + "sat": joinActivities(sat), "sun": joinActivities(sun), + }) case len(sat) > 0: - return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.", - joinActivities(sat)) + return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)}) case len(sun) > 0: - return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.", - joinActivities(sun)) + return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)}) case len(p.Everyday) > 0: - return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.", - joinActivities(p.Everyday)) + return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)}) } - return "по выходным я пока не вижу у тебя ничего постоянного." + return say.S(say.HabitWeekendNone, nil) } // FormatOverallRU reads back the habits that hold across the whole week, and @@ -378,19 +380,23 @@ func (p Profile) FormatWeekendRU() string { // a year of them, and only one of those is worth believing. func (p Profile) FormatOverallRU() string { if len(p.All) == 0 { - return "я ещё не набрала достаточно записей, чтобы говорить о привычках." + return say.S(say.HabitOverallNone, nil) } - return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU()) + return say.S(say.HabitOverall, map[string]string{ + "items": joinActivities(p.All), "span": p.spanRU(), + }) } // spanRU — "по записям за последние N дней", or a vaguer phrase when the window // is too short to name in days. func (p Profile) spanRU() string { if p.Since.IsZero() || !p.Until.After(p.Since) { - return "по записям за сегодня" + return say.S(say.HabitSpanToday, nil) } days := int(p.Until.Sub(p.Since).Hours()/24) + 1 - return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days)) + return say.S(say.HabitSpanDays, map[string]string{ + "n": strconv.Itoa(days), "word": pluralDaysRU(days), + }) } // pluralDaysRU — the Russian count form of "день" for n. @@ -423,14 +429,18 @@ func joinActivities(acts []Activity) string { // come from the model, so an unglossed one is as likely to be // "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is // not a sentence. - gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " ")) + gloss = say.S(say.HabitUnglossed, map[string]string{ + "key": strings.ReplaceAll(a.Key, "_", " "), + }) } if !a.HasTypical { parts[i] = gloss continue } - parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss, - int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60) + parts[i] = say.S(say.HabitAt, map[string]string{ + "gloss": gloss, + "time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60), + }) } if len(parts) == 1 { return parts[0] diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go index e2ee61c..cc75e71 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" "unicode" + + "github.com/kami/maven/internal/say" ) // habitHistory — n weeks of the same weekday, at the given local time. @@ -70,7 +72,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) { if len(p.All) != 0 || len(p.Weekly) != 0 { t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly) } - if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") { + if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) { t.Errorf("empty profile reads %q", got) } } @@ -203,7 +205,9 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) { // A day with nothing of its own says so rather than reciting water as if // Wednesday were the reason for it. wed := p.FormatWeekdayRU(time.Wednesday) - if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") { + if !say.IsS(say.HabitWeekdaySame, map[string]string{ + "day": "средам", "items": "пьёшь воду около 13:30", + }, wed) { t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed) } } diff --git a/internal/morning/plan.go b/internal/morning/plan.go index ba2c699..5129187 100644 --- a/internal/morning/plan.go +++ b/internal/morning/plan.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/kami/maven/internal/say" "github.com/kami/maven/internal/store" ) @@ -164,17 +165,20 @@ func (p Plan) FormatRU() string { // it is over, and saying it was empty is a false statement about a day // he just lived. if p.Rest { - return "на сегодня больше ничего не запланировано." + return say.S(say.PlanRestEmpty, nil) } - return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006")) + return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")}) } parts := make([]string, len(p.Items)) for i, it := range p.Items { line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text) if it.Uncertain { - line = "похоже, " + line + line = say.S(say.PlanUncertain, map[string]string{"line": line}) } parts[i] = line } - return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; ")) + return say.S(say.PlanDay, map[string]string{ + "date": p.Date.Format("02.01.2006"), + "items": strings.Join(parts, "; "), + }) } diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go index 4a6f6dd..c860663 100644 --- a/internal/phraser/acks.go +++ b/internal/phraser/acks.go @@ -16,6 +16,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed ack_ru_v1.json @@ -93,12 +95,12 @@ var ackFloor = map[string]string{ } // Acks picks a hand-written Russian acknowledgement. Safe for concurrent use. -type Acks struct{ d *deck } +type Acks struct{ d *say.Deck } // LoadAcks reads the embedded file. Pass a source to make the picking // reproducible in tests; nil seeds from the clock. func LoadAcks(src rand.Source) (*Acks, error) { - d, err := loadDeck(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src) + d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src) if err != nil { return nil, err } @@ -109,7 +111,7 @@ func LoadAcks(src rand.Source) (*Acks, error) { {AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"}, {AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"}, } { - if err := d.requirePlaceholder(req.key, req.ph); err != nil { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { return nil, err } } @@ -117,9 +119,9 @@ func LoadAcks(src rand.Source) (*Acks, error) { } // deck reads through a nil *Acks, which is the unloadable-file case. -func (a *Acks) deck() *deck { +func (a *Acks) deck() *say.Deck { if a == nil { - return floorDeck(ackFloor) + return say.FloorDeck(ackFloor) } return a.d } @@ -127,11 +129,11 @@ func (a *Acks) deck() *deck { // Say returns one line for key, with his data filled into the frame. Pass nil // when the entry takes none. func (a *Acks) Say(key string, vars map[string]string) string { - return a.deck().text(key, vars) + return a.deck().Text(key, vars) } // Variants returns every line the file can produce, for the persona scorer. -func (a *Acks) Variants() []string { return a.deck().variants() } +func (a *Acks) Variants() []string { return a.deck().Variants() } var ( ackOnce sync.Once @@ -158,5 +160,5 @@ func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(k // IsAck reports whether text is a line key could have produced. For the daemon // tests, which can no longer compare against one literal. func IsAck(key string, vars map[string]string, text string) bool { - return DefaultAcks().deck().matches(key, vars, text) + return DefaultAcks().deck().Matches(key, vars, text) } diff --git a/internal/phraser/act_lines_test.go b/internal/phraser/act_lines_test.go index 020698f..9d48054 100644 --- a/internal/phraser/act_lines_test.go +++ b/internal/phraser/act_lines_test.go @@ -75,7 +75,7 @@ func TestActOutcomesStayDistinct(t *testing.T) { a := loadTestActs(t) seen := map[string]string{} for _, key := range actKeys { - for _, v := range a.d.file.Entries[key].Variants { + for _, v := range a.d.VariantsOf(key) { if prev, dup := seen[v]; dup { t.Errorf("%s and %s both say %q", prev, key, v) } @@ -83,4 +83,3 @@ func TestActOutcomesStayDistinct(t *testing.T) { } } } - diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go index b60c9c9..a7a0be5 100644 --- a/internal/phraser/acts.go +++ b/internal/phraser/acts.go @@ -16,6 +16,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed acts_ru_v1.json @@ -114,12 +116,12 @@ var actFloor = map[string]string{ } // Acts picks a hand-written Russian act reply. Safe for concurrent use. -type Acts struct{ d *deck } +type Acts struct{ d *say.Deck } // LoadActs reads the embedded file. Pass a source to make the picking // reproducible in tests; nil seeds from the clock. func LoadActs(src rand.Source) (*Acts, error) { - d, err := loadDeck(actJSON, ActSchemaVersion, actKeys, actFloor, src) + d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src) if err != nil { return nil, err } @@ -137,7 +139,7 @@ func LoadActs(src rand.Source) (*Acts, error) { {AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"}, {AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"}, } { - if err := d.requirePlaceholder(req.key, req.ph); err != nil { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { return nil, err } } @@ -145,20 +147,20 @@ func LoadActs(src rand.Source) (*Acts, error) { } // deck reads through a nil *Acts, which is the unloadable-file case. -func (a *Acts) deck() *deck { +func (a *Acts) deck() *say.Deck { if a == nil { - return floorDeck(actFloor) + return say.FloorDeck(actFloor) } return a.d } // Say returns one line for key, with the names filled into the frame. func (a *Acts) Say(key string, vars map[string]string) string { - return a.deck().text(key, vars) + return a.deck().Text(key, vars) } // Variants returns every line the file can produce, for the persona scorer. -func (a *Acts) Variants() []string { return a.deck().variants() } +func (a *Acts) Variants() []string { return a.deck().Variants() } var ( actOnce sync.Once @@ -184,5 +186,5 @@ func A(key string, vars map[string]string) string { return DefaultActs().Say(key // IsA reports whether text is a line key could have produced, for the tests. func IsA(key string, vars map[string]string, text string) bool { - return DefaultActs().deck().matches(key, vars, text) + return DefaultActs().deck().Matches(key, vars, text) } diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 4498bd6..eb2f617 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -6,11 +6,13 @@ import ( "testing" "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/say" ) -// TestFallbackPersona scores every line in every hand-written line family on the persona checks the nudges already pass. These lines are -// heard out loud and they live in a JSON file now, so a reworded variant that -// says "рад" or "вы" would otherwise reach him with nothing in between. +// TestFallbackPersona scores every line in every hand-written family on the +// persona checks the nudges already pass. These lines are heard out loud and +// they live in a JSON file now, so a reworded variant that says "рад" or "вы" +// would otherwise reach him with nothing in between. // // Only the persona checks run. Mood and topic belong to a nudge, and these are // not nudges. @@ -42,6 +44,11 @@ func TestFallbackPersona(t *testing.T) { t.Fatalf("LoadActs: %v", err) } variants = append(variants, act.Variants()...) + sum, err := say.LoadSummaries(rand.NewSource(20260804)) + if err != nil { + t.Fatalf("LoadSummaries: %v", err) + } + variants = append(variants, sum.Variants()...) if len(variants) == 0 { t.Fatal("no variants — the file loaded empty") } @@ -50,7 +57,8 @@ func TestFallbackPersona(t *testing.T) { body := v for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}", "{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}", - "{entity}", "{count}", "{word}"} { + "{entity}", "{count}", "{word}", + "{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} { body = strings.ReplaceAll(body, ph, "вода") } for _, r := range RunChecks(Case{}, body, "neutral") { diff --git a/internal/phraser/failure_test.go b/internal/phraser/failure_test.go index 714acc0..28636fe 100644 --- a/internal/phraser/failure_test.go +++ b/internal/phraser/failure_test.go @@ -13,7 +13,7 @@ import ( // fallbacks_ru_v1.json break Go tests, which is the coupling this file removed. func isFallback(t *testing.T, key, sources, got string) bool { t.Helper() - return DefaultFallbacks().deck().matches(key, map[string]string{"sources": sources}, got) + return DefaultFallbacks().deck().Matches(key, map[string]string{"sources": sources}, got) } // A dead server must be distinguishable from bad phrasing. Both PhraseChat and diff --git a/internal/phraser/fallbacks.go b/internal/phraser/fallbacks.go index 2124242..6876fcf 100644 --- a/internal/phraser/fallbacks.go +++ b/internal/phraser/fallbacks.go @@ -15,6 +15,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed fallbacks_ru_v1.json @@ -47,48 +49,48 @@ var hardFloor = map[string]string{ } // Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use. -type Fallbacks struct{ d *deck } +type Fallbacks struct{ d *say.Deck } // LoadFallbacks reads the embedded file. Pass a source to make the picking // reproducible in tests; nil seeds from the clock. func LoadFallbacks(src rand.Source) (*Fallbacks, error) { - d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src) + d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src) if err != nil { return nil, err } // query_sources is the one entry whose whole job is to read something back. - if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil { + if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil { return nil, err } return &Fallbacks{d: d}, nil } // deck reads through a nil *Fallbacks, which is the unloadable-file case. -func (f *Fallbacks) deck() *deck { +func (f *Fallbacks) deck() *say.Deck { if f == nil { - return floorDeck(hardFloor) + return say.FloorDeck(hardFloor) } return f.d } // Chat — nothing usable came back on the chat path. -func (f *Fallbacks) Chat() string { return f.deck().text(fbChat, nil) } +func (f *Fallbacks) Chat() string { return f.deck().Text(fbChat, nil) } // Unknown — a question she cannot answer and will not guess at. -func (f *Fallbacks) Unknown() string { return f.deck().text(fbQueryUnknown, nil) } +func (f *Fallbacks) Unknown() string { return f.deck().Text(fbQueryUnknown, nil) } // FromSources — read back what she was handed, because phrasing it failed. func (f *Fallbacks) FromSources(sources string) string { - return f.deck().text(fbQuerySources, map[string]string{"sources": sources}) + return f.deck().Text(fbQuerySources, map[string]string{"sources": sources}) } // WorldGap — the world model is the one configured to answer and it is not // answering. Fixed wording: it names a specific gap, and a variant set here // would let "the big model is asleep" drift into "I don't know". -func (f *Fallbacks) WorldGap() string { return f.deck().text(fbWorldGap, nil) } +func (f *Fallbacks) WorldGap() string { return f.deck().Text(fbWorldGap, nil) } // Variants returns every line the file can produce, for the persona scorer. -func (f *Fallbacks) Variants() []string { return f.deck().variants() } +func (f *Fallbacks) Variants() []string { return f.deck().Variants() } // The process-wide instance. Package-level because these lines are needed on // paths that have no phraser to hand — cmd/mavend names the world gap without @@ -129,10 +131,10 @@ func WorldGap() string { return DefaultFallbacks().WorldGap() } // IsUnknownFallback reports whether text is one of her "I do not know" lines. // The daemon tests read it to tell an answer from a shrug. func IsUnknownFallback(text string) bool { - return DefaultFallbacks().deck().matches(fbQueryUnknown, nil, text) + return DefaultFallbacks().deck().Matches(fbQueryUnknown, nil, text) } // IsSourcesFallback reports whether text is sources read back verbatim. func IsSourcesFallback(text, sources string) bool { - return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text) + return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text) } diff --git a/internal/phraser/query.go b/internal/phraser/query.go index aefbb44..81dd1a7 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -16,6 +16,8 @@ import ( "log" "math/rand" "sync" + + "github.com/kami/maven/internal/say" ) //go:embed query_ru_v1.json @@ -97,12 +99,12 @@ var queryFloor = map[string]string{ } // Queries picks a hand-written Russian query line. Safe for concurrent use. -type Queries struct{ d *deck } +type Queries struct{ d *say.Deck } // LoadQueries reads the embedded file. Pass a source to make the picking // reproducible in tests; nil seeds from the clock. func LoadQueries(src rand.Source) (*Queries, error) { - d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src) + d, err := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src) if err != nil { return nil, err } @@ -114,7 +116,7 @@ func LoadQueries(src rand.Source) (*Queries, error) { {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"}, } { - if err := d.requirePlaceholder(req.key, req.ph); err != nil { + if err := d.RequirePlaceholder(req.key, req.ph); err != nil { return nil, err } } @@ -122,20 +124,20 @@ func LoadQueries(src rand.Source) (*Queries, error) { } // deck reads through a nil *Queries, which is the unloadable-file case. -func (q *Queries) deck() *deck { +func (q *Queries) deck() *say.Deck { if q == nil { - return floorDeck(queryFloor) + return say.FloorDeck(queryFloor) } return q.d } // Say returns one line for key, with the values filled into the frame. func (q *Queries) Say(key string, vars map[string]string) string { - return q.deck().text(key, vars) + return q.deck().Text(key, vars) } // Variants returns every line the file can produce, for the persona scorer. -func (q *Queries) Variants() []string { return q.deck().variants() } +func (q *Queries) Variants() []string { return q.deck().Variants() } var ( queryOnce sync.Once @@ -161,5 +163,5 @@ func Q(key string, vars map[string]string) string { return DefaultQueries().Say( // IsQ reports whether text is a line key could have produced, for the tests. func IsQ(key string, vars map[string]string, text string) bool { - return DefaultQueries().deck().matches(key, vars, text) + return DefaultQueries().deck().Matches(key, vars, text) } diff --git a/internal/phraser/query_lines_test.go b/internal/phraser/query_lines_test.go index 17302dd..4ccde68 100644 --- a/internal/phraser/query_lines_test.go +++ b/internal/phraser/query_lines_test.go @@ -66,7 +66,7 @@ func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) { for _, v := range f.Variants() { failures[v] = true } - for _, v := range q.d.file.Entries[QueryUnknown].Variants { + for _, v := range q.d.VariantsOf(QueryUnknown) { if failures[v] { t.Errorf("query_unknown variant %q is also a phrasing failure line", v) } @@ -80,7 +80,7 @@ func TestWeatherLineCountsWithTheHelper(t *testing.T) { if err != nil { t.Fatalf("LoadQueries: %v", err) } - for _, v := range q.d.file.Entries[QueryWeatherNow].Variants { + for _, v := range q.d.VariantsOf(QueryWeatherNow) { if strings.Contains(v, "градус") { t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v) } diff --git a/internal/phraser/deck.go b/internal/say/deck.go similarity index 72% rename from internal/phraser/deck.go rename to internal/say/deck.go index 9136775..7d88a67 100644 --- a/internal/phraser/deck.go +++ b/internal/say/deck.go @@ -1,6 +1,9 @@ -package phraser +package say -// deck — the mechanics every family of hand-written Russian lines shares. +// Package say holds the mechanics every family of hand-written Russian lines +// shares. The families themselves live next to the code that speaks them. +// +// Deck — the mechanics every family of hand-written Russian lines shares. // // A family is one embedded JSON file: schema-versioned, several variants per // entry, never the same variant twice running, and a hard floor of Go literals @@ -20,28 +23,28 @@ import ( "time" ) -// deckEntry — one line she can say, in as many wordings as the file gives. -type deckEntry struct { +// Entry — one line she can say, in as many wordings as the file gives. +type Entry struct { // Fixed — one variant, never picked between. For wording that must not // drift from turn to turn, like a phrase naming one specific gap. Fixed bool `json:"fixed"` Variants []string `json:"variants"` } -type deckFile struct { - SchemaVersion int `json:"schema_version"` - Name string `json:"name"` - Notes []string `json:"notes"` - Entries map[string]deckEntry `json:"entries"` +type file struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + Notes []string `json:"notes"` + Entries map[string]Entry `json:"entries"` } -// deck picks a line. Safe for concurrent use. A deck with no entries answers -// from the floor, which is what an unloadable file leaves behind (floorDeck). -type deck struct { +// Deck picks a line. Safe for concurrent use. A Deck with no entries answers +// from the floor, which is what an unloadable file leaves behind (FloorDeck). +type Deck struct { mu sync.Mutex rnd *rand.Rand last map[string]string - file deckFile + file file keys []string floor map[string]string } @@ -49,8 +52,8 @@ type deck struct { // loadDeck parses raw, checks the version and every required key, and seeds the // picker. Pass a source to make the picking reproducible in tests; nil seeds // from the clock. -func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) { - var f deckFile +func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) { + var f file if err := json.Unmarshal(raw, &f); err != nil { return nil, fmt.Errorf("parse: %w", err) } @@ -69,13 +72,13 @@ func loadDeck(raw []byte, version int, keys []string, floor map[string]string, s if src == nil { src = rand.NewSource(time.Now().UnixNano()) } - return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil + return &Deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil } // requirePlaceholder fails the load when a variant of key does not use ph. For // an entry whose whole job is to read something back, a variant without the // placeholder silently drops it. -func (d *deck) requirePlaceholder(key, ph string) error { +func (d *Deck) RequirePlaceholder(key, ph string) error { for _, v := range d.file.Entries[key].Variants { if !strings.Contains(v, ph) { return fmt.Errorf("%q variant %q does not use %s", key, v, ph) @@ -86,7 +89,7 @@ func (d *deck) requirePlaceholder(key, ph string) error { // text returns one variant for key with the placeholders filled in. A nil // receiver answers from the floor, so no caller checks whether the file loaded. -func (d *deck) text(key string, vars map[string]string) string { +func (d *Deck) Text(key string, vars map[string]string) string { tmpl := "" if d != nil { if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 { @@ -101,7 +104,7 @@ func (d *deck) text(key string, vars map[string]string) string { // matches reports whether text is a line key could have produced. A caller that // has to recognise one of these lines cannot compare against a literal any more. -func (d *deck) matches(key string, vars map[string]string, text string) bool { +func (d *Deck) Matches(key string, vars map[string]string, text string) bool { if fill(floorOf(d, key), vars) == text { return true } @@ -118,7 +121,7 @@ func (d *deck) matches(key string, vars map[string]string, text string) bool { // variants returns every line the file can produce, in key order, for the // persona scorer. Stable order so a failure names the same variant twice. -func (d *deck) variants() []string { +func (d *Deck) Variants() []string { if d == nil { return nil } @@ -129,6 +132,33 @@ func (d *deck) variants() []string { return out } +// VariantsOf returns the wordings the file gives for one key, for a test that +// has something to say about every one of them. +func (d *Deck) VariantsOf(key string) []string { + if d == nil { + return nil + } + return d.file.Entries[key].Variants +} + +// UnfixedSingles lists the keys with exactly one variant that are not marked +// fixed. Nothing breaks on one — the picker has nothing to pick either way — but +// the flag is what a reader goes by, and parallel entries disagreeing about it +// is how a family stops being readable. Load already rejects the other half of +// the rule, fixed with more than one variant, so this is the pair to it. +func (d *Deck) UnfixedSingles() []string { + if d == nil { + return nil + } + var out []string + for _, k := range d.keys { + if e := d.file.Entries[k]; len(e.Variants) == 1 && !e.Fixed { + out = append(out, k) + } + } + return out +} + // fillable narrows variants to the ones this call can actually say, which is // the rule an optional placeholder needs: a caller with nothing to put in // {tail} must not be handed a variant that has one. Two passes, because both @@ -185,7 +215,7 @@ func placeholders(tmpl string) []string { } // pick chooses at random, skipping whatever this entry said last time. -func (d *deck) pick(key string, variants []string) string { +func (d *Deck) pick(key string, variants []string) string { d.mu.Lock() defer d.mu.Unlock() @@ -211,18 +241,18 @@ func (d *deck) pick(key string, variants []string) string { // lookup ever crosses families. It used to go through one global map keyed by // bare entry name, which two families both calling an entry query_unknown // silently shared: whichever registered last answered for both (Vikunja #521). -func floorOf(d *deck, key string) string { +func floorOf(d *Deck, key string) string { if d == nil { return "" } return d.floor[key] } -// floorDeck — the deck a family falls back to when its file will not load. It +// FloorDeck — the deck a family falls back to when its file will not load. It // has no entries, so every read drops through to the floor literals, and it is -// a real *deck so no accessor has to know which case it is in. -func floorDeck(floor map[string]string) *deck { - return &deck{last: map[string]string{}, floor: floor} +// a real *Deck so no accessor has to know which case it is in. +func FloorDeck(floor map[string]string) *Deck { + return &Deck{last: map[string]string{}, floor: floor} } // fill substitutes {name} for each var. A placeholder with no value is left diff --git a/internal/say/summary.go b/internal/say/summary.go new file mode 100644 index 0000000..ac70341 --- /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 = 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 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) +} diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json new file mode 100644 index 0000000..c387290 --- /dev/null +++ b/internal/say/summary_ru_v1.json @@ -0,0 +1,141 @@ +{ + "schema_version": 1, + "name": "russian summary sentences v1", + "notes": [ + "The sentences she builds around aggregated data: the morning plan, the ranked task list, and the habits she reads back out of behaviour records.", + "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", + "\"I have not seen enough yet\" and \"there is nothing there\" are different claims, and the habit entries keep the first. Three days of taps do not license a statement about his life, so habit_*_none says she does not see a pattern, never that he has no habits.", + "Placeholders: {date} a formatted date, {items} a joined list, {day} a weekday name, {span} the stretch of records a habit claim rests on, {n} a count, {word} a Russian count form built Go-side.", + "The count forms (день/дня/дней, задача/задачи/задач) are morphology, not copy. They stay in Go and arrive here through {word}.", + "fixed: true means exactly one variant and no picking. Used where the wording is the distinction: the day that is over versus the day that was empty, and the list of tasks he never confirmed." + ], + "entries": { + "plan_rest_empty": { + "fixed": true, + "variants": ["на сегодня больше ничего не запланировано."] + }, + "plan_day_empty": { + "fixed": true, + "variants": ["на {date} ничего не запланировано."] + }, + "plan_day": { + "fixed": true, + "variants": ["план на {date}: {items}."] + }, + "plan_uncertain": { + "fixed": true, + "variants": ["похоже, {line}"] + }, + + "tasks_none": { + "fixed": true, + "variants": ["задач нет."] + }, + "tasks_first": { + "fixed": true, + "variants": ["сначала: {items}."] + }, + "tasks_candidates": { + "fixed": true, + "variants": ["ещё я нашла, но ты не подтвердил: {items}."] + }, + + "reason_overdue": { + "fixed": true, + "variants": ["просрочено"] + }, + "reason_overdue_day": { + "fixed": true, + "variants": ["просрочено на день"] + }, + "reason_overdue_days": { + "fixed": true, + "variants": ["просрочено на {n} дн."] + }, + "reason_today": { + "fixed": true, + "variants": ["сегодня"] + }, + "reason_tomorrow": { + "fixed": true, + "variants": ["завтра"] + }, + "reason_in_days": { + "fixed": true, + "variants": ["через {n} дн."] + }, + "reason_important": { + "fixed": true, + "variants": ["важно"] + }, + "reason_urgent": { + "fixed": true, + "variants": ["срочно"] + }, + "reason_stale": { + "fixed": true, + "variants": ["давно в списке"] + }, + + "habit_weekday": { + "fixed": true, + "variants": ["по {day} ты обычно {items}."] + }, + "habit_weekday_same": { + "variants": [ + "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", + "по {day} всё как обычно — то же, что и в остальные дни: {items}." + ] + }, + "habit_weekday_none": { + "variants": [ + "по {day} я пока не вижу у тебя ничего постоянного.", + "по {day} у тебя пока ничего постоянного не вижу — записей мало." + ] + }, + "habit_weekend_both": { + "variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."] + }, + "habit_weekend_sat": { + "variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."] + }, + "habit_weekend_sun": { + "variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."] + }, + "habit_weekend_same": { + "variants": [ + "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.", + "по выходным всё как обычно — то же, что и в остальные дни: {items}." + ] + }, + "habit_weekend_none": { + "variants": [ + "по выходным я пока не вижу у тебя ничего постоянного.", + "по выходным у тебя пока ничего постоянного не вижу — записей мало." + ] + }, + "habit_overall": { + "variants": ["обычно ты {items} — {span}."] + }, + "habit_overall_none": { + "variants": [ + "я ещё не набрала достаточно записей, чтобы говорить о привычках.", + "записей пока мало — на привычки я так не сошлюсь." + ] + }, + "habit_span_today": { + "variants": ["по записям за сегодня"] + }, + "habit_span_days": { + "variants": ["по записям за последние {n} {word}"] + }, + "habit_unglossed": { + "fixed": true, + "variants": ["отмечаешь «{key}»"] + }, + "habit_at": { + "fixed": true, + "variants": ["{gloss} около {time}"] + } + } +} diff --git a/internal/say/summary_test.go b/internal/say/summary_test.go new file mode 100644 index 0000000..be1bd13 --- /dev/null +++ b/internal/say/summary_test.go @@ -0,0 +1,49 @@ +package say + +import ( + "math/rand" + "strings" + "testing" +) + +// The file has to load, and every key the code names has to be in it. +func TestSummariesLoad(t *testing.T) { + s, err := LoadSummaries(rand.NewSource(1)) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, key := range summaryKeys { + if got := s.Say(key, nil); got == "" { + t.Errorf("%s says nothing", key) + } + } +} + +// A nil *Summaries is the unloadable-file case, and it must still speak. The +// habit sentences are the ones that matter here: falling back must not turn +// "I have not seen enough" into silence. +func TestNilSummariesAnswerFromTheFloor(t *testing.T) { + var s *Summaries + if got, want := s.Say(HabitOverallNone, nil), summaryFloor[HabitOverallNone]; got != want { + t.Errorf("got %q, want %q", got, want) + } + if got := s.Say(PlanDay, map[string]string{"date": "03.08.2026", "items": "x"}); !strings.Contains(got, "03.08.2026") { + t.Errorf("the floor dropped the date: %q", got) + } +} + +// The empty cases claim she has not seen enough, never that he has no habits. +// Every variant has to hold that line, since the picker treats them as equals. +func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) { + s, err := LoadSummaries(rand.NewSource(1)) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, key := range []string{HabitWeekdayNone, HabitWeekendNone, HabitOverallNone} { + for _, v := range s.d.file.Entries[key].Variants { + if !strings.Contains(v, "пока") && !strings.Contains(v, "ещё") { + t.Errorf("%s variant %q reads as a fact about him, not as a gap in her records", key, v) + } + } + } +} diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go index e2f88fd..8121102 100644 --- a/internal/tasks/rank.go +++ b/internal/tasks/rank.go @@ -19,8 +19,11 @@ package tasks import ( "fmt" "sort" + "strconv" "strings" "time" + + "github.com/kami/maven/internal/say" ) // Status values, mirroring internal/store so a caller can rank ipc.Task rows @@ -117,21 +120,21 @@ func score(it Item, now time.Time) (float64, string) { bonus = scoreOverdueCap } total += scoreOverdue + bonus - reason = "просрочено" + reason = say.S(say.ReasonOverdue, nil) if late == 1 { - reason = "просрочено на день" + reason = say.S(say.ReasonOverdueDay, nil) } else if late > 1 { - reason = fmt.Sprintf("просрочено на %d дн.", late) + reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)}) } case days == 0: total += scoreDueToday - reason = "сегодня" + reason = say.S(say.ReasonToday, nil) case days == 1: total += scoreDueTomorrow - reason = "завтра" + reason = say.S(say.ReasonTomorrow, nil) case days <= 7: total += scoreDueWeek - reason = fmt.Sprintf("через %d дн.", days) + reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)}) default: total += scoreDueLater } @@ -147,9 +150,9 @@ func score(it Item, now time.Time) (float64, string) { // The rungs get their own words. The reason string is the one place // the ranking explains itself, and reading "важно" back at a task // he flagged "срочно" reports a word he did not say. - reason = "важно" + reason = say.S(say.ReasonImportant, nil) if w >= MaxWeight { - reason = "срочно" + reason = say.S(say.ReasonUrgent, nil) } } } @@ -163,7 +166,7 @@ func score(it Item, now time.Time) (float64, string) { } total += age if reason == "" && weeks >= 2 { - reason = "давно в списке" + reason = say.S(say.ReasonStale, nil) } } } @@ -210,22 +213,22 @@ func FormatRU(ranked []Ranked) string { } } if len(open) == 0 && len(cands) == 0 { - return "задач нет." + return say.S(say.TasksNone, nil) } var b strings.Builder if len(open) > 0 { - b.WriteString("сначала: ") - b.WriteString(joinRU(open, SpokenLimit, true)) - b.WriteString(".") + b.WriteString(say.S(say.TasksFirst, map[string]string{ + "items": joinRU(open, SpokenLimit, true), + })) } if len(cands) > 0 { if b.Len() > 0 { b.WriteString(" ") } - b.WriteString("ещё я нашла, но ты не подтвердил: ") - b.WriteString(joinRU(cands, SpokenLimit, false)) - b.WriteString(".") + b.WriteString(say.S(say.TasksCandidates, map[string]string{ + "items": joinRU(cands, SpokenLimit, false), + })) } return b.String() }