From 42d7a39c491cd0c9466c9e06d25f97e81f32f418 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 01:47:52 +0400 Subject: [PATCH] morning, tasks, memory: say the summaries from the file (V-506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/memory/behavior.go | 50 +++++++++++++++---------- internal/memory/behavior_test.go | 8 +++- internal/morning/plan.go | 12 ++++-- internal/phraser/eval/fallbacks_test.go | 9 ++++- internal/say/summary_ru_v1.json | 7 ++-- internal/say/summary_test.go | 49 ++++++++++++++++++++++++ internal/tasks/rank.go | 35 +++++++++-------- 7 files changed, 124 insertions(+), 46 deletions(-) create mode 100644 internal/say/summary_test.go diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go index 8ed3260..20cc614 100644 --- a/internal/memory/behavior.go +++ b/internal/memory/behavior.go @@ -3,8 +3,11 @@ package memory import ( "fmt" "sort" + "strconv" "strings" "time" + + "github.com/kami/maven/internal/say" ) // Behavioural memory — "what do I usually do?" (Vikunja #254). @@ -337,13 +340,14 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string { day := weekdayRU[int(wd)%7] acts := p.Weekly[wd] if len(acts) > 0 { - return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts)) + return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)}) } if len(p.Everyday) > 0 { - return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.", - day, joinActivities(p.Everyday)) + return say.S(say.HabitWeekdaySame, map[string]string{ + "day": day, "items": joinActivities(p.Everyday), + }) } - return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day) + return say.S(say.HabitWeekdayNone, map[string]string{"day": day}) } // FormatWeekendRU reads back what distinguishes Saturday and Sunday. @@ -355,19 +359,17 @@ func (p Profile) FormatWeekendRU() string { sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday] switch { case len(sat) > 0 && len(sun) > 0: - return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.", - joinActivities(sat), joinActivities(sun)) + return say.S(say.HabitWeekendBoth, map[string]string{ + "sat": joinActivities(sat), "sun": joinActivities(sun), + }) case len(sat) > 0: - return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.", - joinActivities(sat)) + return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)}) case len(sun) > 0: - return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.", - joinActivities(sun)) + return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)}) case len(p.Everyday) > 0: - return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.", - joinActivities(p.Everyday)) + return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)}) } - return "по выходным я пока не вижу у тебя ничего постоянного." + return say.S(say.HabitWeekendNone, nil) } // FormatOverallRU reads back the habits that hold across the whole week, and @@ -378,19 +380,23 @@ func (p Profile) FormatWeekendRU() string { // a year of them, and only one of those is worth believing. func (p Profile) FormatOverallRU() string { if len(p.All) == 0 { - return "я ещё не набрала достаточно записей, чтобы говорить о привычках." + return say.S(say.HabitOverallNone, nil) } - return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU()) + return say.S(say.HabitOverall, map[string]string{ + "items": joinActivities(p.All), "span": p.spanRU(), + }) } // spanRU — "по записям за последние N дней", or a vaguer phrase when the window // is too short to name in days. func (p Profile) spanRU() string { if p.Since.IsZero() || !p.Until.After(p.Since) { - return "по записям за сегодня" + return say.S(say.HabitSpanToday, nil) } days := int(p.Until.Sub(p.Since).Hours()/24) + 1 - return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days)) + return say.S(say.HabitSpanDays, map[string]string{ + "n": strconv.Itoa(days), "word": pluralDaysRU(days), + }) } // pluralDaysRU — the Russian count form of "день" for n. @@ -423,14 +429,18 @@ func joinActivities(acts []Activity) string { // come from the model, so an unglossed one is as likely to be // "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is // not a sentence. - gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " ")) + gloss = say.S(say.HabitUnglossed, map[string]string{ + "key": strings.ReplaceAll(a.Key, "_", " "), + }) } if !a.HasTypical { parts[i] = gloss continue } - parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss, - int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60) + parts[i] = say.S(say.HabitAt, map[string]string{ + "gloss": gloss, + "time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60), + }) } if len(parts) == 1 { return parts[0] diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go index e2ee61c..cc75e71 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" "unicode" + + "github.com/kami/maven/internal/say" ) // habitHistory — n weeks of the same weekday, at the given local time. @@ -70,7 +72,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) { if len(p.All) != 0 || len(p.Weekly) != 0 { t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly) } - if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") { + if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) { t.Errorf("empty profile reads %q", got) } } @@ -203,7 +205,9 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) { // A day with nothing of its own says so rather than reciting water as if // Wednesday were the reason for it. wed := p.FormatWeekdayRU(time.Wednesday) - if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") { + if !say.IsS(say.HabitWeekdaySame, map[string]string{ + "day": "средам", "items": "пьёшь воду около 13:30", + }, wed) { t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed) } } diff --git a/internal/morning/plan.go b/internal/morning/plan.go index ba2c699..5129187 100644 --- a/internal/morning/plan.go +++ b/internal/morning/plan.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/kami/maven/internal/say" "github.com/kami/maven/internal/store" ) @@ -164,17 +165,20 @@ func (p Plan) FormatRU() string { // it is over, and saying it was empty is a false statement about a day // he just lived. if p.Rest { - return "на сегодня больше ничего не запланировано." + return say.S(say.PlanRestEmpty, nil) } - return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006")) + return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")}) } parts := make([]string, len(p.Items)) for i, it := range p.Items { line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text) if it.Uncertain { - line = "похоже, " + line + line = say.S(say.PlanUncertain, map[string]string{"line": line}) } parts[i] = line } - return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; ")) + return say.S(say.PlanDay, map[string]string{ + "date": p.Date.Format("02.01.2006"), + "items": strings.Join(parts, "; "), + }) } diff --git a/internal/phraser/eval/fallbacks_test.go b/internal/phraser/eval/fallbacks_test.go index 481776b..eb2f617 100644 --- a/internal/phraser/eval/fallbacks_test.go +++ b/internal/phraser/eval/fallbacks_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/say" ) // 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) } 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") } @@ -51,7 +57,8 @@ func TestFallbackPersona(t *testing.T) { body := v for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}", "{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}", - "{entity}", "{count}", "{word}"} { + "{entity}", "{count}", "{word}", + "{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} { body = strings.ReplaceAll(body, ph, "вода") } for _, r := range RunChecks(Case{}, body, "neutral") { diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json index d4d55a9..c387290 100644 --- a/internal/say/summary_ru_v1.json +++ b/internal/say/summary_ru_v1.json @@ -78,7 +78,8 @@ }, "habit_weekday": { - "variants": ["по {day} ты обычно {items}.", "по {day} у тебя обычно {items}."] + "fixed": true, + "variants": ["по {day} ты обычно {items}."] }, "habit_weekday_same": { "variants": [ @@ -89,7 +90,7 @@ "habit_weekday_none": { "variants": [ "по {day} я пока не вижу у тебя ничего постоянного.", - "по {day} я пока не набрала записей, чтобы говорить о постоянном." + "по {day} у тебя пока ничего постоянного не вижу — записей мало." ] }, "habit_weekend_both": { @@ -110,7 +111,7 @@ "habit_weekend_none": { "variants": [ "по выходным я пока не вижу у тебя ничего постоянного.", - "по выходным я пока не набрала записей, чтобы говорить о постоянном." + "по выходным у тебя пока ничего постоянного не вижу — записей мало." ] }, "habit_overall": { diff --git a/internal/say/summary_test.go b/internal/say/summary_test.go new file mode 100644 index 0000000..be1bd13 --- /dev/null +++ b/internal/say/summary_test.go @@ -0,0 +1,49 @@ +package say + +import ( + "math/rand" + "strings" + "testing" +) + +// The file has to load, and every key the code names has to be in it. +func TestSummariesLoad(t *testing.T) { + s, err := LoadSummaries(rand.NewSource(1)) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, key := range summaryKeys { + if got := s.Say(key, nil); got == "" { + t.Errorf("%s says nothing", key) + } + } +} + +// A nil *Summaries is the unloadable-file case, and it must still speak. The +// habit sentences are the ones that matter here: falling back must not turn +// "I have not seen enough" into silence. +func TestNilSummariesAnswerFromTheFloor(t *testing.T) { + var s *Summaries + if got, want := s.Say(HabitOverallNone, nil), summaryFloor[HabitOverallNone]; got != want { + t.Errorf("got %q, want %q", got, want) + } + if got := s.Say(PlanDay, map[string]string{"date": "03.08.2026", "items": "x"}); !strings.Contains(got, "03.08.2026") { + t.Errorf("the floor dropped the date: %q", got) + } +} + +// The empty cases claim she has not seen enough, never that he has no habits. +// Every variant has to hold that line, since the picker treats them as equals. +func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) { + s, err := LoadSummaries(rand.NewSource(1)) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, key := range []string{HabitWeekdayNone, HabitWeekendNone, HabitOverallNone} { + for _, v := range s.d.file.Entries[key].Variants { + if !strings.Contains(v, "пока") && !strings.Contains(v, "ещё") { + t.Errorf("%s variant %q reads as a fact about him, not as a gap in her records", key, v) + } + } + } +} diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go index e2f88fd..8121102 100644 --- a/internal/tasks/rank.go +++ b/internal/tasks/rank.go @@ -19,8 +19,11 @@ package tasks import ( "fmt" "sort" + "strconv" "strings" "time" + + "github.com/kami/maven/internal/say" ) // Status values, mirroring internal/store so a caller can rank ipc.Task rows @@ -117,21 +120,21 @@ func score(it Item, now time.Time) (float64, string) { bonus = scoreOverdueCap } total += scoreOverdue + bonus - reason = "просрочено" + reason = say.S(say.ReasonOverdue, nil) if late == 1 { - reason = "просрочено на день" + reason = say.S(say.ReasonOverdueDay, nil) } else if late > 1 { - reason = fmt.Sprintf("просрочено на %d дн.", late) + reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)}) } case days == 0: total += scoreDueToday - reason = "сегодня" + reason = say.S(say.ReasonToday, nil) case days == 1: total += scoreDueTomorrow - reason = "завтра" + reason = say.S(say.ReasonTomorrow, nil) case days <= 7: total += scoreDueWeek - reason = fmt.Sprintf("через %d дн.", days) + reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)}) default: total += scoreDueLater } @@ -147,9 +150,9 @@ func score(it Item, now time.Time) (float64, string) { // The rungs get their own words. The reason string is the one place // the ranking explains itself, and reading "важно" back at a task // he flagged "срочно" reports a word he did not say. - reason = "важно" + reason = say.S(say.ReasonImportant, nil) if w >= MaxWeight { - reason = "срочно" + reason = say.S(say.ReasonUrgent, nil) } } } @@ -163,7 +166,7 @@ func score(it Item, now time.Time) (float64, string) { } total += age if reason == "" && weeks >= 2 { - reason = "давно в списке" + reason = say.S(say.ReasonStale, nil) } } } @@ -210,22 +213,22 @@ func FormatRU(ranked []Ranked) string { } } if len(open) == 0 && len(cands) == 0 { - return "задач нет." + return say.S(say.TasksNone, nil) } var b strings.Builder if len(open) > 0 { - b.WriteString("сначала: ") - b.WriteString(joinRU(open, SpokenLimit, true)) - b.WriteString(".") + b.WriteString(say.S(say.TasksFirst, map[string]string{ + "items": joinRU(open, SpokenLimit, true), + })) } if len(cands) > 0 { if b.Len() > 0 { b.WriteString(" ") } - b.WriteString("ещё я нашла, но ты не подтвердил: ") - b.WriteString(joinRU(cands, SpokenLimit, false)) - b.WriteString(".") + b.WriteString(say.S(say.TasksCandidates, map[string]string{ + "items": joinRU(cands, SpokenLimit, false), + })) } return b.String() }