Strings family 6: summaries and reports into summary_ru_v1.json #113

Closed
claude wants to merge 4 commits from task/506-strings-family-6-summaries-and-reports-i into task/504-strings-family-4-act-and-smart-home-repl
14 changed files with 526 additions and 105 deletions
+30 -20
View File
@@ -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]
+6 -2
View File
@@ -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)
}
}
+8 -4
View File
@@ -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, "; "),
})
}
+10 -8
View File
@@ -16,6 +16,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed ack_ru_v1.json
@@ -65,7 +67,7 @@ var ackKeys = []string{
// ackFloor — 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 ackFloor = registerFloor(map[string]string{
var ackFloor = say.RegisterFloor(map[string]string{
AckFact: "записала факт.",
AckFactKey: "отметила: {key}",
AckFactValue: "отметила: {key} = {value}",
@@ -93,12 +95,12 @@ var ackFloor = registerFloor(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,7 +119,7 @@ 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 nil
}
@@ -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)
}
+10 -8
View File
@@ -16,6 +16,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed acts_ru_v1.json
@@ -74,7 +76,7 @@ var actKeys = []string{
// actFloor — 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 actFloor = registerFloor(map[string]string{
var actFloor = say.RegisterFloor(map[string]string{
ActDone: "готово.",
ActDoneOut: "готово: {out}",
ActDoneEntity: "команда выполнена для {name}.",
@@ -112,12 +114,12 @@ var actFloor = registerFloor(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
}
@@ -133,7 +135,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
}
}
@@ -141,7 +143,7 @@ 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 nil
}
@@ -150,11 +152,11 @@ func (a *Acts) deck() *deck {
// 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
@@ -180,5 +182,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)
}
+12 -4
View File
@@ -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") {
+1 -1
View File
@@ -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
+14 -12
View File
@@ -15,6 +15,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed fallbacks_ru_v1.json
@@ -39,7 +41,7 @@ var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
// hardFloor — 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 hardFloor = registerFloor(map[string]string{
var hardFloor = say.RegisterFloor(map[string]string{
fbChat: "даже не знаю, что сказать.",
fbQueryUnknown: "не знаю.",
fbQuerySources: "вот что я нашла: {sources}",
@@ -47,24 +49,24 @@ var hardFloor = registerFloor(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 nil
}
@@ -72,23 +74,23 @@ func (f *Fallbacks) deck() *deck {
}
// 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)
}
+10 -8
View File
@@ -16,6 +16,8 @@ import (
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed query_ru_v1.json
@@ -65,7 +67,7 @@ var queryKeys = []string{
// queryFloor — 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 queryFloor = registerFloor(map[string]string{
var queryFloor = say.RegisterFloor(map[string]string{
QueryUnknown: "не знаю.",
QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
@@ -95,12 +97,12 @@ var queryFloor = registerFloor(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
}
@@ -111,7 +113,7 @@ func LoadQueries(src rand.Source) (*Queries, error) {
{QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"},
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {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
}
}
@@ -119,7 +121,7 @@ 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 nil
}
@@ -128,11 +130,11 @@ func (q *Queries) deck() *deck {
// 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
@@ -158,5 +160,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)
}
@@ -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 nil *deck answers from the
// deck picks a line. Safe for concurrent use. A nil *Deck answers from the
// floor, which is what an unloadable file leaves behind.
type deck struct {
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
}
@@ -130,7 +133,7 @@ func (d *deck) variants() []string {
}
// pick chooses at random, skipping whatever this entry said last time.
func (d *deck) pick(key string, e deckEntry) string {
func (d *Deck) pick(key string, e Entry) string {
d.mu.Lock()
defer d.mu.Unlock()
@@ -153,7 +156,7 @@ func (d *deck) pick(key string, e deckEntry) string {
// floorOf reads the Go literal behind key, and works on a nil deck because that
// is exactly the case it exists for. The per-family map is the source of truth.
func floorOf(d *deck, key string) string {
func floorOf(d *Deck, key string) string {
if d != nil && d.floor != nil {
return d.floor[key]
}
@@ -164,7 +167,7 @@ func floorOf(d *deck, key string) string {
// finds them. Families register at init; the keys are namespaced by family.
var deckFloors = map[string]string{}
func registerFloor(floor map[string]string) map[string]string {
func RegisterFloor(floor map[string]string) map[string]string {
for k, v := range floor {
deckFloors[k] = v
}
+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)
}
+141
View File
@@ -0,0 +1,141 @@
{
Review

bugs first — and one of them is mine from the previous file.

json
"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_days": { "fixed": true, "variants": ["просрочено на {n} {word}"] },
"reason_today": { "fixed": true, "variants": ["сегодня"] },
"reason_tomorrow": { "fixed": true, "variants": ["завтра"] },
"reason_in_days": { "fixed": true, "variants": ["через {n} {word}"] },
"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": { "fixed": true, "variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."] },
"habit_weekday_none": { "variants": ["по {day} я пока не вижу ничего постоянного.", "по {day} постоянного пока не вижу — записей мало."] },
"habit_weekend_both": { "fixed": true, "variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."] },
"habit_weekend_sat": { "fixed": true, "variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."] },
"habit_weekend_sun": { "fixed": true, "variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."] },
"habit_weekend_same": { "fixed": true, "variants": ["по выходным всё как обычно — то же, что и в остальные дни: {items}."] },
"habit_weekend_none": { "variants": ["по выходным я пока не вижу ничего постоянного.", "по выходным постоянного пока не вижу — записей мало."] },
"habit_overall": { "fixed": true, "variants": ["обычно ты {items} — {span}."] },
"habit_overall_none": { "variants": ["записей пока мало, про привычки не скажу.", "пока мало записей, чтобы говорить о привычках."] },
"habit_span_today": { "fixed": true, "variants": ["по записям за сегодня"] },
"habit_span_days": { "fixed": true, "variants": ["по записям за последние {n} {word}"] },
"habit_unglossed": { "fixed": true, "variants": ["отмечаешь «{key}»"] },
"habit_at": { "fixed": true, "variants": ["{gloss} около {time}"] }

bug 1 — «дн.» is a written abbreviation and this thing talks. «просрочено на 5 дн.» through TTS reads as garbage or gets spelled out. you already built {word} for exactly this; the reason_* entries just don't use it. switching them also deletes reason_overdue_day as a separate key — «просрочено на 1 день» falls out of the helper, no special case needed.

and my correction: {temp}° in the query file has the same defect. i suggested it to dodge declension, which was right for a text UI and wrong for voice — ° reads as nothing on most engines. use {temp} {word} there too and let the helper handle градус/градуса/градусов. one helper, every count site.

bug 2 — five undeclared placeholders. {line}, {sat}, {sun}, {key}, {gloss}, {time}. worse, habit_weekend_both uses {sat}/{sun} while its two siblings use {items} for the same data — three parallel entries, two schemes, and the caller has to remember which. i renamed to {items_sat}/{items_sun} so the family reads consistently, but pick whatever matches the Go side and get all six into the notes.

bug 3 — fixedness is inconsistent across parallel entries. habit_weekday is fixed, habit_weekend_both isn't, and both have exactly one variant. same for habit_overall, both habit_span_*. per your own note that's a schema violation waiting on a linter — worth one test that asserts single-variant ⇒ fixed, across all five files.

bug 4 — plan_uncertain nests phraser output. «похоже, {line}» prepends onto another rendered string. if {line} ever arrives capitalised or already hedged you get «похоже, На сегодня…» or a double hedge. flat-out fine as a mechanism, fragile as a composition — assert lowercase-first on {line} at the join.

register cuts, same filters as the other files:

«у тебя нет ничего особенного» — that's a verdict on him, not on the data. «всё как обычно» says the same thing about records instead of about his life. this was the only editorialising line in the file.
«на привычки я так не сошлюсь» — bookish. «про привычки не скажу.» is the same claim in your register.
«ещё я нашла, но ты не подтвердил» — word order reads translated; «нашла ещё, но ты не подтверждал» is spoken. imperfective also softens it from an accusation to a note.
dropped «у тебя» where it was filler — «по {day} я пока не вижу ничего постоянного.» carries it already.
trailing periods after {items} removed on plan_day / tasks_first / tasks_candidates, since a joined list arrives with its own punctuation and you were getting «…: сделать X..» — the habit entries keep theirs because there the list is mid-sentence.

the hedging discipline in this file is the best of the five, for what it's worth. «пока не вижу» rather than «нет» is the same distinction as «вроде, оно» and it's load-bearing in a place where a confident wrong claim about his own habits would be genuinely irritating.

bugs first — and one of them is mine from the previous file. json "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_days": { "fixed": true, "variants": ["просрочено на {n} {word}"] }, "reason_today": { "fixed": true, "variants": ["сегодня"] }, "reason_tomorrow": { "fixed": true, "variants": ["завтра"] }, "reason_in_days": { "fixed": true, "variants": ["через {n} {word}"] }, "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": { "fixed": true, "variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."] }, "habit_weekday_none": { "variants": ["по {day} я пока не вижу ничего постоянного.", "по {day} постоянного пока не вижу — записей мало."] }, "habit_weekend_both": { "fixed": true, "variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."] }, "habit_weekend_sat": { "fixed": true, "variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."] }, "habit_weekend_sun": { "fixed": true, "variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."] }, "habit_weekend_same": { "fixed": true, "variants": ["по выходным всё как обычно — то же, что и в остальные дни: {items}."] }, "habit_weekend_none": { "variants": ["по выходным я пока не вижу ничего постоянного.", "по выходным постоянного пока не вижу — записей мало."] }, "habit_overall": { "fixed": true, "variants": ["обычно ты {items} — {span}."] }, "habit_overall_none": { "variants": ["записей пока мало, про привычки не скажу.", "пока мало записей, чтобы говорить о привычках."] }, "habit_span_today": { "fixed": true, "variants": ["по записям за сегодня"] }, "habit_span_days": { "fixed": true, "variants": ["по записям за последние {n} {word}"] }, "habit_unglossed": { "fixed": true, "variants": ["отмечаешь «{key}»"] }, "habit_at": { "fixed": true, "variants": ["{gloss} около {time}"] } bug 1 — «дн.» is a written abbreviation and this thing talks. «просрочено на 5 дн.» through TTS reads as garbage or gets spelled out. you already built {word} for exactly this; the reason_* entries just don't use it. switching them also deletes reason_overdue_day as a separate key — «просрочено на 1 день» falls out of the helper, no special case needed. and my correction: {temp}° in the query file has the same defect. i suggested it to dodge declension, which was right for a text UI and wrong for voice — ° reads as nothing on most engines. use {temp} {word} there too and let the helper handle градус/градуса/градусов. one helper, every count site. bug 2 — five undeclared placeholders. {line}, {sat}, {sun}, {key}, {gloss}, {time}. worse, habit_weekend_both uses {sat}/{sun} while its two siblings use {items} for the same data — three parallel entries, two schemes, and the caller has to remember which. i renamed to {items_sat}/{items_sun} so the family reads consistently, but pick whatever matches the Go side and get all six into the notes. bug 3 — fixedness is inconsistent across parallel entries. habit_weekday is fixed, habit_weekend_both isn't, and both have exactly one variant. same for habit_overall, both habit_span_*. per your own note that's a schema violation waiting on a linter — worth one test that asserts single-variant ⇒ fixed, across all five files. bug 4 — plan_uncertain nests phraser output. «похоже, {line}» prepends onto another rendered string. if {line} ever arrives capitalised or already hedged you get «похоже, На сегодня…» or a double hedge. flat-out fine as a mechanism, fragile as a composition — assert lowercase-first on {line} at the join. register cuts, same filters as the other files: «у тебя нет ничего особенного» — that's a verdict on him, not on the data. «всё как обычно» says the same thing about records instead of about his life. this was the only editorialising line in the file. «на привычки я так не сошлюсь» — bookish. «про привычки не скажу.» is the same claim in your register. «ещё я нашла, но ты не подтвердил» — word order reads translated; «нашла ещё, но ты не подтверждал» is spoken. imperfective also softens it from an accusation to a note. dropped «у тебя» where it was filler — «по {day} я пока не вижу ничего постоянного.» carries it already. trailing periods after {items} removed on plan_day / tasks_first / tasks_candidates, since a joined list arrives with its own punctuation and you were getting «…: сделать X..» — the habit entries keep theirs because there the list is mid-sentence. the hedging discipline in this file is the best of the five, for what it's worth. «пока не вижу» rather than «нет» is the same distinction as «вроде, оно» and it's load-bearing in a place where a confident wrong claim about his own habits would be genuinely irritating.
"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}"]
}
}
}
+49
View File
@@ -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)
}
}
}
}
+19 -16
View File
@@ -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()
}