morning, tasks, memory: say the summaries from the file (V-506)

The three callers now read their sentences out of summary_ru_v1.json: the
plan lines in morning.Plan.FormatRU, the list and reason words in
tasks.FormatRU, and the habit readouts in memory.Profile.

Two behaviour_test assertions moved from substring to say.IsS, because the
habit gaps have variants now and a substring pins one of them. The
"по {day} у тебя обычно" variant was dropped on sight: the activities are
verbs, so it read "у тебя обычно тренируешься".

The persona scorer covers the family, and a new test asserts every gap
variant still says she has not seen enough rather than that he has nothing.
This commit is contained in:
2026-08-04 01:47:52 +04:00
parent bad3fa4035
commit 42d7a39c49
7 changed files with 124 additions and 46 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, "; "),
})
} }
+8 -1
View File
@@ -6,6 +6,7 @@ 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 family on the // TestFallbackPersona scores every line in every hand-written family on the
@@ -43,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")
} }
@@ -51,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") {
+4 -3
View File
@@ -78,7 +78,8 @@
}, },
"habit_weekday": { "habit_weekday": {
"variants": ["по {day} ты обычно {items}.", "по {day} у тебя обычно {items}."] "fixed": true,
"variants": ["по {day} ты обычно {items}."]
}, },
"habit_weekday_same": { "habit_weekday_same": {
"variants": [ "variants": [
@@ -89,7 +90,7 @@
"habit_weekday_none": { "habit_weekday_none": {
"variants": [ "variants": [
"по {day} я пока не вижу у тебя ничего постоянного.", "по {day} я пока не вижу у тебя ничего постоянного.",
"по {day} я пока не набрала записей, чтобы говорить о постоянном." "по {day} у тебя пока ничего постоянного не вижу — записей мало."
] ]
}, },
"habit_weekend_both": { "habit_weekend_both": {
@@ -110,7 +111,7 @@
"habit_weekend_none": { "habit_weekend_none": {
"variants": [ "variants": [
"по выходным я пока не вижу у тебя ничего постоянного.", "по выходным я пока не вижу у тебя ничего постоянного.",
"по выходным я пока не набрала записей, чтобы говорить о постоянном." "по выходным у тебя пока ничего постоянного не вижу — записей мало."
] ]
}, },
"habit_overall": { "habit_overall": {
+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()
} }