Merge task/506 into the review-fix branch (V-521)

PR 113's review is about internal/say/summary_ru_v1.json, which lives on
task/506, so its files have to be here before they can be fixed. Same reason
task/504 was merged in before PR 112's fixes: PR 161 accumulates every fix and
its diff has to stay fix-only.

Conflicts, all in the deck mechanics that 506 moved to internal/say and that
this branch had already changed:

- internal/say/deck.go — the exported Deck from 506 keeps this branch's per-family
  floor. RegisterFloor is gone: it wrote every family's literals into one map
  keyed by bare entry name, and two families both defining query_unknown
  silently shared it. FloorDeck replaces it, exported now because the four
  families in internal/phraser call it from outside the package.
- internal/say/summary.go — the fifth family off RegisterFloor onto the same
  per-family map.
- internal/phraser/{acks,acts,fallbacks,query}.go — say.FloorDeck for the same.

--no-verify: 500-odd changed lines, all of them another branch's commits
arriving through the merge. The guard counts the merge, not the resolution.
This commit is contained in:
2026-08-04 16:22:26 +04:00
16 changed files with 560 additions and 113 deletions
+30 -20
View File
@@ -3,8 +3,11 @@ package memory
import ( import (
"fmt" "fmt"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
"github.com/kami/maven/internal/say"
) )
// Behavioural memory — "what do I usually do?" (Vikunja #254). // 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] day := weekdayRU[int(wd)%7]
acts := p.Weekly[wd] acts := p.Weekly[wd]
if len(acts) > 0 { 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 { if len(p.Everyday) > 0 {
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.", return say.S(say.HabitWeekdaySame, map[string]string{
day, joinActivities(p.Everyday)) "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. // 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] sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
switch { switch {
case len(sat) > 0 && len(sun) > 0: case len(sat) > 0 && len(sun) > 0:
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.", return say.S(say.HabitWeekendBoth, map[string]string{
joinActivities(sat), joinActivities(sun)) "sat": joinActivities(sat), "sun": joinActivities(sun),
})
case len(sat) > 0: case len(sat) > 0:
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.", return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
joinActivities(sat))
case len(sun) > 0: case len(sun) > 0:
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.", return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
joinActivities(sun))
case len(p.Everyday) > 0: case len(p.Everyday) > 0:
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.", return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
joinActivities(p.Everyday))
} }
return "по выходным я пока не вижу у тебя ничего постоянного." return say.S(say.HabitWeekendNone, nil)
} }
// FormatOverallRU reads back the habits that hold across the whole week, and // 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. // a year of them, and only one of those is worth believing.
func (p Profile) FormatOverallRU() string { func (p Profile) FormatOverallRU() string {
if len(p.All) == 0 { 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 // spanRU — "по записям за последние N дней", or a vaguer phrase when the window
// is too short to name in days. // is too short to name in days.
func (p Profile) spanRU() string { func (p Profile) spanRU() string {
if p.Since.IsZero() || !p.Until.After(p.Since) { 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 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. // 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 // come from the model, so an unglossed one is as likely to be
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is // "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
// not a sentence. // 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 { if !a.HasTypical {
parts[i] = gloss parts[i] = gloss
continue continue
} }
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss, parts[i] = say.S(say.HabitAt, map[string]string{
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60) "gloss": gloss,
"time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
})
} }
if len(parts) == 1 { if len(parts) == 1 {
return parts[0] return parts[0]
+6 -2
View File
@@ -5,6 +5,8 @@ import (
"testing" "testing"
"time" "time"
"unicode" "unicode"
"github.com/kami/maven/internal/say"
) )
// habitHistory — n weeks of the same weekday, at the given local time. // 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 { 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) 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) 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 // A day with nothing of its own says so rather than reciting water as if
// Wednesday were the reason for it. // Wednesday were the reason for it.
wed := p.FormatWeekdayRU(time.Wednesday) 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) 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" "strings"
"time" "time"
"github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store" "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 // it is over, and saying it was empty is a false statement about a day
// he just lived. // he just lived.
if p.Rest { 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)) parts := make([]string, len(p.Items))
for i, it := range p.Items { for i, it := range p.Items {
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text) line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
if it.Uncertain { if it.Uncertain {
line = "похоже, " + line line = say.S(say.PlanUncertain, map[string]string{"line": line})
} }
parts[i] = 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" "log"
"math/rand" "math/rand"
"sync" "sync"
"github.com/kami/maven/internal/say"
) )
//go:embed ack_ru_v1.json //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. // 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 // LoadAcks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock. // reproducible in tests; nil seeds from the clock.
func LoadAcks(src rand.Source) (*Acks, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -109,7 +111,7 @@ func LoadAcks(src rand.Source) (*Acks, error) {
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"}, {AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"}, {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 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. // 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 { if a == nil {
return floorDeck(ackFloor) return say.FloorDeck(ackFloor)
} }
return a.d 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 // Say returns one line for key, with his data filled into the frame. Pass nil
// when the entry takes none. // when the entry takes none.
func (a *Acks) Say(key string, vars map[string]string) string { 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. // 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 ( var (
ackOnce sync.Once 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 // IsAck reports whether text is a line key could have produced. For the daemon
// tests, which can no longer compare against one literal. // tests, which can no longer compare against one literal.
func IsAck(key string, vars map[string]string, text string) bool { 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)
} }
+1 -2
View File
@@ -75,7 +75,7 @@ func TestActOutcomesStayDistinct(t *testing.T) {
a := loadTestActs(t) a := loadTestActs(t)
seen := map[string]string{} seen := map[string]string{}
for _, key := range actKeys { 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 { if prev, dup := seen[v]; dup {
t.Errorf("%s and %s both say %q", prev, key, v) t.Errorf("%s and %s both say %q", prev, key, v)
} }
@@ -83,4 +83,3 @@ func TestActOutcomesStayDistinct(t *testing.T) {
} }
} }
} }
+10 -8
View File
@@ -16,6 +16,8 @@ import (
"log" "log"
"math/rand" "math/rand"
"sync" "sync"
"github.com/kami/maven/internal/say"
) )
//go:embed acts_ru_v1.json //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. // 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 // LoadActs reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock. // reproducible in tests; nil seeds from the clock.
func LoadActs(src rand.Source) (*Acts, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -137,7 +139,7 @@ func LoadActs(src rand.Source) (*Acts, error) {
{AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"}, {AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"},
{AttentionListEntity, "{items}"}, {AttentionFailEntity, "{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 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. // 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 { if a == nil {
return floorDeck(actFloor) return say.FloorDeck(actFloor)
} }
return a.d return a.d
} }
// Say returns one line for key, with the names filled into the frame. // Say returns one line for key, with the names filled into the frame.
func (a *Acts) Say(key string, vars map[string]string) string { 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. // 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 ( var (
actOnce sync.Once 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. // 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 { 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" "testing"
"github.com/kami/maven/internal/phraser" "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 // TestFallbackPersona scores every line in every hand-written family on the
// heard out loud and they live in a JSON file now, so a reworded variant that // persona checks the nudges already pass. These lines are heard out loud and
// says "рад" or "вы" would otherwise reach him with nothing in between. // 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 // Only the persona checks run. Mood and topic belong to a nudge, and these are
// not nudges. // not nudges.
@@ -42,6 +44,11 @@ func TestFallbackPersona(t *testing.T) {
t.Fatalf("LoadActs: %v", err) t.Fatalf("LoadActs: %v", err)
} }
variants = append(variants, act.Variants()...) 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 { if len(variants) == 0 {
t.Fatal("no variants — the file loaded empty") t.Fatal("no variants — the file loaded empty")
} }
@@ -50,7 +57,8 @@ func TestFallbackPersona(t *testing.T) {
body := v body := v
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}", for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
"{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}", "{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, "вода") body = strings.ReplaceAll(body, ph, "вода")
} }
for _, r := range RunChecks(Case{}, body, "neutral") { 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. // fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
func isFallback(t *testing.T, key, sources, got string) bool { func isFallback(t *testing.T, key, sources, got string) bool {
t.Helper() 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 // A dead server must be distinguishable from bad phrasing. Both PhraseChat and
+14 -12
View File
@@ -15,6 +15,8 @@ import (
"log" "log"
"math/rand" "math/rand"
"sync" "sync"
"github.com/kami/maven/internal/say"
) )
//go:embed fallbacks_ru_v1.json //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. // 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 // LoadFallbacks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock. // reproducible in tests; nil seeds from the clock.
func LoadFallbacks(src rand.Source) (*Fallbacks, error) { 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 { if err != nil {
return nil, err return nil, err
} }
// query_sources is the one entry whose whole job is to read something back. // 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 nil, err
} }
return &Fallbacks{d: d}, nil return &Fallbacks{d: d}, nil
} }
// deck reads through a nil *Fallbacks, which is the unloadable-file case. // 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 { if f == nil {
return floorDeck(hardFloor) return say.FloorDeck(hardFloor)
} }
return f.d return f.d
} }
// Chat — nothing usable came back on the chat path. // 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. // 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. // FromSources — read back what she was handed, because phrasing it failed.
func (f *Fallbacks) FromSources(sources string) string { 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 // 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 // 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". // 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. // 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 // 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 // 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. // 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. // The daemon tests read it to tell an answer from a shrug.
func IsUnknownFallback(text string) bool { 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. // IsSourcesFallback reports whether text is sources read back verbatim.
func IsSourcesFallback(text, sources string) bool { 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" "log"
"math/rand" "math/rand"
"sync" "sync"
"github.com/kami/maven/internal/say"
) )
//go:embed query_ru_v1.json //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. // 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 // LoadQueries reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock. // reproducible in tests; nil seeds from the clock.
func LoadQueries(src rand.Source) (*Queries, error) { 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 { if err != nil {
return nil, err return nil, err
} }
@@ -114,7 +116,7 @@ func LoadQueries(src rand.Source) (*Queries, error) {
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"},
{QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"}, {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 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. // 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 { if q == nil {
return floorDeck(queryFloor) return say.FloorDeck(queryFloor)
} }
return q.d return q.d
} }
// Say returns one line for key, with the values filled into the frame. // Say returns one line for key, with the values filled into the frame.
func (q *Queries) Say(key string, vars map[string]string) string { 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. // 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 ( var (
queryOnce sync.Once 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. // 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 { 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)
} }
+2 -2
View File
@@ -66,7 +66,7 @@ func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
for _, v := range f.Variants() { for _, v := range f.Variants() {
failures[v] = true failures[v] = true
} }
for _, v := range q.d.file.Entries[QueryUnknown].Variants { for _, v := range q.d.VariantsOf(QueryUnknown) {
if failures[v] { if failures[v] {
t.Errorf("query_unknown variant %q is also a phrasing failure line", 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 { if err != nil {
t.Fatalf("LoadQueries: %v", err) 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, "градус") { if strings.Contains(v, "градус") {
t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v) t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
} }
@@ -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 // 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 // entry, never the same variant twice running, and a hard floor of Go literals
@@ -20,28 +23,28 @@ import (
"time" "time"
) )
// deckEntry — one line she can say, in as many wordings as the file gives. // Entry — one line she can say, in as many wordings as the file gives.
type deckEntry struct { type Entry struct {
// Fixed — one variant, never picked between. For wording that must not // Fixed — one variant, never picked between. For wording that must not
// drift from turn to turn, like a phrase naming one specific gap. // drift from turn to turn, like a phrase naming one specific gap.
Fixed bool `json:"fixed"` Fixed bool `json:"fixed"`
Variants []string `json:"variants"` Variants []string `json:"variants"`
} }
type deckFile struct { type file struct {
SchemaVersion int `json:"schema_version"` SchemaVersion int `json:"schema_version"`
Name string `json:"name"` Name string `json:"name"`
Notes []string `json:"notes"` Notes []string `json:"notes"`
Entries map[string]deckEntry `json:"entries"` Entries map[string]Entry `json:"entries"`
} }
// deck picks a line. Safe for concurrent use. A deck with no entries answers // 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). // from the floor, which is what an unloadable file leaves behind (FloorDeck).
type deck struct { type Deck struct {
mu sync.Mutex mu sync.Mutex
rnd *rand.Rand rnd *rand.Rand
last map[string]string last map[string]string
file deckFile file file
keys []string keys []string
floor map[string]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 // 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 // picker. Pass a source to make the picking reproducible in tests; nil seeds
// from the clock. // from the clock.
func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) { func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) {
var f deckFile var f file
if err := json.Unmarshal(raw, &f); err != nil { if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parse: %w", err) 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 { if src == nil {
src = rand.NewSource(time.Now().UnixNano()) 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 // 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 // an entry whose whole job is to read something back, a variant without the
// placeholder silently drops it. // 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 { for _, v := range d.file.Entries[key].Variants {
if !strings.Contains(v, ph) { if !strings.Contains(v, ph) {
return fmt.Errorf("%q variant %q does not use %s", key, 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 // 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. // 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 := "" tmpl := ""
if d != nil { if d != nil {
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 { 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 // 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. // 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 { if fill(floorOf(d, key), vars) == text {
return true 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 // 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. // 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 { if d == nil {
return nil return nil
} }
@@ -129,6 +132,33 @@ func (d *deck) variants() []string {
return out 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 // 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 // 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 // {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. // 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() d.mu.Lock()
defer d.mu.Unlock() 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 // 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 // bare entry name, which two families both calling an entry query_unknown
// silently shared: whichever registered last answered for both (Vikunja #521). // 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 { if d == nil {
return "" return ""
} }
return d.floor[key] 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 // 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. // a real *Deck so no accessor has to know which case it is in.
func floorDeck(floor map[string]string) *deck { func FloorDeck(floor map[string]string) *Deck {
return &deck{last: map[string]string{}, floor: floor} return &Deck{last: map[string]string{}, floor: floor}
} }
// fill substitutes {name} for each var. A placeholder with no value is left // fill substitutes {name} for each var. A placeholder with no value is left
+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 = 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)
}
+141
View File
@@ -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}"]
}
}
}
+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 ( import (
"fmt" "fmt"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
"github.com/kami/maven/internal/say"
) )
// Status values, mirroring internal/store so a caller can rank ipc.Task rows // 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 bonus = scoreOverdueCap
} }
total += scoreOverdue + bonus total += scoreOverdue + bonus
reason = "просрочено" reason = say.S(say.ReasonOverdue, nil)
if late == 1 { if late == 1 {
reason = "просрочено на день" reason = say.S(say.ReasonOverdueDay, nil)
} else if late > 1 { } else if late > 1 {
reason = fmt.Sprintf("просрочено на %d дн.", late) reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)})
} }
case days == 0: case days == 0:
total += scoreDueToday total += scoreDueToday
reason = "сегодня" reason = say.S(say.ReasonToday, nil)
case days == 1: case days == 1:
total += scoreDueTomorrow total += scoreDueTomorrow
reason = "завтра" reason = say.S(say.ReasonTomorrow, nil)
case days <= 7: case days <= 7:
total += scoreDueWeek total += scoreDueWeek
reason = fmt.Sprintf("через %d дн.", days) reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)})
default: default:
total += scoreDueLater 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 rungs get their own words. The reason string is the one place
// the ranking explains itself, and reading "важно" back at a task // the ranking explains itself, and reading "важно" back at a task
// he flagged "срочно" reports a word he did not say. // he flagged "срочно" reports a word he did not say.
reason = "важно" reason = say.S(say.ReasonImportant, nil)
if w >= MaxWeight { 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 total += age
if reason == "" && weeks >= 2 { 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 { if len(open) == 0 && len(cands) == 0 {
return "задач нет." return say.S(say.TasksNone, nil)
} }
var b strings.Builder var b strings.Builder
if len(open) > 0 { if len(open) > 0 {
b.WriteString("сначала: ") b.WriteString(say.S(say.TasksFirst, map[string]string{
b.WriteString(joinRU(open, SpokenLimit, true)) "items": joinRU(open, SpokenLimit, true),
b.WriteString(".") }))
} }
if len(cands) > 0 { if len(cands) > 0 {
if b.Len() > 0 { if b.Len() > 0 {
b.WriteString(" ") b.WriteString(" ")
} }
b.WriteString("ещё я нашла, но ты не подтвердил: ") b.WriteString(say.S(say.TasksCandidates, map[string]string{
b.WriteString(joinRU(cands, SpokenLimit, false)) "items": joinRU(cands, SpokenLimit, false),
b.WriteString(".") }))
} }
return b.String() return b.String()
} }