From d79b30a1a64b71628a763b031e0f72f899495430 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 15:37:54 +0400 Subject: [PATCH 1/7] =?UTF-8?q?phraser:=20one=20count=20helper,=20so=20the?= =?UTF-8?q?=20weather=20says=20"1=20=D0=B3=D1=80=D0=B0=D0=B4=D1=83=D1=81"?= =?UTF-8?q?=20(V-521)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weather line spelled "градусов" out in the template, which is the wrong form for 1-4 and for every number ending in 1-4. Russian inflects the noun after a numeral, so the count splits into the number and {word}. hostWord in cmd/mavend/netscan.go already knew the rule for устройство and was the only place that did. It moves to internal/phraser as CountWord, with Degrees and Devices over it, and the three call sites that counted devices now read the same helper the weather line does. Degrees rounds before it counts, so the noun agrees with the number she is about to say rather than the reading behind it, and a negative reading counts by its magnitude. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- cmd/mavend/netscan.go | 20 ++------------ cmd/mavend/netscan_test.go | 12 --------- cmd/mavend/smarthome.go | 5 ++-- internal/phraser/plural.go | 47 +++++++++++++++++++++++++++++++++ internal/phraser/plural_test.go | 32 ++++++++++++++++++++++ 5 files changed, 84 insertions(+), 32 deletions(-) create mode 100644 internal/phraser/plural.go create mode 100644 internal/phraser/plural_test.go diff --git a/cmd/mavend/netscan.go b/cmd/mavend/netscan.go index c5de502..88d28f1 100644 --- a/cmd/mavend/netscan.go +++ b/cmd/mavend/netscan.go @@ -119,7 +119,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) { if len(res.Hosts) == 0 { return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true } - out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts))) + out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts))) if shape := scanShape(res.Hosts); shape != "" { out += ", " + shape } @@ -180,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) { if w.api == nil { return } - head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts))) + head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts))) if res.Truncated { head += " (не вся сеть)" } @@ -211,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) { } } -// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства, -// 5+ устройств, and the teens are all the last form. -func hostWord(n int) string { - if n%100 >= 11 && n%100 <= 14 { - return "устройств" - } - switch n % 10 { - case 1: - return "устройство" - case 2, 3, 4: - return "устройства" - default: - return "устройств" - } -} - // isNetworkQuery recognises a question about the LAN, narrowly. It needs a // network word AND an ask: "интернет не работает" is a complaint, not a request // to scan, and a scan she runs unasked is exactly the noisy behaviour the diff --git a/cmd/mavend/netscan_test.go b/cmd/mavend/netscan_test.go index df87124..eb10526 100644 --- a/cmd/mavend/netscan_test.go +++ b/cmd/mavend/netscan_test.go @@ -74,18 +74,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) { } } -func TestHostWordAgreesWithTheCount(t *testing.T) { - for n, want := range map[int]string{ - 1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств", - 11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства", - 25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств", - } { - if got := hostWord(n); got != want { - t.Errorf("hostWord(%d) = %q, want %q", n, got, want) - } - } -} - func TestIsNetworkQuery(t *testing.T) { yes := []string{ "какие устройства в сети?", diff --git a/cmd/mavend/smarthome.go b/cmd/mavend/smarthome.go index 03946b2..2e14f3a 100644 --- a/cmd/mavend/smarthome.go +++ b/cmd/mavend/smarthome.go @@ -8,6 +8,7 @@ import ( "time" "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/smarthome" "github.com/kami/maven/internal/store" ) @@ -185,7 +186,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) { case dark > 0 && len(sensors) == 0: // Nothing is on and everything she can see is unreachable. "всё // выключено" would be a claim about the house she cannot make. - return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true + return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, phraser.Devices(dark)), true default: parts = append(parts, "всё выключено") } @@ -193,7 +194,7 @@ func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) { parts = append(parts, strings.Join(sensors, ", ")) } if dark > 0 { - parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark))) + parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, phraser.Devices(dark))) } return strings.Join(parts, "; ") + ".", true } diff --git a/internal/phraser/plural.go b/internal/phraser/plural.go new file mode 100644 index 0000000..23ad704 --- /dev/null +++ b/internal/phraser/plural.go @@ -0,0 +1,47 @@ +package phraser + +// 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, which meant the +// weather line said "градусов" for every temperature and was wrong for 1-4 and +// for every number ending in 1-4. 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, "устройство", "устройства", "устройств") +} diff --git a/internal/phraser/plural_test.go b/internal/phraser/plural_test.go new file mode 100644 index 0000000..cfd4fd2 --- /dev/null +++ b/internal/phraser/plural_test.go @@ -0,0 +1,32 @@ +package phraser + +import "testing" + +// The bug the helper exists for: the weather line said "градусов" for every +// reading, which is wrong for 1-4 and for every number ending in 1-4. +func TestDegreesAgreeWithTheReading(t *testing.T) { + for temp, want := range map[float64]string{ + 1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов", + 0: "градусов", 11: "градусов", 14: "градусов", 21: "градус", + 22: "градуса", 25: "градусов", 101: "градус", + // Minus does not change the noun, and a reading rounds to the number + // she is about to say: -2.4° is "-2 градуса", not "-2 градусов". + -1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов", + } { + if got := Degrees(temp); got != want { + t.Errorf("Degrees(%v) = %q, want %q", temp, got, want) + } + } +} + +func TestDevicesAgreeWithTheCount(t *testing.T) { + for n, want := range map[int]string{ + 1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств", + 11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства", + 25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств", + } { + if got := Devices(n); got != want { + t.Errorf("Devices(%d) = %q, want %q", n, got, want) + } + } +} -- 2.52.0 From feca776077e657b8fda23bcfc09ffe506d46cb74 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 15:38:16 +0400 Subject: [PATCH 2/7] phraser: a variant she cannot fill is not a variant she can say (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the deck, both of which reach him as a broken answer. An optional placeholder had no rule. net_empty carries {tail} for the case where a scan stopped short of the whole range, and a scan that finished has nothing to put there — so the answer went out with the braces in it, or with nothing at all if the variant was all placeholder. The picker now narrows to the variants this call can actually fill, and prefers, among those, the ones using the most of what the caller supplied, so a caveat he was given is never dropped for a shorter wording. Nothing fillable still says the line, because a visible placeholder beats silence. The floor literals lived in one global map keyed by bare entry name, and two families both define an entry called query_unknown: the query answers, where she looked and found nothing, and the phrasing fallbacks, where she failed to say an answer she had. Whichever registered last answered for both, so the distinction those two files exist for disappeared exactly when a file failed to load. Each family now carries its own map, and an unloadable file leaves a floor-only deck behind instead of a nil one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- internal/phraser/acks.go | 6 +-- internal/phraser/deck.go | 92 +++++++++++++++++++++++++++-------- internal/phraser/fallbacks.go | 6 +-- 3 files changed, 79 insertions(+), 25 deletions(-) diff --git a/internal/phraser/acks.go b/internal/phraser/acks.go index 20c0be4..4a6f6dd 100644 --- a/internal/phraser/acks.go +++ b/internal/phraser/acks.go @@ -65,7 +65,7 @@ var ackKeys = []string{ // ackFloor — 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. -var ackFloor = registerFloor(map[string]string{ +var ackFloor = map[string]string{ AckFact: "записала факт.", AckFactKey: "отметила: {key}", AckFactValue: "отметила: {key} = {value}", @@ -90,7 +90,7 @@ var ackFloor = registerFloor(map[string]string{ FailAck: "не получилось отметить.", FailSnooze: "не получилось отложить.", FailQuiet: "не получилось переключить тихий режим.", -}) +} // Acks picks a hand-written Russian acknowledgement. Safe for concurrent use. type Acks struct{ d *deck } @@ -119,7 +119,7 @@ func LoadAcks(src rand.Source) (*Acks, error) { // deck reads through a nil *Acks, which is the unloadable-file case. func (a *Acks) deck() *deck { if a == nil { - return nil + return floorDeck(ackFloor) } return a.d } diff --git a/internal/phraser/deck.go b/internal/phraser/deck.go index 1f53745..9136775 100644 --- a/internal/phraser/deck.go +++ b/internal/phraser/deck.go @@ -35,8 +35,8 @@ type deckFile struct { Entries map[string]deckEntry `json:"entries"` } -// deck picks a line. Safe for concurrent use. A nil *deck answers from the -// floor, which is what an unloadable file leaves behind. +// deck picks a line. Safe for concurrent use. A deck with no entries answers +// from the floor, which is what an unloadable file leaves behind (floorDeck). type deck struct { mu sync.Mutex rnd *rand.Rand @@ -90,7 +90,7 @@ func (d *deck) text(key string, vars map[string]string) string { tmpl := "" if d != nil { if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 { - tmpl = d.pick(key, e) + tmpl = d.pick(key, fillable(e.Variants, vars)) } } if tmpl == "" { @@ -129,12 +129,67 @@ func (d *deck) variants() []string { return out } +// fillable narrows variants to the ones this call can actually say, which is +// the rule an optional placeholder needs: a caller with nothing to put in +// {tail} must not be handed a variant that has one. Two passes, because both +// halves matter. The first keeps only variants whose every placeholder has a +// non-empty value, so an absent optional never reaches him as braces. The +// second prefers, among those, the variants using the most of what the caller +// supplied, so a caveat he was given is not dropped for a shorter wording. +// Nothing fillable leaves the list alone, and the unfilled placeholder shows +// up in the answer rather than turning it into silence. +func fillable(variants []string, vars map[string]string) []string { + if len(variants) < 2 { + return variants + } + best, bestUsed := make([]string, 0, len(variants)), -1 + for _, v := range variants { + used := 0 + ok := true + for _, ph := range placeholders(v) { + if vars[ph] == "" { + ok = false + break + } + used++ + } + if !ok || used < bestUsed { + continue + } + if used > bestUsed { + best, bestUsed = best[:0], used + } + best = append(best, v) + } + if len(best) == 0 { + return variants + } + return best +} + +// placeholders lists the {name}s in tmpl, in order. +func placeholders(tmpl string) []string { + var out []string + for { + i := strings.IndexByte(tmpl, '{') + if i < 0 { + return out + } + j := strings.IndexByte(tmpl[i:], '}') + if j < 0 { + return out + } + out = append(out, tmpl[i+1:i+j]) + tmpl = tmpl[i+j+1:] + } +} + // pick chooses at random, skipping whatever this entry said last time. -func (d *deck) pick(key string, e deckEntry) string { +func (d *deck) pick(key string, variants []string) string { d.mu.Lock() defer d.mu.Unlock() - choices := e.Variants + choices := variants if len(choices) > 1 { fresh := make([]string, 0, len(choices)) for _, v := range choices { @@ -151,24 +206,23 @@ func (d *deck) pick(key string, e deckEntry) string { return got } -// floorOf reads the Go literal behind key, and works on a nil deck because that -// is exactly the case it exists for. The per-family map is the source of truth. +// floorOf reads the Go literal behind key. Every deck carries its own family's +// map, including the floor-only deck an unloadable file leaves behind, so no +// lookup ever crosses families. It used to go through one global map keyed by +// bare entry name, which two families both calling an entry query_unknown +// silently shared: whichever registered last answered for both (Vikunja #521). func floorOf(d *deck, key string) string { - if d != nil && d.floor != nil { - return d.floor[key] + if d == nil { + return "" } - return deckFloors[key] + return d.floor[key] } -// deckFloors — every family's floor literals in one map, so a nil deck still -// finds them. Families register at init; the keys are namespaced by family. -var deckFloors = map[string]string{} - -func registerFloor(floor map[string]string) map[string]string { - for k, v := range floor { - deckFloors[k] = v - } - return floor +// floorDeck — the deck a family falls back to when its file will not load. It +// has no entries, so every read drops through to the floor literals, and it is +// a real *deck so no accessor has to know which case it is in. +func floorDeck(floor map[string]string) *deck { + return &deck{last: map[string]string{}, floor: floor} } // fill substitutes {name} for each var. A placeholder with no value is left diff --git a/internal/phraser/fallbacks.go b/internal/phraser/fallbacks.go index a9c7058..2124242 100644 --- a/internal/phraser/fallbacks.go +++ b/internal/phraser/fallbacks.go @@ -39,12 +39,12 @@ var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap} // hardFloor — 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. -var hardFloor = registerFloor(map[string]string{ +var hardFloor = map[string]string{ fbChat: "даже не знаю, что сказать.", fbQueryUnknown: "не знаю.", fbQuerySources: "вот что я нашла: {sources}", fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.", -}) +} // Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use. type Fallbacks struct{ d *deck } @@ -66,7 +66,7 @@ func LoadFallbacks(src rand.Source) (*Fallbacks, error) { // deck reads through a nil *Fallbacks, which is the unloadable-file case. func (f *Fallbacks) deck() *deck { if f == nil { - return nil + return floorDeck(hardFloor) } return f.d } -- 2.52.0 From 765ed3634042dba4e848973d1c8bd2d429926aca Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 15:38:16 +0400 Subject: [PATCH 3/7] phraser: the query answers as review rewrote them (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's wording, taken from the PR 111 review, with one correction from the PR 113 review folded in: {temp} {word} rather than {temp}°, because the degree sign reads as nothing through piper. What the wording changes: query_unknown drops "не знаю.", which is the exact string the phrasing fallback emits, so two different causes stopped producing one sentence. weather_nolocation stops reading voice.weather.default_location out loud and just asks which city. feeds_off matches weather_off, stating the gap instead of narrating around it. The passive doubles and the near-identical pairs go. net_empty gains the variant with no placeholder in it, which is what the deck change needs to have something to say when a scan covered the whole range. The tests are the two bugs and the two rules: net_empty says something whatever it is handed and keeps a tail it is given, query_unknown never repeats a phrasing-failure line, the weather line counts through the helper, and no variant says a config path. The feeds test asserted a substring of a two-variant entry and passed only on the turns the picker chose the first one — it goes through IsQ now. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- cmd/mavend/actions_query.go | 1 + cmd/mavend/feeds_test.go | 10 ++- internal/phraser/query.go | 27 ++++--- internal/phraser/query_lines_test.go | 107 +++++++++++++++++++++++++++ internal/phraser/query_ru_v1.json | 38 +++++----- 5 files changed, 151 insertions(+), 32 deletions(-) create mode 100644 internal/phraser/query_lines_test.go diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index f019816..9f3f771 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -397,6 +397,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin return phraser.Q(phraser.QueryWeatherNow, map[string]string{ "location": w.Location, "temp": fmt.Sprintf("%.0f", w.Temperature), + "word": phraser.Degrees(w.Temperature), "condition": w.Condition, }), true } diff --git a/cmd/mavend/feeds_test.go b/cmd/mavend/feeds_test.go index 7db7f94..4db05da 100644 --- a/cmd/mavend/feeds_test.go +++ b/cmd/mavend/feeds_test.go @@ -87,16 +87,22 @@ func TestQueryFeedsByCategory(t *testing.T) { // "не настроены" and "ничего нового" are different truths, and neither may be // answered by the model inventing a bulletin. func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) { + // Against the entries, not against a substring: both of these have several + // wordings, so "ничего нового" passed only on the turns the picker happened + // to choose the first one. off := buildFeedHandler(t, false) reply, ok := askFeeds(t, off, "что нового в лентах?") - if !ok || !strings.Contains(reply, "не настроены") { + if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) { t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok) } on := buildFeedHandler(t, true) reply, ok = askFeeds(t, on, "что нового в лентах?") - if !ok || !strings.Contains(reply, "ничего нового") { + if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) { t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok) } + if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) { + t.Fatalf("an empty feed answered as an unconfigured one: %q", reply) + } } func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) { diff --git a/internal/phraser/query.go b/internal/phraser/query.go index 638eefd..aefbb44 100644 --- a/internal/phraser/query.go +++ b/internal/phraser/query.go @@ -64,25 +64,27 @@ var queryKeys = []string{ } // queryFloor — 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. -var queryFloor = registerFloor(map[string]string{ - QueryUnknown: "не знаю.", +// 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 queryFloor = map[string]string{ + QueryUnknown: "ничего не нашла.", QueryOtherDay: "про другой день так не отвечу — спроси целиком.", QueryPersonalNone: "не знаю — не нашла у тебя такой записи.", - QueryFactWhen: "я записала это {when}", - QueryFactValue: "вот что я знаю: {key} — {value}", + QueryFactWhen: "записала это {when}", + QueryFactValue: "у меня записано: {key} — {value}", QueryFound: "вот что я нашла: {text}", QueryPageText: "вот что на странице: {text}", QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.", QueryPageEmpty: "страница открылась, но читать там нечего.", - QueryFeedsOff: "я пока не читаю ленты — они не настроены.", + QueryFeedsOff: "ленты не настроены.", QueryFeedsNew: "вот что нового: {items}", QueryFeedsEmpty: "в лентах пока ничего нового.", QueryFeedsTopic: "по этой теме в лентах пока ничего.", - QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.", + QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.", QueryWeatherOff: "погода не настроена.", - QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.", - QueryNetEmpty: "в сети никого не нашла{tail}.", + QueryWeatherWhere: "для какого города?", + QueryNetEmpty: "в сети никого не нашла.", QueryFailPlan: "не получилось собрать план.", QueryFailNotes: "не получилось посмотреть записи.", @@ -92,7 +94,7 @@ var queryFloor = registerFloor(map[string]string{ QueryFailAnswer: "не получилось найти ответ.", QueryFailPage: "не получилось прочитать страницу.", QueryFailNetscan: "не получилось просканировать сеть.", -}) +} // Queries picks a hand-written Russian query line. Safe for concurrent use. type Queries struct{ d *deck } @@ -109,7 +111,8 @@ func LoadQueries(src rand.Source) (*Queries, error) { for _, req := range []struct{ key, ph string }{ {QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"}, {QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"}, - {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"}, + {QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, + {QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"}, } { if err := d.requirePlaceholder(req.key, req.ph); err != nil { return nil, err @@ -121,7 +124,7 @@ func LoadQueries(src rand.Source) (*Queries, error) { // deck reads through a nil *Queries, which is the unloadable-file case. func (q *Queries) deck() *deck { if q == nil { - return nil + return floorDeck(queryFloor) } return q.d } diff --git a/internal/phraser/query_lines_test.go b/internal/phraser/query_lines_test.go new file mode 100644 index 0000000..17302dd --- /dev/null +++ b/internal/phraser/query_lines_test.go @@ -0,0 +1,107 @@ +package phraser + +import ( + "math/rand" + "strings" + "testing" +) + +func TestQueriesLoad(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, k := range queryKeys { + if got := q.Say(k, nil); got == "" { + t.Errorf("%s says nothing", k) + } + } +} + +// The bug: net_empty carried {tail} in every variant, and a scan that finished +// the whole range has no caveat to put there. Whatever the file says, an answer +// he can hear has to come out — never braces, never nothing. +func TestNetEmptySaysSomethingWithNoTail(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, vars := range []map[string]string{nil, {"tail": ""}} { + for i := 0; i < 20; i++ { + got := q.Say(QueryNetEmpty, vars) + if got == "" || strings.ContainsAny(got, "{}") { + t.Fatalf("net_empty with vars %v said %q", vars, got) + } + } + } +} + +// The other half: a caveat he was given is not dropped for a shorter wording. +func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + const tail = ", но успела посмотреть не всю сеть" + for i := 0; i < 20; i++ { + if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) { + t.Fatalf("net_empty dropped the tail: %q", got) + } + } +} + +// query_unknown means she looked and found nothing. The phraser's fallback +// means she failed to phrase an answer she had. Two causes, two sentences, or +// the distinction the two files exist for is unobservable from the outside. +func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + f, err := LoadFallbacks(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadFallbacks: %v", err) + } + failures := map[string]bool{} + for _, v := range f.Variants() { + failures[v] = true + } + for _, v := range q.d.file.Entries[QueryUnknown].Variants { + if failures[v] { + t.Errorf("query_unknown variant %q is also a phrasing failure line", v) + } + } +} + +// The weather line splits the count into a number and a noun, so a variant that +// says the temperature without {word} is the hardcoded "градусов" coming back. +func TestWeatherLineCountsWithTheHelper(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, v := range q.d.file.Entries[QueryWeatherNow].Variants { + if strings.Contains(v, "градус") { + t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v) + } + } + got := q.Say(QueryWeatherNow, map[string]string{ + "location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно", + }) + if !strings.Contains(got, "1 градус,") { + t.Errorf("weather_now said %q, want the singular noun", got) + } +} + +// No line spoken to him names a config key. She asks instead. +func TestNoQueryLineRecitesAConfigPath(t *testing.T) { + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + for _, v := range q.Variants() { + if strings.Contains(v, "voice.") || strings.Contains(v, "_location") { + t.Errorf("variant %q says a config path out loud", v) + } + } +} diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index 249294e..943d5bd 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -5,13 +5,15 @@ "What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.", "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", "A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.", - "query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose.", - "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.", + "query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.", + "Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.", + "A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.", + "{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.", "fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day." ], "entries": { "query_unknown": { - "variants": ["не знаю.", "не нашла ничего.", "ничего не нашла."] + "variants": ["ничего не нашла.", "искала — не нашла."] }, "other_day": { "fixed": true, @@ -22,10 +24,10 @@ "variants": ["не знаю — не нашла у тебя такой записи."] }, "fact_when": { - "variants": ["я записала это {when}", "записала это {when}"] + "variants": ["записала это {when}"] }, "fact_value": { - "variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"] + "variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"] }, "found": { "variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"] @@ -38,10 +40,10 @@ "variants": ["эта страница закрыта для чтения — robots.txt не разрешает."] }, "page_empty": { - "variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."] + "variants": ["страница открылась, но читать там нечего."] }, "feeds_off": { - "variants": ["я пока не читаю ленты — они не настроены."] + "variants": ["ленты не настроены."] }, "feeds_new": { "variants": ["вот что нового: {items}", "нового вот что: {items}"] @@ -53,41 +55,41 @@ "variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."] }, "weather_now": { - "variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."] + "variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."] }, "weather_off": { "variants": ["погода не настроена."] }, "weather_nolocation": { "fixed": true, - "variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."] + "variants": ["для какого города?"] }, "net_empty": { - "variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."] + "variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."] }, "fail_plan": { - "variants": ["не получилось собрать план.", "план не собрался."] + "variants": ["не получилось собрать план."] }, "fail_notes": { - "variants": ["не получилось посмотреть записи.", "записи не открылись."] + "variants": ["не получилось посмотреть записи."] }, "fail_feeds": { - "variants": ["не получилось посмотреть ленты.", "ленты не открылись."] + "variants": ["не получилось посмотреть ленты."] }, "fail_calendar": { - "variants": ["не получилось проверить календарь.", "календарь не открылся."] + "variants": ["не получилось проверить календарь."] }, "fail_weather": { - "variants": ["не получилось узнать погоду.", "погода не пришла."] + "variants": ["не получилось узнать погоду."] }, "fail_answer": { - "variants": ["не получилось найти ответ.", "ответ не нашёлся."] + "variants": ["не получилось найти ответ."] }, "fail_page": { - "variants": ["не получилось прочитать страницу.", "страница не прочиталась."] + "variants": ["не получилось прочитать страницу."] }, "fail_netscan": { - "variants": ["не получилось просканировать сеть.", "сеть не просканировалась."] + "variants": ["не получилось просканировать сеть."] } } } -- 2.52.0 From 4c95b200e4f753fdbcf9c4101f4c1d285e7fae76 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 15:59:59 +0400 Subject: [PATCH 4/7] phraser: the act replies as review rewrote them (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's wording from the PR 112 review, and the placeholder fixes under it. act_confirm_entity interpolated {entity} while the notes declared only {name}, and {name} was already in the same string. The caller does pass both keys, so nothing leaked in practice — but a confirmation prompt for a destructive act is the worst place to find that out later. Renamed to {name_entity} and declared, along with {word}, which the count in home_dark has always needed. Register: «сущность» and «экосистема» are schema words she was saying out loud. act_done_entity stops reporting in the passive and matches «готово.», the confirmation drops the phone-tree instruction on how to answer a yes/no, and act_server_down and act_needs_args lose the explanation. «угадывать не буду» stays exactly as it was. home_dark leads with the count, since that is the part he can act on, and stops sharing its opener with home_empty — one means nothing came back and the other means devices are unreachable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- internal/phraser/act_lines_test.go | 86 ++++++++++++++++++++++++++++++ internal/phraser/acts.go | 40 +++++++------- internal/phraser/acts_ru_v1.json | 48 +++++++++-------- 3 files changed, 134 insertions(+), 40 deletions(-) create mode 100644 internal/phraser/act_lines_test.go diff --git a/internal/phraser/act_lines_test.go b/internal/phraser/act_lines_test.go new file mode 100644 index 0000000..020698f --- /dev/null +++ b/internal/phraser/act_lines_test.go @@ -0,0 +1,86 @@ +package phraser + +import ( + "math/rand" + "strconv" + "strings" + "testing" +) + +func loadTestActs(t *testing.T) *Acts { + t.Helper() + a, err := LoadActs(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadActs: %v", err) + } + return a +} + +// She talks about a lamp or a server, never about a row in a schema. «сущность» +// and «экосистема» are the same defect as saying a capability id out loud. +func TestNoActLineSaysASchemaWord(t *testing.T) { + a := loadTestActs(t) + for _, v := range a.Variants() { + for _, word := range []string{"сущност", "экосистем"} { + if strings.Contains(v, word) { + t.Errorf("variant %q says %q out loud", v, word) + } + } + } +} + +// Nexus, Praxis and Hexis fail independently, so "не отвечает" with no subject +// is not an answer he can act on. +func TestAServiceFailureNamesTheService(t *testing.T) { + a := loadTestActs(t) + for _, key := range []string{EcoDown, EcoDenied} { + got := a.Say(key, map[string]string{"name": "Praxis"}) + if !strings.HasPrefix(got, "Praxis ") { + t.Errorf("%s = %q, want it to name the service", key, got) + } + } +} + +// A confirmation prompt for a destructive act is the worst place for an unfilled +// placeholder, so the two names it interpolates are distinct keys and both are +// declared. +func TestConfirmEntityFillsBothNames(t *testing.T) { + a := loadTestActs(t) + got := a.Say(ActConfirmEntity, map[string]string{ + "name": "restart", "name_entity": "Muzick indexer", + }) + if strings.ContainsAny(got, "{}") { + t.Fatalf("act_confirm_entity = %q, want no placeholder left", got) + } + if !strings.Contains(got, "restart") || !strings.Contains(got, "Muzick indexer") { + t.Fatalf("act_confirm_entity = %q, want both names", got) + } +} + +// home_dark counts unreachable devices, and Russian inflects the noun after the +// number: the count goes in {count} and the noun comes from the helper. +func TestHomeDarkCountsWithTheHelper(t *testing.T) { + a := loadTestActs(t) + for n, want := range map[int]string{1: "1 устройство", 2: "2 устройства", 5: "5 устройств"} { + got := a.Say(HomeDark, map[string]string{"count": strconv.Itoa(n), "word": Devices(n)}) + if !strings.Contains(got, want) { + t.Errorf("home_dark for %d = %q, want %q in it", n, got, want) + } + } +} + +// Four truths, four entries: a failure must not be able to report itself as a +// success, and an empty result must not read as a failure. +func TestActOutcomesStayDistinct(t *testing.T) { + a := loadTestActs(t) + seen := map[string]string{} + for _, key := range actKeys { + for _, v := range a.d.file.Entries[key].Variants { + if prev, dup := seen[v]; dup { + t.Errorf("%s and %s both say %q", prev, key, v) + } + seen[v] = key + } + } +} + diff --git a/internal/phraser/acts.go b/internal/phraser/acts.go index 9290c5f..b60c9c9 100644 --- a/internal/phraser/acts.go +++ b/internal/phraser/acts.go @@ -72,28 +72,30 @@ var actKeys = []string{ HomeUnreachable, HomeEmpty, HomeOn, HomeDark, } -// actFloor — 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. +// actFloor — the literal each key falls back to when the file is unusable. 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 actFloor = map[string]string{ ActDone: "готово.", ActDoneOut: "готово: {out}", - ActDoneEntity: "команда выполнена для {name}.", - ActConfirm: "выполнить «{name}»? скажи «да» или «нет».", - ActConfirmEntity: "выполнить «{name}» для {entity}? скажи «да» или «нет».", + ActDoneEntity: "готово: {name}.", + ActConfirm: "выполнить «{name}»? да или нет.", + ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.", ActWhich: "какую команду для {name}: {items}?", ActFail: "не получилось выполнить команду.", ActFailOut: "не получилось выполнить команду: {out}", ActFailEntity: "не получилось выполнить команду для {name}.", - ActServerDown: "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён.", - ActWithdrawn: "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools.", - ActNeedsArgs: "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать.", + ActServerDown: "инструмент есть, но сервер не подключён.", + ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.", + ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.", - EcoDenied: "экосистема отклоняет доступ, проверь токен.", - EcoDown: "экосистема недоступна, попробуй ещё раз.", - EcoAmbiguous: "уточни, что именно: {items}?", - EcoUnknownEntity: "не знаю такой сущности.", - EcoNoNexus: "не могу связать это с сущностью — Nexus не настроен.", - EcoAboutWhat: "про что именно спросить?", + EcoDenied: "{name} отклоняет доступ, проверь токен.", + EcoDown: "{name} не отвечает, попробуй ещё раз.", + EcoAmbiguous: "что именно: {items}?", + EcoUnknownEntity: "не знаю, что это.", + EcoNoNexus: "не с чем связать — Nexus не настроен.", + EcoAboutWhat: "про что именно?", EcoRecall: "я помню: {items}", AttentionNone: "ничего не требует внимания.", @@ -102,13 +104,13 @@ var actFloor = map[string]string{ AttentionNoneEntity: "по «{name}» ничего нет.", AttentionListEntity: "по «{name}»: {items}", AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».", - ChangesNone: "нет изменений.", + ChangesNone: "изменений нет.", ChangesList: "изменения: {items}", ChangesFail: "не могу сейчас узнать об изменениях.", - HomeUnreachable: "не смогла достучаться до дома.", + HomeUnreachable: "дом не отвечает.", HomeEmpty: "дом ничего не отдаёт.", HomeOn: "включено: {items}", - HomeDark: "дом молчит: {count} {word} не отвечают.", + HomeDark: "не отвечают: {count} {word}.", } // Acts picks a hand-written Russian act reply. Safe for concurrent use. @@ -126,10 +128,12 @@ func LoadActs(src rand.Source) (*Acts, error) { for _, req := range []struct{ key, ph string }{ {ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"}, {ActFailEntity, "{name}"}, {ActConfirm, "{name}"}, - {ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{entity}"}, + {ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{name_entity}"}, {ActWhich, "{name}"}, {ActWhich, "{items}"}, {EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"}, {AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"}, + {EcoDenied, "{name}"}, {EcoDown, "{name}"}, + {HomeDark, "{count}"}, {HomeDark, "{word}"}, {AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"}, {AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"}, } { diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json index afcfbc2..e7597f2 100644 --- a/internal/phraser/acts_ru_v1.json +++ b/internal/phraser/acts_ru_v1.json @@ -4,9 +4,13 @@ "notes": [ "What she says when a capability ran, refused, or could not be reached. Edit the wording here, no Go changes needed.", "Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.", - "\"it ran\", \"it was refused\", \"the ecosystem is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.", - "Placeholders: {name} an entity or capability the caller resolved, {out} the command's own output, {items} a joined list, {count} a number. Entity names and capability ids are interpolated Go-side.", - "fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the two lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test." + "\"it ran\", \"it was refused\", \"a service is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.", + "She says what he would say. No schema words out loud: not «сущность», not «экосистема», not a capability id, not a config key. She is talking about a lamp or a server.", + "A service that is down or refusing is named. \"не отвечает\" with no subject tells him nothing he can act on, and Nexus, Praxis and Hexis fail independently.", + "Placeholders: {name} an entity or capability the caller resolved, {name_entity} the entity an act runs against when {name} is already the capability, {out} the command's own output, {items} a joined list, {count} a number, {word} the counted noun in the form {count} needs. Entity names and capability ids are interpolated Go-side.", + "A count never carries a hardcoded noun. Russian inflects it — 1 устройство, 2 устройства, 5 устройств — so the number goes in {count} and the noun comes from the Go helper through {word}.", + "An entry that only exists to read a list back must never be reached with an empty list. The caller routes an empty list to the matching _none entry, because a single-variant placeholder-only line has no shorter wording to fall back to.", + "fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test." ], "entries": { "act_done": { @@ -18,61 +22,61 @@ }, "act_done_entity": { "fixed": true, - "variants": ["команда выполнена для {name}."] + "variants": ["готово: {name}."] }, "act_confirm": { "fixed": true, - "variants": ["выполнить «{name}»? скажи «да» или «нет»."] + "variants": ["выполнить «{name}»? да или нет."] }, "act_confirm_entity": { "fixed": true, - "variants": ["выполнить «{name}» для {entity}? скажи «да» или «нет»."] + "variants": ["выполнить «{name}» для {name_entity}? да или нет."] }, "act_which": { "variants": ["какую команду для {name}: {items}?"] }, "act_fail": { - "variants": ["не получилось выполнить команду.", "команда не выполнилась."] + "variants": ["не получилось выполнить команду."] }, "act_fail_out": { "variants": ["не получилось выполнить команду: {out}"] }, "act_fail_entity": { - "variants": ["не получилось выполнить команду для {name}.", "команда для {name} не выполнилась."] + "variants": ["не получилось выполнить команду для {name}."] }, "act_server_down": { - "variants": ["этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."] + "variants": ["инструмент есть, но сервер не подключён."] }, "act_withdrawn": { "fixed": true, - "variants": ["сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."] + "variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."] }, "act_needs_args": { - "variants": ["этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."] + "variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."] }, "eco_denied": { - "variants": ["экосистема отклоняет доступ, проверь токен."] + "variants": ["{name} отклоняет доступ, проверь токен."] }, "eco_down": { - "variants": ["экосистема недоступна, попробуй ещё раз.", "экосистема не отвечает, попробуй ещё раз."] + "variants": ["{name} не отвечает, попробуй ещё раз."] }, "eco_ambiguous": { - "variants": ["уточни, что именно: {items}?", "что именно из этого: {items}?"] + "variants": ["что именно: {items}?"] }, "eco_unknown_entity": { - "variants": ["не знаю такой сущности.", "такой сущности у меня нет."] + "variants": ["не знаю, что это.", "такого у меня нет."] }, "eco_no_nexus": { - "variants": ["не могу связать это с сущностью — Nexus не настроен."] + "variants": ["не с чем связать — Nexus не настроен."] }, "eco_about_what": { - "variants": ["про что именно спросить?", "про что спросить?"] + "variants": ["про что именно?"] }, "eco_recall": { "variants": ["я помню: {items}"] }, "attention_none": { - "variants": ["ничего не требует внимания.", "внимания сейчас ничего не требует."] + "variants": ["ничего не требует внимания."] }, "attention_list": { "variants": ["требует внимания: {items}"] @@ -90,7 +94,7 @@ "variants": ["не могу сейчас узнать, что требует внимания по «{name}»."] }, "changes_none": { - "variants": ["нет изменений.", "изменений нет."] + "variants": ["изменений нет."] }, "changes_list": { "variants": ["изменения: {items}"] @@ -99,16 +103,16 @@ "variants": ["не могу сейчас узнать об изменениях."] }, "home_unreachable": { - "variants": ["не смогла достучаться до дома.", "дом не отвечает."] + "variants": ["дом не отвечает.", "не достучалась до дома."] }, "home_empty": { - "variants": ["дом ничего не отдаёт.", "дом молчит."] + "variants": ["дом ничего не отдаёт."] }, "home_on": { "variants": ["включено: {items}"] }, "home_dark": { - "variants": ["дом молчит: {count} {word} не отвечают."] + "variants": ["не отвечают: {count} {word}."] } } } -- 2.52.0 From 6a9d8a4dd5df8f2dcde43e0da71b9adc83384732 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 16:00:17 +0400 Subject: [PATCH 5/7] mavend: name the service that is down, and never read an empty list (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two caller-side halves of the same review. «экосистема недоступна» named nothing. Nexus, Praxis and Hexis fail independently, and every one of the six call sites already knew which one it was talking to — it writes that name into the trace on the line above. So eco_down and eco_denied now take {name}, and he hears which service refused him. The list entries are single-variant and placeholder-only, so an empty list has no shorter wording to fall back on: attention_list would render as its own label and a colon. Both Praxis readers checked the response length and neither checked what survived formatting, so an item with no title counted toward a list it could not appear in. They skip the untitled item and fall to the _none entry when nothing is left. The ecosystem tests asserted the substring "выполнена", which was a literal out of the act file that review has now reworded. Seventeen sites go through actRan, which asks the file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS --- cmd/mavend/ecosystem_acts.go | 48 +++++++++++++++++++++++---- cmd/mavend/ecosystem_degraded_test.go | 20 +++++------ cmd/mavend/ecosystem_harness_test.go | 4 +-- cmd/mavend/ecosystem_test.go | 20 ++++++++--- cmd/mavend/ecosystem_trace_test.go | 2 +- 5 files changed, 70 insertions(+), 24 deletions(-) diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index a2bb47d..7ed0be7 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -14,6 +14,19 @@ import ( "github.com/kami/maven/internal/store" ) +// The three services, spelled the way she says them out loud. A service that is +// down or refusing has to be named: they degrade independently, so "не +// отвечает" on its own tells him nothing he can act on, and each call site +// already knows which one it was talking to — it records the same name in the +// trace (Vikunja #521). +const ( + serviceNexus = "Nexus" + serviceHexis = "Hexis" +) + +// serviceVars — the one-key map the eco_down and eco_denied lines take. +func serviceVars(name string) map[string]string { return map[string]string{"name": name} } + // praxisCapability is one arm of the Praxis act dispatch. This is an interface // rather than a map[string]func because each arm carries its own state: the // verb aliases it answers to, the trace name it records, and its own reply @@ -158,6 +171,11 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p importance, _ := item["importance"].(float64) rule, _ := item["rule"].(string) s := title + if s == "" { + // An item Praxis returned without a title is not an item she can + // read out. Counting it would put an empty slot in the list. + continue + } if importance > 0 { s += fmt.Sprintf(" (важность %d", int(importance)) if rule != "" { @@ -176,6 +194,12 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p } } } + if len(parts) == 0 { + // Praxis returned items and not one of them could be said. "ничего не + // требует внимания" is the honest answer; the list line would render as + // its own label and a colon (Vikunja #521). + return phraser.A(phraser.AttentionNone, nil) + } return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")}) } @@ -202,9 +226,19 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px var parts []string for _, c := range changes { title, _ := c["title"].(string) + if title == "" { + continue + } typ, _ := c["change_type"].(string) + if typ == "" { + parts = append(parts, title) + continue + } parts = append(parts, fmt.Sprintf("%s (%s)", title, typ)) } + if len(parts) == 0 { + return phraser.A(phraser.ChangesNone, nil) + } return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")}) } @@ -249,9 +283,9 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)})) if unauthorizedEcosystemError(err) { - return phraser.A(phraser.EcoDenied, nil) + return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus)) } - return phraser.A(phraser.EcoDown, nil) + return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) } if len(ambiguous) > 0 { return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")}) @@ -521,13 +555,13 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started, mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)})) if unauthorizedEcosystemError(err) { - return phraser.A(phraser.EcoDenied, nil) + return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus)) } // A genuine Nexus dependency failure, not "no such entity" — stop here // and report degradation rather than silently falling through to the // local command executor (ECOSYSTEM-SPEC.md: services degrade // independently, never a silent all-clear). - return phraser.A(phraser.EcoDown, nil) + return phraser.A(phraser.EcoDown, serviceVars(serviceNexus)) } if len(ambiguous) > 0 { h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started, @@ -551,9 +585,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID})) if unauthorizedEcosystemError(err) { - return phraser.A(phraser.EcoDenied, nil) + return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis)) } - return phraser.A(phraser.EcoDown, nil) + return phraser.A(phraser.EcoDown, serviceVars(serviceHexis)) } h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered, map[string]any{"entity_id": entityID, "count": len(caps)}) @@ -603,7 +637,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio h.mu.Unlock() h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started, map[string]any{"entity_id": entityID, "capability": matched.Name}) - return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "entity": displayName}) + return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName}) } return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName) diff --git a/cmd/mavend/ecosystem_degraded_test.go b/cmd/mavend/ecosystem_degraded_test.go index 383959e..ac9b9fc 100644 --- a/cmd/mavend/ecosystem_degraded_test.go +++ b/cmd/mavend/ecosystem_degraded_test.go @@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) { // A Nexus outage during a Hexis act writes a failure trace, and a shared // store is the one thing the Praxis path could inherit it through. nexus.SetFault(503) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) { t.Fatalf("nexus outage must not report success, got %q", reply) } if len(tracesFor(t, h, "nexus", "resolve")) == 0 { @@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) { if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") { t.Fatalf("praxis outage must not serve content, got %q", reply) } - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("a praxis outage must not block the hexis path, got %q", reply) } } @@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) { if reply == "" { t.Fatal("a resolve with no entity must degrade, not fall through to local execution") } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("a resolve with no entity must not report success, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { @@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) { nexus.SetBody(`{"status":"resolved","entity":`) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if reply == "" || strings.Contains(reply, "выполнена") { + if reply == "" || actRan(reply) { t.Fatalf("malformed nexus body must degrade, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { @@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) { nexus := newFakeNexus(t, body) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply) } }) @@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) defer cancel() reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if reply == "" || strings.Contains(reply, "выполнена") { + if reply == "" || actRan(reply) { t.Fatalf("cancelled resolve must degrade, got %q", reply) } if hexis.Count("", "/api/v1") != 0 { @@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) { h := ecoHandler(t, nexus, nil, hexis) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("failed execution must not read as success, got %q", reply) } if reply == "" { @@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) { hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("setup: expected success, got %q", reply) } exec := tracesFor(t, h, "hexis", "execute") @@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) { hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("setup: expected success, got %q", reply) } if len(traces(t, h)) == 0 { @@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) { if reply == "" { t.Errorf("%s: total outage must not answer with silence", name) } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Errorf("%s: total outage must not claim success: %q", name, reply) } } diff --git a/cmd/mavend/ecosystem_harness_test.go b/cmd/mavend/ecosystem_harness_test.go index 521d220..8b8ce5d 100644 --- a/cmd/mavend/ecosystem_harness_test.go +++ b/cmd/mavend/ecosystem_harness_test.go @@ -105,13 +105,13 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) { nexus.SetFault(503) reply := h.handleHexisAct(ctx, actDec("muzick indexer")) - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("nexus outage must not report success, got %q", reply) } nexus.SetFault(0) reply = h.handleHexisAct(ctx, actDec("muzick indexer")) - if !strings.Contains(reply, "выполнена") { + if !actRan(reply) { t.Fatalf("expected success once nexus recovers, got %q", reply) } } diff --git a/cmd/mavend/ecosystem_test.go b/cmd/mavend/ecosystem_test.go index 4fc557f..9296e1f 100644 --- a/cmd/mavend/ecosystem_test.go +++ b/cmd/mavend/ecosystem_test.go @@ -11,6 +11,7 @@ import ( hexisclient "github.com/kami/hexis/pkg/client" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" ) @@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) { // The follow-up "да" turn executes exactly the parked capability. confirmReply, handled := h.resolveConfirm(ctx, "да") - if !handled || !strings.Contains(confirmReply, "выполнена") { + if !handled || !actRan(confirmReply) { t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply) } if !*executed { @@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) { if h.pendingHexis != nil { t.Fatal("read-only cap should not park a confirmation") } - if !strings.Contains(reply, "выполнена") { + if !actRan(reply) { t.Fatalf("unexpected reply %q", reply) } } @@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) { if reply == "" { t.Fatal("nexus dependency failure must not fall through with an empty reply") } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("nexus dependency failure must not report success, got %q", reply) } } @@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) { if reply == "" { t.Fatal("hexis dependency failure must not fall through with an empty reply") } - if strings.Contains(reply, "выполнена") { + if actRan(reply) { t.Fatalf("hexis dependency failure must not report success, got %q", reply) } } @@ -241,3 +242,14 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) { t.Fatal("not_found resolution must never execute a hexis capability") } } + +// actRan — the reply is the line she says when a capability ran against an +// entity. The tests used to look for the substring "выполнена", which was a +// literal out of the act file: the review reworded that line to "готово: {name}" +// and seventeen assertions went with it (Vikunja #521). +func actRan(reply string) bool { + return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply) +} + +// muzickIndexer — the display name every ecosystem fixture resolves to. +const muzickIndexer = "Muzick indexer" diff --git a/cmd/mavend/ecosystem_trace_test.go b/cmd/mavend/ecosystem_trace_test.go index 86d3b1c..7345481 100644 --- a/cmd/mavend/ecosystem_trace_test.go +++ b/cmd/mavend/ecosystem_trace_test.go @@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) { hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) - if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") { + if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) { t.Fatalf("setup: expected success, got %q", reply) } -- 2.52.0 From a286865fe5704591ad1b4cf22594c6786de86d62 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 16:24:38 +0400 Subject: [PATCH 6/7] say: the summary sentences as review rewrote them (V-521) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/mavend/actions_task_test.go | 2 +- internal/memory/behavior.go | 18 +-------- internal/memory/behavior_test.go | 26 +++++-------- internal/morning/plan_test.go | 26 ++++++++++++- internal/phraser/acts_ru_v1.json | 23 ++++++++++++ internal/phraser/fixedness_test.go | 38 +++++++++++++++++++ internal/phraser/plural.go | 50 ++++++------------------- internal/phraser/query_ru_v1.json | 12 ++++++ internal/say/plural.go | 55 +++++++++++++++++++++++++++ internal/say/plural_test.go | 16 ++++++++ internal/say/summary.go | 39 +++++++++---------- internal/say/summary_ru_v1.json | 60 +++++++++++++++--------------- internal/say/summary_test.go | 13 +++++++ internal/tasks/rank.go | 20 +++++++--- internal/tasks/rank_test.go | 10 +++-- 15 files changed, 277 insertions(+), 131 deletions(-) create mode 100644 internal/phraser/fixedness_test.go create mode 100644 internal/say/plural.go create mode 100644 internal/say/plural_test.go diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go index 33e304c..5f38bba 100644 --- a/cmd/mavend/actions_task_test.go +++ b/cmd/mavend/actions_task_test.go @@ -138,7 +138,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) { if !(openIdx < candIdx) { t.Errorf("reply = %q, want confirmed work before candidates", reply) } - if !strings.Contains(reply, "не подтвердил") { + if !strings.Contains(reply, "не подтверждал") { t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply) } } diff --git a/internal/memory/behavior.go b/internal/memory/behavior.go index 20cc614..e926671 100644 --- a/internal/memory/behavior.go +++ b/internal/memory/behavior.go @@ -360,7 +360,7 @@ func (p Profile) FormatWeekendRU() string { switch { case len(sat) > 0 && len(sun) > 0: return say.S(say.HabitWeekendBoth, map[string]string{ - "sat": joinActivities(sat), "sun": joinActivities(sun), + "items_sat": joinActivities(sat), "items_sun": joinActivities(sun), }) case len(sat) > 0: return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)}) @@ -395,24 +395,10 @@ func (p Profile) spanRU() string { } days := int(p.Until.Sub(p.Since).Hours()/24) + 1 return say.S(say.HabitSpanDays, map[string]string{ - "n": strconv.Itoa(days), "word": pluralDaysRU(days), + "n": strconv.Itoa(days), "word": say.Days(days), }) } -// pluralDaysRU — the Russian count form of "день" for n. -func pluralDaysRU(n int) string { - switch { - case n%100 >= 11 && n%100 <= 14: - return "дней" - case n%10 == 1: - return "день" - case n%10 >= 2 && n%10 <= 4: - return "дня" - default: - return "дней" - } -} - // maxRecited bounds a spoken profile. A list of fifteen habits read aloud is // not an answer; the most established few are. const maxRecited = 5 diff --git a/internal/memory/behavior_test.go b/internal/memory/behavior_test.go index cc75e71..5ad342e 100644 --- a/internal/memory/behavior_test.go +++ b/internal/memory/behavior_test.go @@ -126,7 +126,7 @@ func TestProfileFormatRUPersona(t *testing.T) { if got != want { t.Errorf("got %q\nwant %q", got, want) } - if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") { + if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") { t.Errorf("an unknown weekday reads %q", empty) } // Persona: she addresses him informally, never in the masculine about @@ -317,16 +317,19 @@ func TestOverallNamesThePeriod(t *testing.T) { } } -// The no-data weekday answer is about him, not about her. "у меня пока нет -// ничего постоянного" answers a question nobody asked. -func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) { +// The no-data weekday answer is about his week, not about her. "у меня пока нет +// ничего постоянного" answers a question nobody asked. It does say "я не вижу", +// because what she is reporting is the state of her records — and it names the +// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя" +// came out as filler, the day carries it). +func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) { p := BuildProfile(nil, behaviorNow()) got := p.FormatWeekdayRU(time.Wednesday) if strings.Contains(got, "у меня") { t.Errorf("got %q", got) } - if !strings.Contains(got, "у тебя") { - t.Errorf("got %q, want an answer about him", got) + if !strings.Contains(got, "средам") { + t.Errorf("got %q, want the day he asked about", got) } } @@ -344,17 +347,6 @@ func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) { } } -func TestPluralDaysRU(t *testing.T) { - for _, c := range []struct { - n int - want string - }{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} { - if got := pluralDaysRU(c.n); got != c.want { - t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want) - } - } -} - // "по выходным" is a question about two days, answered as two days. func TestFormatWeekendRU(t *testing.T) { now := behaviorNow() diff --git a/internal/morning/plan_test.go b/internal/morning/plan_test.go index c7b282e..3448412 100644 --- a/internal/morning/plan_test.go +++ b/internal/morning/plan_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" "time" + "unicode" "github.com/kami/maven/internal/store" ) @@ -137,7 +138,7 @@ func TestPlanFormatRU(t *testing.T) { want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " + "10:30 — утро — осталось: витамины; " + "похоже, 14:00 — Планёрка @ 14:00-14:30; " + - "18:30 — позвонить маме." + "18:30 — позвонить маме" if got != want { t.Errorf("got %q\nwant %q", got, want) } @@ -215,3 +216,26 @@ func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) { t.Fatalf("got %+v", p.Items) } } + +// plan_uncertain nests one rendered line inside another sentence: «похоже, » in +// front of what this loop already built. That reads as one sentence only while +// what arrives starts lowercase, and it does here because every line starts with +// the clock time. A capital after the hedge would be «похоже, Планёрка». +func TestTheUncertainHedgeRunsIntoLowercase(t *testing.T) { + now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC) + p := Plan{Date: now, Items: []PlanEntry{ + {At: planAt(now, 14, 0), Text: "Планёрка", Kind: PlanEvent, Uncertain: true}, + }} + got := p.FormatRU() + const hedge = "похоже, " + i := strings.Index(got, hedge) + if i < 0 { + t.Fatalf("%q does not hedge an uncertain item", got) + } + for _, r := range got[i+len(hedge):] { + if unicode.IsUpper(r) { + t.Fatalf("the hedge runs into a capital: %q", got) + } + break + } +} diff --git a/internal/phraser/acts_ru_v1.json b/internal/phraser/acts_ru_v1.json index e7597f2..00908f0 100644 --- a/internal/phraser/acts_ru_v1.json +++ b/internal/phraser/acts_ru_v1.json @@ -33,18 +33,23 @@ "variants": ["выполнить «{name}» для {name_entity}? да или нет."] }, "act_which": { + "fixed": true, "variants": ["какую команду для {name}: {items}?"] }, "act_fail": { + "fixed": true, "variants": ["не получилось выполнить команду."] }, "act_fail_out": { + "fixed": true, "variants": ["не получилось выполнить команду: {out}"] }, "act_fail_entity": { + "fixed": true, "variants": ["не получилось выполнить команду для {name}."] }, "act_server_down": { + "fixed": true, "variants": ["инструмент есть, но сервер не подключён."] }, "act_withdrawn": { @@ -52,66 +57,84 @@ "variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."] }, "act_needs_args": { + "fixed": true, "variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."] }, "eco_denied": { + "fixed": true, "variants": ["{name} отклоняет доступ, проверь токен."] }, "eco_down": { + "fixed": true, "variants": ["{name} не отвечает, попробуй ещё раз."] }, "eco_ambiguous": { + "fixed": true, "variants": ["что именно: {items}?"] }, "eco_unknown_entity": { "variants": ["не знаю, что это.", "такого у меня нет."] }, "eco_no_nexus": { + "fixed": true, "variants": ["не с чем связать — Nexus не настроен."] }, "eco_about_what": { + "fixed": true, "variants": ["про что именно?"] }, "eco_recall": { + "fixed": true, "variants": ["я помню: {items}"] }, "attention_none": { + "fixed": true, "variants": ["ничего не требует внимания."] }, "attention_list": { + "fixed": true, "variants": ["требует внимания: {items}"] }, "attention_fail": { + "fixed": true, "variants": ["не могу сейчас узнать, что требует внимания."] }, "attention_none_entity": { "variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."] }, "attention_list_entity": { + "fixed": true, "variants": ["по «{name}»: {items}"] }, "attention_fail_entity": { + "fixed": true, "variants": ["не могу сейчас узнать, что требует внимания по «{name}»."] }, "changes_none": { + "fixed": true, "variants": ["изменений нет."] }, "changes_list": { + "fixed": true, "variants": ["изменения: {items}"] }, "changes_fail": { + "fixed": true, "variants": ["не могу сейчас узнать об изменениях."] }, "home_unreachable": { "variants": ["дом не отвечает.", "не достучалась до дома."] }, "home_empty": { + "fixed": true, "variants": ["дом ничего не отдаёт."] }, "home_on": { + "fixed": true, "variants": ["включено: {items}"] }, "home_dark": { + "fixed": true, "variants": ["не отвечают: {count} {word}."] } } diff --git a/internal/phraser/fixedness_test.go b/internal/phraser/fixedness_test.go new file mode 100644 index 0000000..1372798 --- /dev/null +++ b/internal/phraser/fixedness_test.go @@ -0,0 +1,38 @@ +package phraser + +import ( + "math/rand" + "testing" +) + +// The other four families, held to the rule internal/say holds the fifth to: +// one variant means fixed. Reported per family, because a failure that names +// "some file" is a failure nobody acts on. +func TestEverySingleVariantEntryIsFixed(t *testing.T) { + f, err := LoadFallbacks(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadFallbacks: %v", err) + } + a, err := LoadAcks(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadAcks: %v", err) + } + q, err := LoadQueries(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadQueries: %v", err) + } + acts, err := LoadActs(rand.NewSource(1)) + if err != nil { + t.Fatalf("LoadActs: %v", err) + } + for name, keys := range map[string][]string{ + "fallbacks": f.d.UnfixedSingles(), + "acks": a.d.UnfixedSingles(), + "queries": q.d.UnfixedSingles(), + "acts": acts.d.UnfixedSingles(), + } { + if len(keys) > 0 { + t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys) + } + } +} diff --git a/internal/phraser/plural.go b/internal/phraser/plural.go index 23ad704..c9c76f7 100644 --- a/internal/phraser/plural.go +++ b/internal/phraser/plural.go @@ -1,47 +1,19 @@ package phraser -// The counted noun, in the form the number in front of it demands. +// The counted noun, for callers that already speak through this package. // -// 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, which meant the -// weather line said "градусов" for every temperature and was wrong for 1-4 and -// for every number ending in 1-4. One helper, every count site (Vikunja #521). +// The rule itself lives in internal/say, next to the line files that carry the +// {word} placeholder, because internal/say sits under phraser in the import +// graph and internal/memory and internal/tasks need it too. These are the same +// three functions under the names cmd/mavend already calls. -import "math" +import "github.com/kami/maven/internal/say" -// 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 - } -} +// CountWord picks between the three forms a Russian count needs. +func CountWord(n int, one, few, many string) string { return say.CountWord(n, one, few, 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)), "градус", "градуса", "градусов") -} +// Degrees — the noun for a temperature. +func Degrees(temp float64) string { return say.Degrees(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, "устройство", "устройства", "устройств") -} +func Devices(n int) string { return say.Devices(n) } diff --git a/internal/phraser/query_ru_v1.json b/internal/phraser/query_ru_v1.json index 943d5bd..a86f376 100644 --- a/internal/phraser/query_ru_v1.json +++ b/internal/phraser/query_ru_v1.json @@ -24,6 +24,7 @@ "variants": ["не знаю — не нашла у тебя такой записи."] }, "fact_when": { + "fixed": true, "variants": ["записала это {when}"] }, "fact_value": { @@ -40,9 +41,11 @@ "variants": ["эта страница закрыта для чтения — robots.txt не разрешает."] }, "page_empty": { + "fixed": true, "variants": ["страница открылась, но читать там нечего."] }, "feeds_off": { + "fixed": true, "variants": ["ленты не настроены."] }, "feeds_new": { @@ -58,6 +61,7 @@ "variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."] }, "weather_off": { + "fixed": true, "variants": ["погода не настроена."] }, "weather_nolocation": { @@ -68,27 +72,35 @@ "variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."] }, "fail_plan": { + "fixed": true, "variants": ["не получилось собрать план."] }, "fail_notes": { + "fixed": true, "variants": ["не получилось посмотреть записи."] }, "fail_feeds": { + "fixed": true, "variants": ["не получилось посмотреть ленты."] }, "fail_calendar": { + "fixed": true, "variants": ["не получилось проверить календарь."] }, "fail_weather": { + "fixed": true, "variants": ["не получилось узнать погоду."] }, "fail_answer": { + "fixed": true, "variants": ["не получилось найти ответ."] }, "fail_page": { + "fixed": true, "variants": ["не получилось прочитать страницу."] }, "fail_netscan": { + "fixed": true, "variants": ["не получилось просканировать сеть."] } } diff --git a/internal/say/plural.go b/internal/say/plural.go new file mode 100644 index 0000000..3014447 --- /dev/null +++ b/internal/say/plural.go @@ -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, "день", "дня", "дней") +} diff --git a/internal/say/plural_test.go b/internal/say/plural_test.go new file mode 100644 index 0000000..00a6f94 --- /dev/null +++ b/internal/say/plural_test.go @@ -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) + } + } +} diff --git a/internal/say/summary.go b/internal/say/summary.go index ac70341..e4f6791 100644 --- a/internal/say/summary.go +++ b/internal/say/summary.go @@ -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}"}, diff --git a/internal/say/summary_ru_v1.json b/internal/say/summary_ru_v1.json index c387290..daed24e 100644 --- a/internal/say/summary_ru_v1.json +++ b/internal/say/summary_ru_v1.json @@ -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": { diff --git a/internal/say/summary_test.go b/internal/say/summary_test.go index be1bd13..7bce545 100644 --- a/internal/say/summary_test.go +++ b/internal/say/summary_test.go @@ -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) + } +} diff --git a/internal/tasks/rank.go b/internal/tasks/rank.go index 8121102..ca4b72f 100644 --- a/internal/tasks/rank.go +++ b/internal/tasks/rank.go @@ -121,10 +121,12 @@ func score(it Item, now time.Time) (float64, string) { } total += scoreOverdue + bonus reason = say.S(say.ReasonOverdue, nil) - if late == 1 { - reason = say.S(say.ReasonOverdueDay, nil) - } else if late > 1 { - reason = say.S(say.ReasonOverdueDays, map[string]string{"n": strconv.Itoa(late)}) + if late > 0 { + // One day needs no arm of its own: «просрочено на 1 день» + // falls out of the count helper like every other number. + reason = say.S(say.ReasonOverdueDays, map[string]string{ + "n": strconv.Itoa(late), "word": say.Days(late), + }) } case days == 0: total += scoreDueToday @@ -134,7 +136,9 @@ func score(it Item, now time.Time) (float64, string) { reason = say.S(say.ReasonTomorrow, nil) case days <= 7: total += scoreDueWeek - reason = say.S(say.ReasonInDays, map[string]string{"n": strconv.Itoa(days)}) + reason = say.S(say.ReasonInDays, map[string]string{ + "n": strconv.Itoa(days), "word": say.Days(days), + }) default: total += scoreDueLater } @@ -223,7 +227,13 @@ func FormatRU(ranked []Ranked) string { })) } if len(cands) > 0 { + // Two sentences, and the first one ends on a joined list that carries + // whatever punctuation its last task had — usually none. So the break + // is the caller's to make, not the line file's (Vikunja #521). if b.Len() > 0 { + if !strings.HasSuffix(b.String(), ".") { + b.WriteString(".") + } b.WriteString(" ") } b.WriteString(say.S(say.TasksCandidates, map[string]string{ diff --git a/internal/tasks/rank_test.go b/internal/tasks/rank_test.go index 3364e27..6926521 100644 --- a/internal/tasks/rank_test.go +++ b/internal/tasks/rank_test.go @@ -123,10 +123,12 @@ func TestRankOverdueDaysAreCounted(t *testing.T) { if got[0].Text != "давно" { t.Errorf("order = %v, want the later-overdue task first", texts(got)) } - if got[0].Reason != "просрочено на 12 дн." { + if got[0].Reason != "просрочено на 12 дней" { t.Errorf("reason = %q", got[0].Reason) } - if got[1].Reason != "просрочено на день" { + // One day goes through the same entry as twelve: «на 1 день» is what the + // count helper says, so there is no reason_overdue_day any more. + if got[1].Reason != "просрочено на 1 день" { t.Errorf("reason = %q", got[1].Reason) } } @@ -141,7 +143,7 @@ func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) { if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") { t.Errorf("reply = %q", got) } - if !strings.Contains(got, "не подтвердил: продлить страховку") { + if !strings.Contains(got, "не подтверждал: продлить страховку") { t.Errorf("candidate not named as unconfirmed: %q", got) } // A candidate's due date is Maven's reading of a mail, not his statement. @@ -178,7 +180,7 @@ func TestFormatRUEmpty(t *testing.T) { // A due date read back from the store is a UTC instant, so comparing calendar // days in ITS location put every date a day out east of Greenwich: the row said -// "сегодня" for a task due tomorrow, and "просрочено на день" on the due date +// "сегодня" for a task due tomorrow, and "просрочено на 1 день" on the due date // itself while the due column one cell over said otherwise. func TestRankComparesDaysInTheCallersLocation(t *testing.T) { tz := time.FixedZone("UTC+4", 4*3600) -- 2.52.0 From 6fba4d69315a295cb4f02e6841fcbe019227275c Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 16:47:23 +0400 Subject: [PATCH 7/7] 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) } } } -- 2.52.0