say: one count rule everywhere, and a page she can explain (V-521)

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.
This commit is contained in:
2026-08-04 16:47:23 +04:00
parent 12c18dcf65
commit 6fba4d6931
7 changed files with 30 additions and 44 deletions
+5 -4
View File
@@ -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)
}
}
+12 -20
View File
@@ -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")
}
+6 -1
View File
@@ -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)},
+1 -1
View File
@@ -79,7 +79,7 @@ var queryFloor = map[string]string{
QueryFactValue: "у меня записано: {key} — {value}",
QueryFound: "вот что я нашла: {text}",
QueryPageText: "вот что на странице: {text}",
QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.",
QueryPageBlocked: "эта страница закрыта для чтения — сам сайт это запрещает.",
QueryPageEmpty: "страница открылась, но читать там нечего.",
QueryFeedsOff: "ленты не настроены.",
QueryFeedsNew: "вот что нового: {items}",
+1 -1
View File
@@ -38,7 +38,7 @@
},
"page_blocked": {
"fixed": true,
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
"variants": ["эта страница закрыта для чтения — сам сайт это запрещает."]
},
"page_empty": {
"fixed": true,
+1 -15
View File
@@ -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 "задач"
}
+4 -2
View File
@@ -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)
}
}
}