From 6fba4d69315a295cb4f02e6841fcbe019227275c Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 16:47:23 +0400 Subject: [PATCH] say: one count rule everywhere, and a page she can explain (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR 113 review found four defects in one line file. Swept the other four families and the Go side for the same four. The JSON was clean: no undeclared placeholder, no abbreviation spoken, no single-variant entry left unfixed. One register leak — page_blocked read "robots.txt" out loud, which is a filename, not a reason he can act on. The count rule was not clean. Four more copies of the three-way agreement existed and two of them were wrong: ruPlural produced «1 минут назад» and «5 часа назад» because formatTime spelled the noun out. pluralTasksRU was a fifth copy. All of them now call say.CountWord. The pending-notification summary picks the whole phrase, because the adjective declines with the noun. --- cmd/mavend/crawls_test.go | 9 +++++---- cmd/mavend/ruwords.go | 32 ++++++++++++------------------- cmd/mavend/tick.go | 7 ++++++- internal/phraser/query.go | 2 +- internal/phraser/query_ru_v1.json | 2 +- internal/tasks/rank.go | 16 +--------------- internal/tasks/rank_test.go | 6 ++++-- 7 files changed, 30 insertions(+), 44 deletions(-) diff --git a/cmd/mavend/crawls_test.go b/cmd/mavend/crawls_test.go index b6b1882..937d150 100644 --- a/cmd/mavend/crawls_test.go +++ b/cmd/mavend/crawls_test.go @@ -165,16 +165,17 @@ func TestQueryWebRefusesNonHTML(t *testing.T) { } } -// robots.txt is honoured on the answer path too, and she says so instead of -// reporting a generic failure. +// robots.txt is honoured on the answer path too, and she says the page is +// closed instead of reporting a generic failure. func TestQueryWebObeysRobots(t *testing.T) { h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{})) reply, ok := askWeb(h, "посмотри https://example.org/private") if !ok { t.Fatal("the web source did not claim a question with a URL") } - if !strings.Contains(reply, "robots.txt") { - t.Errorf("reply = %q, want the robots answer", reply) + // She names the cause without reading a filename out loud. + if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") { + t.Errorf("reply = %q, want the closed-page answer with no filename", reply) } } diff --git a/cmd/mavend/ruwords.go b/cmd/mavend/ruwords.go index f14513f..aba97aa 100644 --- a/cmd/mavend/ruwords.go +++ b/cmd/mavend/ruwords.go @@ -4,6 +4,10 @@ // plural agreement, clock/date rendering, and the "do I actually know this // place/day" guards that pick an honest reply over a confidently wrong one. // Extend this file rather than voice.go for anything in that shape. +// +// Count agreement is not here. It is say.CountWord, because there were four +// copies of the same three-way rule and two of the sites that needed it were +// spelling one form out (Vikunja #521). package main import ( @@ -11,6 +15,8 @@ import ( "strconv" "strings" "time" + + "github.com/kami/maven/internal/say" ) var ruWeekdays = []string{ @@ -94,11 +100,11 @@ func mentionsUnknownDay(u string) bool { // ruClock renders the clock part of the time reply: "15 часов 4 минуты". func ruClock(t time.Time) string { h, m := t.Hour(), t.Minute() - hourWord := ruPlural(h, "час", "часа", "часов") + hourWord := say.CountWord(h, "час", "часа", "часов") if m == 0 { return fmt.Sprintf("%d %s ровно", h, hourWord) } - return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут")) + return fmt.Sprintf("%d %s %d %s", h, hourWord, m, say.CountWord(m, "минута", "минуты", "минут")) } // dayPrefix names the day relative to now ("завтра", "вчера", …) so the date @@ -118,22 +124,6 @@ func dayPrefix(now, day time.Time) string { return "это" } -func ruPlural(n int, one, two, many string) string { - n = n % 100 - if n > 10 && n < 20 { - return many - } - n = n % 10 - switch n { - case 1: - return one - case 2, 3, 4: - return two - default: - return many - } -} - // hasDurationWords checks whether u is asking about elapsed/remaining time // rather than the current clock — guards replySystem from replying "сейчас // X часов" to "сколько времени прошло". Mirrors the stage0.go build filter. @@ -169,11 +159,13 @@ func formatTime(t time.Time) string { case diff < 10*time.Minute: return "несколько минут назад" case diff < 60*time.Minute: - return fmt.Sprintf("%d минут назад", int(diff.Minutes())) + n := int(diff.Minutes()) + return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "минуту", "минуты", "минут")) case diff < 2*time.Hour: return "час назад" case diff < 24*time.Hour: - return fmt.Sprintf("%d часа назад", int(diff.Hours())) + n := int(diff.Hours()) + return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов")) default: return t.Format("2 января 15:04") } diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index 8926a47..4f5dbaa 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -28,6 +28,7 @@ import ( "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/routine" + "github.com/kami/maven/internal/say" "github.com/kami/maven/internal/store" ) @@ -627,7 +628,11 @@ func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now t fmt.Fprintf(&b, " · и ещё %d", extra) } body := b.String() - summary := fmt.Sprintf("%d отложенных уведомлений", len(entries)) + // The adjective declines with the noun, so the count picks the whole + // phrase: 1 отложенное уведомление, 2 отложенных уведомления, 5 + // отложенных уведомлений. + summary := fmt.Sprintf("%d %s", len(entries), say.CountWord(len(entries), + "отложенное уведомление", "отложенных уведомления", "отложенных уведомлений")) cand := loop.Candidate{ Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)}, diff --git a/internal/phraser/query.go b/internal/phraser/query.go index 497cada..5a3657b 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -79,7 +79,7 @@ var queryFloor = map[string]string{ QueryFactValue: "у меня записано: {key} — {value}", QueryFound: "вот что я нашла: {text}", QueryPageText: "вот что на странице: {text}", - QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.", + QueryPageBlocked: "эта страница закрыта для чтения — сам сайт это запрещает.", QueryPageEmpty: "страница открылась, но читать там нечего.", QueryFeedsOff: "ленты не настроены.", QueryFeedsNew: "вот что нового: {items}", diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index dafa5d0..463f2be 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -38,7 +38,7 @@ }, "page_blocked": { "fixed": true, - "variants": ["эта страница закрыта для чтения — robots.txt не разрешает."] + "variants": ["эта страница закрыта для чтения — сам сайт это запрещает."] }, "page_empty": { "fixed": true, diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go index ca4b72f..1d14d98 100644 --- a/internal/tasks/rank.go +++ b/internal/tasks/rank.go @@ -263,21 +263,7 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string { s := strings.Join(parts, "; ") if rest > 0 { // With the noun. Spoken, a bare number trails off mid-sentence. - s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest)) + s += fmt.Sprintf("; и ещё %d %s", rest, say.CountWord(rest, "задача", "задачи", "задач")) } return s } - -// pluralTasksRU — the right form of "задача" for a count. Russian needs three. -func pluralTasksRU(n int) string { - if n%100 >= 11 && n%100 <= 14 { - return "задач" - } - switch n % 10 { - case 1: - return "задача" - case 2, 3, 4: - return "задачи" - } - return "задач" -} diff --git a/internal/tasks/rank_test.go b/internal/tasks/rank_test.go index 6926521..832b7ed 100644 --- a/internal/tasks/rank_test.go +++ b/internal/tasks/rank_test.go @@ -4,6 +4,8 @@ import ( "strings" "testing" "time" + + "github.com/kami/maven/internal/say" ) func at(y int, m time.Month, d int) *time.Time { @@ -240,8 +242,8 @@ func TestFormatRUTailCarriesTheNoun(t *testing.T) { t.Errorf("reply = %q, want the count with its noun", got) } for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} { - if got := pluralTasksRU(n); got != want { - t.Errorf("pluralTasksRU(%d) = %q, want %q", n, got, want) + if got := say.CountWord(n, "задача", "задачи", "задач"); got != want { + t.Errorf("CountWord(%d) = %q, want %q", n, got, want) } } }