From 8c36e7ef848388fe14791a05af9285247f34bdce Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 03:20:10 +0400 Subject: [PATCH] say, persona, ttsnorm: the clock is spoken and the months have one copy (V-581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A clock time read aloud now inflects its nouns and drops its leading zeros. The old rewrite said "часов" for every hour and "минут" for every minute, so 21:00 came out as "21 часов" and 14:00 as "14 часов 00 минут". Russian inflects a noun after a numeral and internal/say already owns that rule, so spokenTime calls say.CountWord for both halves and omits the minutes when there are none. 21:00 is "21 час", 22:02 is "22 часа 2 минуты", 14:00 is "14 часов". internal/persona held its own copies of the twelve months and the seven weekdays. Both are closed classes and both already live in internal/lexicon, which is where cmd/mavend/ruwords.go sent its copy. The block now reads lexicon.Weekday and lexicon.MonthGenitive and carries no word list of its own. LoadSummaries asserted that stall_sitting keeps its two counts and not the two count words beside them. A variant dropping {word} or {dayword} would have loaded and spoken a bare number. Both are required now. Co-Authored-By: Claude Opus 5 --- internal/persona/persona.go | 14 +++++++------- internal/say/summary.go | 3 ++- internal/ttsnorm/ttsnorm.go | 17 ++++++++++++++++- internal/ttsnorm/ttsnorm_test.go | 7 +++++-- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/internal/persona/persona.go b/internal/persona/persona.go index d3928bb..b15e7f9 100644 --- a/internal/persona/persona.go +++ b/internal/persona/persona.go @@ -17,6 +17,8 @@ import ( "fmt" "strings" "time" + + "github.com/kami/maven/internal/lexicon" ) // Facts — the optional, deployment-specific half of the block. All fields may @@ -34,12 +36,10 @@ type Facts struct { Tools bool // at least one shell act is on the allowlist } -var ruWeekdays = [...]string{"воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"} - -var ruMonths = [...]string{ - "января", "февраля", "марта", "апреля", "мая", "июня", - "июля", "августа", "сентября", "октября", "ноября", "декабря", -} +// The weekday and month names are closed classes and live in internal/lexicon, +// which indexes weekdays from Sunday the way time.Weekday does and months from +// one. This file used to carry its own copies, making four copies of the twelve +// months in the tree after cmd/mavend/ruwords.go gave up its own (Vikunja #525). // Block renders the context block for one turn. Russian even in front of the // English prompts: the rules it states are Russian grammar (ты/тебя, feminine @@ -61,7 +61,7 @@ func (f Facts) Block(now time.Time) string { } b.WriteString(fmt.Sprintf("Сейчас: %s, %d %s %d, %02d:%02d (местное время).\n", - ruWeekdays[int(now.Weekday())], now.Day(), ruMonths[int(now.Month())-1], now.Year(), + lexicon.Weekday(int(now.Weekday())), now.Day(), lexicon.MonthGenitive(int(now.Month())), now.Year(), now.Hour(), now.Minute())) b.WriteString("Умеешь: " + strings.Join(f.can(), "; ") + diff --git a/internal/say/summary.go b/internal/say/summary.go index 0ae0cfa..bc2b575 100644 --- a/internal/say/summary.go +++ b/internal/say/summary.go @@ -141,7 +141,8 @@ func LoadSummaries(src rand.Source) (*Summaries, error) { {PlanUncertain, "{line}"}, {TasksFirst, "{items}"}, {TasksCandidates, "{items}"}, {StallOverdue, "{n}"}, {StallOverdue, "{word}"}, - {StallSitting, "{n}"}, {StallSitting, "{days}"}, + {StallSitting, "{n}"}, {StallSitting, "{word}"}, + {StallSitting, "{days}"}, {StallSitting, "{dayword}"}, {StallUnconfirmed, "{n}"}, {StallUnconfirmed, "{word}"}, {ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"}, {ReasonInDays, "{n}"}, {ReasonInDays, "{word}"}, diff --git a/internal/ttsnorm/ttsnorm.go b/internal/ttsnorm/ttsnorm.go index 5fb37a1..ab97c7e 100644 --- a/internal/ttsnorm/ttsnorm.go +++ b/internal/ttsnorm/ttsnorm.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/say" ) // The month names are a closed class and live in internal/lexicon, 1-indexed, @@ -32,7 +33,7 @@ func Speakable(s string) string { }) s = reTime.ReplaceAllStringFunc(s, func(m string) string { p := reTime.FindStringSubmatch(m) - return p[1] + " часов " + p[2] + " минут" + return spokenTime(mustInt(p[1]), mustInt(p[2])) }) s = reDots.ReplaceAllStringFunc(s, func(m string) string { return strings.Join(strings.Split(m, "."), " точка ") @@ -46,6 +47,20 @@ func Speakable(s string) string { return Pronounce(s) } +// spokenTime reads a clock time the way it is said rather than the way it is +// written. Two things the written form gets wrong out loud. The noun after a +// numeral inflects, so 21:00 is "час" and 22:00 is "часа", where the old +// rewrite said "часов" for every hour and "минут" for every minute. And a +// leading zero is punctuation, not a word: 14:00 is "14 часов" and 9:05 is +// "9 часов 5 минут", never "00 минут" or "05 минут". +func spokenTime(h, m int) string { + out := strconv.Itoa(h) + " " + say.CountWord(h, "час", "часа", "часов") + if m == 0 { + return out + } + return out + " " + strconv.Itoa(m) + " " + say.CountWord(m, "минута", "минуты", "минут") +} + func spokenDate(dd, mm, yyyy string) string { mi, _ := strconv.Atoi(mm) if mi < 1 || mi > 12 { diff --git a/internal/ttsnorm/ttsnorm_test.go b/internal/ttsnorm/ttsnorm_test.go index 1d4206a..3d938ad 100644 --- a/internal/ttsnorm/ttsnorm_test.go +++ b/internal/ttsnorm/ttsnorm_test.go @@ -6,8 +6,11 @@ func TestSpeakable(t *testing.T) { cases := []struct{ in, want string }{ {"напомню 10.07.2026", "напомню 10 июля 2026"}, {"срок 01.01", "срок 1 января"}, - {"встреча в 14:00", "встреча в 14 часов 00 минут"}, - {"в 9:05 подъём", "в 9 часов 05 минут подъём"}, + {"встреча в 14:00", "встреча в 14 часов"}, + {"в 9:05 подъём", "в 9 часов 5 минут подъём"}, + {"в 21:00 отбой", "в 21 час отбой"}, + {"в 22:02 отбой", "в 22 часа 2 минуты отбой"}, + {"в 1:01 проснулся", "в 1 час 1 минута проснулся"}, {"это 3.2.1 версия", "это 3 точка 2 точка 1 версия"}, {"без чисел", "без чисел"}, }