Merge the say, persona and ttsnorm sweep (#245)

A spoken defect she says out loud. ttsnorm rewrote a time as
p[1] + " часов " + p[2] + " минут", a literal join with no agreement and no
zero handling. So 21:00 was read as 21 часов, 22:00 as 22 часов, and 14:00 as
14 часов 00 минут. Russian inflects the noun after a numeral, and say.CountWord
already owns that rule. A new spokenTime calls it for both halves and drops the
minute clause when it is zero. 21:00 is 21 час now, and 22:02 is 22 часа 2
минуты.

persona held a fourth copy of the months and the weekdays as hand-written
arrays. CLAUDE.md names months a closed class with exactly one copy in
internal/lexicon, and ruwords.go already gave its copy up under V-525. The block
calls lexicon.Weekday and lexicon.MonthGenitive now, and the existing test
already asserted the output.

LoadSummaries required {n} and {days} on stall_sitting but not {word} or
{dayword}, the two count forms beside them. A variant dropping one would have
loaded and spoken a bare number.

The brief's premise about the persona checks was wrong and is worth recording.
The say lines are already folded into the same CheckAddress, CheckFeminine and
CheckCringe run as the four phraser families, at fallbacks_test.go:56. Read by
hand as well: the self-reference is feminine throughout, the owner is ты, and
there is no вы, no он and no pet name. They are checked and they pass.

(V-581)
This commit is contained in:
2026-08-06 03:20:52 +04:00
4 changed files with 30 additions and 11 deletions
+7 -7
View File
@@ -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(), "; ") +
+2 -1
View File
@@ -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}"},
+16 -1
View File
@@ -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 {
+5 -2
View File
@@ -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 версия"},
{"без чисел", "без чисел"},
}