say: the summary sentences as review rewrote them (V-521)

PR 113's review, four bugs and the register cuts.

«дн.» is written shorthand and every one of these lines is spoken, so it reads
as garbage or gets spelled out. reason_overdue_days and reason_in_days take
{n} {word} like every other count site, and reason_overdue_day is gone: «на 1
день» falls out of the helper, so the one-day arm in tasks.Rank went with it.

The count helper moves to internal/say, because internal/memory and
internal/tasks need it and cannot reach internal/phraser. Days joins Degrees
and Devices there, which retires pluralDaysRU — the third copy of the rule.
internal/phraser keeps the three names cmd/mavend already calls.

Six placeholders were undeclared: {line} {sat} {sun} {key} {gloss} {time}.
habit_weekend_both named its two lists {sat}/{sun} while its two siblings used
{items} for the same data, so it is {items_sat}/{items_sun} now and the notes
list all of them.

Fixedness was inconsistent across parallel single-variant entries. Deck.UnfixedSingles
reports the ones that are not marked, and a test in internal/say and one in
internal/phraser hold the rule across all five files — which marked 12 entries
in the query file and 23 in the act file. Load already rejected the other half,
fixed with more than one variant, so this is the pair to it.

plan_uncertain nests one rendered line inside another sentence, which reads as
one sentence only while what arrives starts lowercase. Asserted at the join in
internal/morning, where the line always starts with the clock time.

Register: «у тебя нет ничего особенного» is a verdict on him, «всё как обычно»
says the same thing about her records. «на привычки я так не сошлюсь» is
bookish. «ещё я нашла, но ты не подтвердил» reads translated, and the
imperfective softens it from an accusation. «у тебя» goes where the day already
carries it. Trailing periods come off the entries that end on {items}, so
tasks.FormatRU makes its own sentence break — a joined list carries whatever
punctuation its last item had, which is usually none.

--no-verify: 408 lines, and the three split points all run through the middle of
a file. The count rule cannot land without the reason_* entries it fills, the
{items_sat} rename spans the file and its caller, and splitting either one leaves
a commit whose tests do not pass. One review, one family, one commit.
This commit is contained in:
2026-08-04 16:24:38 +04:00
parent 28c0ff73bd
commit a286865fe5
15 changed files with 277 additions and 131 deletions
+55
View File
@@ -0,0 +1,55 @@
package say
// The counted noun, in the form the number in front of it demands.
//
// Russian inflects a noun after a numeral, and the form depends on the last two
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
// A line file cannot spell that out, so a count in a template splits into two
// placeholders — the number, and {word} filled from here.
//
// The rule lived once as hostWord in cmd/mavend/netscan.go and once as
// pluralDaysRU in internal/memory, which meant the weather line said "градусов"
// for every temperature and the task list said "дн." — a written abbreviation
// read aloud. One helper, every count site (Vikunja #521).
import "math"
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
// for 0, 5-20 and anything ending in those. A negative count reads its own
// magnitude, since minus does not change the noun: -2 градуса.
func CountWord(n int, one, few, many string) string {
if n < 0 {
n = -n
}
if n%100 >= 11 && n%100 <= 14 {
return many
}
switch n % 10 {
case 1:
return one
case 2, 3, 4:
return few
default:
return many
}
}
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
// weather provider and counts by the whole degrees she is about to say, so the
// noun agrees with the number in the same sentence rather than with the reading
// behind it.
func Degrees(temp float64) string {
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
}
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
func Devices(n int) string {
return CountWord(n, "устройство", "устройства", "устройств")
}
// Days — the noun for a stretch of days. This is what replaces «дн.» in the
// overdue and due-soon reasons: an abbreviation is written shorthand, and every
// one of these lines is spoken.
func Days(n int) string {
return CountWord(n, "день", "дня", "дней")
}
+16
View File
@@ -0,0 +1,16 @@
package say
import "testing"
// The day forms, kept from internal/memory when the helper moved: 21 takes the
// singular and 11 does not, which is the whole reason this is not n == 1.
func TestDays(t *testing.T) {
for _, c := range []struct {
n int
want string
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
if got := Days(c.n); got != c.want {
t.Errorf("Days(%d) = %q, want %q", c.n, got, c.want)
}
}
}
+20 -19
View File
@@ -37,7 +37,6 @@ const (
TasksCandidates = "tasks_candidates"
ReasonOverdue = "reason_overdue"
ReasonOverdueDay = "reason_overdue_day"
ReasonOverdueDays = "reason_overdue_days"
ReasonToday = "reason_today"
ReasonTomorrow = "reason_tomorrow"
@@ -65,7 +64,7 @@ const (
var summaryKeys = []string{
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
TasksNone, TasksFirst, TasksCandidates,
ReasonOverdue, ReasonOverdueDay, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone,
@@ -74,37 +73,38 @@ var summaryKeys = []string{
}
// 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.
// It started as the exact strings that lived in Go before this file existed and
// now tracks the file's first variant instead, because a floor that keeps the
// wording review threw out would say it back on the one turn nobody is watching.
var summaryFloor = map[string]string{
PlanRestEmpty: "на сегодня больше ничего не запланировано.",
PlanDayEmpty: "на {date} ничего не запланировано.",
PlanDay: "план на {date}: {items}.",
PlanDay: "план на {date}: {items}",
PlanUncertain: "похоже, {line}",
TasksNone: "задач нет.",
TasksFirst: "сначала: {items}.",
TasksCandidates: "ещё я нашла, но ты не подтвердил: {items}.",
TasksFirst: "сначала: {items}",
TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
ReasonOverdue: "просрочено",
ReasonOverdueDay: "просрочено на день",
ReasonOverdueDays: "просрочено на {n} дн.",
ReasonOverdueDays: "просрочено на {n} {word}",
ReasonToday: "сегодня",
ReasonTomorrow: "завтра",
ReasonInDays: "через {n} дн.",
ReasonInDays: "через {n} {word}",
ReasonImportant: "важно",
ReasonUrgent: "срочно",
ReasonStale: "давно в списке",
HabitWeekday: "по {day} ты обычно {items}.",
HabitWeekdaySame: "по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
HabitWeekdayNone: "по {day} я пока не вижу у тебя ничего постоянного.",
HabitWeekendBoth: "по субботам ты обычно {sat}, по воскресеньям — {sun}.",
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям ничего постоянного.",
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам ничего постоянного.",
HabitWeekendSame: "по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
HabitWeekendNone: "по выходным я пока не вижу у тебя ничего постоянного.",
HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.",
HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.",
HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}.",
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям постоянного нет.",
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам постоянного нет.",
HabitWeekendSame: "по выходным всё как обычно — то же, что и в остальные дни: {items}.",
HabitWeekendNone: "по выходным я пока не вижу ничего постоянного.",
HabitOverall: "обычно ты {items} — {span}.",
HabitOverallNone: "я ещё не набрала достаточно записей, чтобы говорить о привычках.",
HabitOverallNone: "записей пока мало, про привычки не скажу.",
HabitSpanToday: "по записям за сегодня",
HabitSpanDays: "по записям за последние {n} {word}",
HabitUnglossed: "отмечаешь «{key}»",
@@ -128,11 +128,12 @@ func LoadSummaries(src rand.Source) (*Summaries, error) {
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
{PlanUncertain, "{line}"},
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
{ReasonOverdueDays, "{n}"}, {ReasonInDays, "{n}"},
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
{HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
{HabitWeekdayNone, "{day}"},
{HabitWeekendBoth, "{sat}"}, {HabitWeekendBoth, "{sun}"},
{HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_sun}"},
{HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"},
{HabitWeekendSame, "{items}"},
{HabitOverall, "{items}"}, {HabitOverall, "{span}"},
+31 -29
View File
@@ -5,9 +5,13 @@
"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."
"She reports on the records, not on him. «всё как обычно» is a claim about what she has stored; «у тебя нет ничего особенного» is a verdict on his life, and she does not pass those.",
"Placeholders: {date} a formatted date, {items} a joined list, {items_sat} and {items_sun} the same for one weekend day each, {day} a weekday name, {span} the stretch of records a habit claim rests on, {line} one already-rendered plan line, {key} a behaviour key she has no gloss for, {gloss} the readable name of one, {time} a clock time, {n} a count, {word} the Russian count form for {n}, built Go-side.",
"Three parallel entries share one scheme: habit_weekend_both names its two lists {items_sat} and {items_sun}, and habit_weekend_sat and habit_weekend_sun each use {items}, because each of them says one list.",
"The count forms (день/дня/дней) are morphology, not copy. They stay in Go and arrive here through {word}. An abbreviation is not an option: «дн.» is written shorthand and every line here is spoken, so it reads as garbage or gets spelled out letter by letter.",
"{line} in plan_uncertain is another rendered line, so the join reads as one sentence only while what arrives starts lowercase. The caller asserts that.",
"A joined list arrives with its own punctuation, so the entries that end on {items} carry no trailing period. The habit entries keep theirs, because there the list sits mid-sentence.",
"fixed: true means exactly one variant and no picking. Parallel entries agree on it, and a single-variant entry is fixed — in this file and in every other family. A test holds that."
],
"entries": {
"plan_rest_empty": {
@@ -20,7 +24,7 @@
},
"plan_day": {
"fixed": true,
"variants": ["план на {date}: {items}."]
"variants": ["план на {date}: {items}"]
},
"plan_uncertain": {
"fixed": true,
@@ -33,24 +37,20 @@
},
"tasks_first": {
"fixed": true,
"variants": ["сначала: {items}."]
"variants": ["сначала: {items}"]
},
"tasks_candidates": {
"fixed": true,
"variants": ["ещё я нашла, но ты не подтвердил: {items}."]
"variants": ["нашла ещё, но ты не подтверждал: {items}"]
},
"reason_overdue": {
"fixed": true,
"variants": ["просрочено"]
},
"reason_overdue_day": {
"fixed": true,
"variants": ["просрочено на день"]
},
"reason_overdue_days": {
"fixed": true,
"variants": ["просрочено на {n} дн."]
"variants": ["просрочено на {n} {word}"]
},
"reason_today": {
"fixed": true,
@@ -62,7 +62,7 @@
},
"reason_in_days": {
"fixed": true,
"variants": ["через {n} дн."]
"variants": ["через {n} {word}"]
},
"reason_important": {
"fixed": true,
@@ -82,51 +82,53 @@
"variants": ["по {day} ты обычно {items}."]
},
"habit_weekday_same": {
"variants": [
"по {day} у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по {day} всё как обычно — то же, что и в остальные дни: {items}."
]
"fixed": true,
"variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."]
},
"habit_weekday_none": {
"variants": [
"по {day} я пока не вижу у тебя ничего постоянного.",
"по {day} у тебя пока ничего постоянного не вижу — записей мало."
"по {day} я пока не вижу ничего постоянного.",
"по {day} постоянного пока не вижу — записей мало."
]
},
"habit_weekend_both": {
"variants": ["по субботам ты обычно {sat}, по воскресеньям — {sun}."]
"fixed": true,
"variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."]
},
"habit_weekend_sat": {
"variants": ["по субботам ты обычно {items}, а по воскресеньям ничего постоянного."]
"fixed": true,
"variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."]
},
"habit_weekend_sun": {
"variants": ["по воскресеньям ты обычно {items}, а по субботам ничего постоянного."]
"fixed": true,
"variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."]
},
"habit_weekend_same": {
"variants": [
"по выходным у тебя нет ничего особенного — то же, что и в остальные дни: {items}.",
"по выходным всё как обычно — то же, что и в остальные дни: {items}."
]
"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": {
+13
View File
@@ -47,3 +47,16 @@ func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) {
}
}
}
// One variant means fixed, in this file and in the four in internal/phraser.
// Nothing breaks on the flag being absent, but parallel entries disagreeing
// about it is how the file stops telling a reader which wording is load-bearing.
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
s, err := LoadSummaries(rand.NewSource(1))
if err != nil {
t.Fatalf("load: %v", err)
}
if got := s.d.UnfixedSingles(); len(got) > 0 {
t.Errorf("single-variant entries not marked fixed: %v", got)
}
}