Compare commits

..

9 Commits

Author SHA1 Message Date
claude 23d89b2831 plural service_down nudges agree with the count (V-534)
Two services down read "Мониторинг сообщает: nginx, paperless лежит." — a list
dropped into the singular sentence. Russian agrees the verb with the subject,
so the noun, the verb and the adjective all have to move.

A family may now carry a second set named <rule>_many, used when {service}
holds more than one name. pluralFamily picks it; a family with no _many set is
returned unchanged, so adding one elsewhere is a data change. Only service_down
has one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:17:30 +04:00
claude 06ddf41228 service_down nudges name the service again (V-534)
nudgeValues filled {service} from State.Fact("service_down"), an exact key
mavpoll stopped writing when per-monitor facts landed. The lookup could never
hit, so every variant carrying {service} was rejected as unfillable and the one
nameless variant was the only usable template, every time. A sev4 reaching him
on telegram said only that a service was down.

It now reads loop.DownServices, the same helper the rule fires on, so the
message cannot name a service that is up. Dropped the nameless variant and the
{since} one: service_down facts are keyed by monitor and the rule is
edge-triggered, so neither can fill. service_down joins routine and morning as
a family that always carries a name.

The tests passed through all of this because cand() built the pre-per-monitor
aggregate shape. downCand() builds what a tick actually produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:13:59 +04:00
claude 2e64c8ce94 qa plan: step 2 passes headless, and the sev4 that names nothing (V-287)
Chrome takes a fake microphone, so the browser half of push-to-talk runs
without a person. getUserMedia, MediaRecorder, the webm decode and the
resample all pass. The button is at /, not /dash, which this step had wrong.
The on-screen transcript shows + for every space: QueryEscape decoded with
decodeURIComponent. Filed as 533.

A real sev4 reached telegram with presence away. It named no service, which
is 534: nudgeValues fills {service} from an exact key mavpoll stopped writing
when per-monitor facts landed, so every named variant is rejected as
unfillable and the one nameless variant always wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 23:07:23 +04:00
claude 7695620a96 qa plan: presence arrives, and the state row that never gets written (V-287)
The desk_active poster is live on workpc, so 15 no longer blocks session 1
steps 7 and 8. What blocks them is that no rule's predicate is true: water
needs 3h since the fact step 2 just wrote, meal and break have no anchor.

Separately, SavePresenceState has no caller outside tests. The gate reads the
in-memory bucket so delivery is unaffected, but hysteresis never engages and
every presence readout shows away at score 0.00. Filed as 532.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 22:50:47 +04:00
claude 758fb6a3f0 qa plan: the 30s turn is unbounded whitespace in the grammar, not reasoning (V-287)
Corrects the cause recorded an hour ago. responseGrammar ends with
ws ::= [ \t\n]*, and * is unbounded, so the model emits { and then satisfies
ws with whitespace until max_tokens stops it.

Reproduced on a second Qwen3-1.7B with the same grammar and system prompt:
repeat_penalty 1.0 runs to 512 and returns finish_reason=length, 1.3 stops at
24, and bounding the rule to {0,4} stops at 33 three times out of three with
no penalty at all.

internal/llm.Req sends repeat_penalty and the replier sets 1.3, so that path
is protected by accident. chatReq in the phraser sends none, so PhraseChat,
PhraseQuery, PhraseNudge and PhraseReminder run at the default 1.0.

Two wrong guesses recorded so nobody repeats them: not reasoning tokens, the
probe returned reasoning_content of length 0; and not --cache-ram 512, which
is MiB of prompt cache against a token count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 22:35:05 +04:00
claude 0e75245205 qa plan: push-to-talk runs without a mic, and a spoken turn is 30s of reasoning (V-287)
Session 1 step 2 no longer needs a person. POST /api/ptt takes raw PCM16
16kHz mono, so the committed STT fixtures stand in for a microphone. Three
fixtures pass end to end: 200, real speech back, right intent.

Step 9 gets a cause. A spoken turn is 32-34s, of which one phrasing call is
30.0s. Both interactive calls decoded exactly 512 tokens, the chat cap, and
were truncated. The resident model is a Thinking variant and llamaArgs never
passes the enable_thinking:false that deploy/mavgpud.json passes for the
workstation. Filed as V-531.

Steps 7 and 8 cannot run. The morning routine is the only nudge source and the
dispatcher drops it on presence=away every time, which is V-15.

287's own ten QA steps were rewritten in Vikunja: all ten were mavwaked, which
does not run on homesrv by decision (V-463).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-04 22:25:28 +04:00
claude 8d816f47e9 Merge the QA plan reconcile (#170) 2026-08-04 20:02:17 +02:00
claude 4425ba112b qa plan: reconcile against the board, add the offload sitting (V-492)
The plan named every open QA task on 02-08-2026 and had drifted since. V-492,
the workstation offload, appeared nowhere in it, and neither did the word
offload. It is now a sitting in session 3 with the three card states, the two
things most likely to be wrong, and the one number the week is supposed to
produce. Note that workpc is training today, so the held state is available and
the free state is not.

Fourteen ids the plan named closed on 04-08-2026. Only 282 was actually written
into the text; it is gone, replaced by what remains, which is the desk_active
units on workpc rather than the script.

The header count is refreshed to 95 open and 35 QA, and now says to distrust
itself, because that is the line that goes stale first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:59:09 +04:00
claude a4d5155029 Merge the sweep tail: four files ask the dictionary (#169) 2026-08-04 19:25:10 +02:00
4 changed files with 320 additions and 29 deletions
+166 -14
View File
@@ -1,17 +1,21 @@
# QA plan: checking Maven properly
*Last verified: 2026-08-04 @ 58635f1. Living doc: correct it in place, do not append.*
*Last verified: 2026-08-04 @ 8d816f4. Living doc: correct it in place, do not append.*
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
Reconciled 2026-08-04 against the board, after the review stack merged.
42 of the 50 open Vikunja tasks are `QA:` tasks. They are verification work, not
build work. Most sat unverifiable while Maven was down for 11 days. That
blocker is gone.
The board holds 95 open tasks and 35 of them are `QA:` tasks. The ratio moved
because the build backlog grew, not because verification shrank. QA is
verification work, not build work, and most of it sat unverifiable while Maven
was down for 11 days. That blocker is gone.
The plan as written on 2026-08-01 named 40 task numbers. Ten open `QA:` tasks were
missing and two of the named ones had closed. Every open task now appears below,
the eight non-QA ones in the last two sections.
Every open `QA:` task appears below. Distrust the count in this header first. It
is right on the day it is written and wrong a week later.
Fourteen ids this plan used to name closed on 2026-08-04 and are gone from it. If
you cannot find one, check whether it closed before assuming the plan dropped it.
This plan orders them by what unblocks what. Do sessions 1 and 2 first. Almost everything
downstream assumes the voice loop works, and nobody has confirmed that since
@@ -84,8 +88,71 @@ session quality), **321** steps 3-5 (quiet mode), **288** (STT golden audio).
**288 is not blocked.** The fixtures are committed under `cmd/mavsttd/testdata/`
and `make test-stt-golden` runs today. This plan said otherwise until 02-08-2026.
Steps 1 and 3-6 were run on 02-08-2026 and pass. Steps 2 and 7-9 still need a
person at the box, because they need a microphone or a nudge to arrive.
Steps 1 and 3-6 were run on 02-08-2026 and pass.
**Step 2 no longer needs a person, and step 9 has a number now** (04-08-2026).
`POST /api/ptt` takes raw PCM16 16kHz mono and answers with audio plus an
`X-Reply-Text` header, so the committed STT fixtures stand in for a microphone:
```sh
tail -c +45 cmd/mavsttd/testdata/ru_query.wav > /tmp/q.pcm
curl -s --noproxy '*' -D /tmp/h -o /tmp/reply.pcm -X POST \
http://127.0.0.1:9201/api/ptt --data-binary @/tmp/q.pcm \
-H 'Content-Type: application/octet-stream' -m 180
```
That covers audio in → STT → router → phrasing → TTS audio out. It leaves only
browser microphone capture needing a person, and the wake path needing a machine.
Do not post `en_act.wav` without deciding first: it is a mutating act.
**Steps 7 and 8 still cannot run, but 15 is no longer the reason** (04-08-2026).
The desk presence poster is installed on workpc. It is a `maven-desk` systemd
user timer on a 60s cadence, gated by hypridle at 120s idle. `desk_active` facts
now arrive, and the first landed at 18:43.
What blocks the two steps now is that no rule wants to fire. `/trace` shows all
five at `predicate`, none inert:
| rule | sev | why it is false |
|---|---|---|
| water | 1 | needs ≥3h since the last `water` fact; step 2's `ru_fact` wrote one |
| meal | 1 | needs ≥6h since a `meal` fact; none exists |
| break | 2 | needs both `desk_active` and a `break` fact; `break` has never been written |
| service_down | 4 | no kuma monitor is down |
| netdata_critical | 3 | nothing critical |
So the honest way to run step 8 is to wait three hours after the last `water`
fact, or to write one antedated. Do not read the water rule's silence as a defect.
**The sev4 telegram reach works** (04-08-2026). Resuming a paused kuma monitor
for paperless, which is genuinely down, put a real `service_down` through the
whole path with presence away:
```
23:03 voicesink: no live voice session for service_down, falling through to away channels
/notifications: 19:03 | service_down | telegram | pending | Сервис перестал отвечать.
04.08 23:03 | nudge | service_down | telegram | sent | 23:03
```
`ChannelsFor(Sev4, Away)` returned telegram, the send succeeded, and the row
holds at `pending` because sev4 repeats until acked. The 15:51 row shows the
same rule reaching `acted` earlier, so the ack path works too.
The body was `Сервис перестал отвечать.`, which names no service. That is a bug
and it is deterministic, filed as **534**. `nudgeValues` fills `{service}` from
`State.Fact("service_down")`, an exact key mavpoll stopped writing when
per-monitor facts landed. Nine of the ten templates carry `{service}`, so all
nine are rejected as unfillable. The one nameless variant is left as the only
usable one, every time. The stub and LLM phrasers both call `loop.DownServices`
and get it right. The template path is the one that runs.
**Presence itself has a real defect, filed as 532.** `SavePresenceState` has no
caller outside tests, so the singleton row is never written. The gate is fine,
because it reads the bucket `GatherState` computes in memory each tick. Two
things follow. Hysteresis is dead, because `lastBucket` is always cold-start `Away`
and the 0.30-0.55 hold band never applies. And every presence readout lies:
`/dash` shows `away — score 0.00 (never)` with fresh `desk_active` facts arriving
every 60s. Do not trust that number while checking anything else here.
Steps 1 and 3-6 do not need a browser. `POST /api/chat` takes a form-encoded
`text=` field and a cookie jar, and answers with the rendered `/chat` page:
@@ -108,6 +175,33 @@ turns look misaligned when they are not.
back. This covers browser mic to STT to core to TTS as one path. It does
**not** cover the wake word or the voice-activity gate, and no step here
does — see below.
**Passes below the browser** (04-08-2026, three fixtures through `/api/ptt`):
HTTP 200, `audio/l16;rate=16000;channels=1`, and real speech back. `ru_query`
answered `на 04.08.2026 ничего нет.` in 3.82s of audio at RMS 3865, `ru_fact`
answered `отметила: water = выпил`, `ru_reminder` answered `хорошо, напомню.`
at `intent=reminder`.
**Passes in the browser too** (04-08-2026), and it needed no person. Headless
Chrome takes a fake microphone, so the whole browser half runs unattended:
```sh
chrome --headless=new --remote-debugging-port=9333 --remote-allow-origins='*' \
--use-fake-device-for-media-stream --use-fake-ui-for-media-stream \
--use-file-for-fake-audio-capture=cmd/mavsttd/testdata/ru_query.wav%noloop
```
Then drive it over the debug protocol: click `#btn`, wait, click again, read
`#status` and `#log`. That covers `getUserMedia`, `MediaRecorder`, the webm
decode and the hand-written resample to 16k Int16. It logged
`sending 188160 bytes`, which is 5.88s at 16k mono, and got the reply back.
**The button is on `/`, not `/dash`.** `handleVoice` serves it at the root
(`main.go:332`). `/dash` is the presence and fact dashboard and carries no
`#btn`. This step said `/dash` until 04-08-2026.
One defect fell out, filed as **533**. The reply logged as
`на+04.08.2026+ничего+нет.` The header is escaped with `url.QueryEscape`,
which writes a space as `+`, then decoded with `decodeURIComponent`, which
leaves `+` alone. Transcript only, the audio is fine.
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
@@ -128,6 +222,29 @@ turns look misaligned when they are not.
**First evidence, in text** (02-08-2026): nothing breaks, but answers wander
and stitch unrelated topics. Asked whether he should move flats, she opened
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
**The slowness now has a cause and a number** (04-08-2026). A spoken turn
takes 32 to 34 seconds. One phrasing call is 30.0s of that. STT is 1.0s
and routing is under 10ms. Both interactive calls decoded exactly 512 tokens,
which is the phrasing cap. Both were truncated, to produce a reply of
under 25 characters.
The cause is `responseGrammar`, not the model. Its last rule is
`ws ::= [ \t\n]*`, and `*` is unbounded, so the model emits `{` and then
satisfies `ws` with whitespace until `max_tokens` stops it. Reproduced on a
second server: at `repeat_penalty` 1.0 it runs to 512 and returns
`finish_reason=length`, at 1.3 it stops at 24. Bounding the rule to
`[ \t\n]{0,4}` gives a clean stop at 33 tokens three times out of three with
no penalty at all.
Only some callers are exposed. `internal/llm.Req` sends `repeat_penalty` and
the replier sets it to 1.3, so that path is protected by accident. `chatReq`
in the phraser sends no penalty, so `PhraseChat`, `PhraseQuery`,
`PhraseNudge` and `PhraseReminder` all run at the default 1.0. Filed as
**531**.
Two guesses were wrong on the way and are recorded so nobody repeats them.
It is not reasoning tokens: the probe returned `reasoning_content` of length
0, and the grammar constrains output from the first token. It is not the
`--cache-ram 512` limit either: that is MiB of prompt cache and the 512 that
was hit is a token count.
The wandering is a second thing and stays on 287.
**The wake path cannot be checked here, and that is now the decision rather
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
@@ -252,10 +369,12 @@ check that the failure floor catches a mid-session model death.
These need real use rather than a command, grouped by what one sitting covers.
**Morning and delivery** (**280**, **281**, **128**, **282**, **283**, **285**):
**Morning and delivery** (**280**, **281**, **128**, **283**, **285**):
open `/morning`, walk the seven required behaviours, then check the four
interruption outcomes and the digest gap. **282** needs the `desk_active` script
enabled on the desk PC first, which is **15** and needs you at that machine.
interruption outcomes and the digest gap. The presence half of this sitting
cannot run. `desk-active.sh` is on workpc, but no systemd user unit enables it,
so no `desk_active` fact has ever been written. That is **15** and needs you at
that machine.
**283** is the event intake envelope every reach shares, so a delivery check
exercises it whether you name it or not. **285** is not verification: the bridge
framework works and the remaining ask is more adapters. Decide which reach comes
@@ -381,6 +500,36 @@ With Praxis stopped the card reads `praxis — unreachable` while Nexus and Hexi
keep rendering. On `docker start` the card returns to `nothing needs attention.`
with no mavend restart. Independent degradation and recovery both hold.
**Workstation offload** (**492**): never run, and added to this plan on
2026-08-04. It covers **485**, which shipped in PR #97. Three states, one rule:
silent when the workstation would only do the job better, named when the resident
model cannot do the job at all.
1. **Card free.** mavgpud 200, llama-server holding gemma-4-12b. A routing turn
and a phrased reply both complete through the workstation. Confirm that from
the mavgpud request log, not from the answer sounding good. Nothing in the
answer says where it was phrased.
2. **Card held.** Start a training run so mavgpud yields and answers 503. The same
turns complete on Qwen3-1.7B with no mention of the fallback. Then kill the
card mid-utterance, with a request in flight. That is the case no unit test
reaches and the one most likely to hang.
3. **Machine asleep.** Suspend workpc. It must be indistinguishable from held.
Bring it back and confirm the prober re-admits it inside one 15s interval, with
no mavend restart.
Two things are likely wrong. A remote that accepts the connection and then never
answers is worse than a 503. `timeout` is 90s, so measure what a turn waits. And
two models mean two prompt renderings: `check_prompt_parity.py` guards Go against
the relabelling prompt, not gemma against Qwen, so confirm `{"response","mood"}`
parses from both.
**workpc is running training as of 2026-08-04**, so the held state is available
today and the free state is not. Run step 2 first, out of order.
Write down one number at the end. Read the mavgpud journal and record the
fraction of a working week the card is free. That is what **488** left open, and
it decides whether the offload is worth carrying.
**Operations** (**249**, **250**): both ran 02-08-2026. The code is correct and
neither lever can be pulled on this box. See **477**.
@@ -549,7 +698,7 @@ Not QA. These are blocked on a decision or a credential only you have.
| # | what |
|---|---|
| 16 | Create the Kuma API key. `-kuma-key uk5_mavpoll-key` in `docker-compose.yml` is still the placeholder. |
| 15 | Deploy `desk_active` on the desk PC. Blocks **282**. |
| 15 | Enable the `desk_active` units on workpc. The script is there; the timer is `not-found`, so the strongest presence signal writes nothing. Blocks the presence half of session 3. |
| 122 | Finish the CPT run for Qwen3-1.7B. The persona fix depends on it. |
| 355 | Deploy the Hexis auth change. Was blocked on Maven being under construction, which it no longer is. The client half is vendored and wired. |
| 357 | Decide whether entity-existence validation is the permanent target guard or whether blessing lands in Nexus. |
@@ -583,8 +732,11 @@ where they land, so the board stops reading as 50 things Maven owes.
31ms. The router buys about 4 points of accuracy for four orders of magnitude
of latency. Whether that still earns its place is now an open question.
4. Housekeeping. Cheap, and it makes the remaining backlog honest.
5. Session 3, split whichever way suits you. All five sittings ran on
5. Session 3, split whichever way suits you. Five of its six sittings ran on
02-08-2026. Read the per-sitting notes before repeating any of them.
6. The workstation offload sitting (**492**), which has never run. It is last
because it is newest, not because it matters least. It is the one sitting whose
subject changes state on its own.
The next thing to fix is not in this plan. Four defects say the same sentence:
a capability is built and no utterance reaches it. **466** (a clarify is global),
+33 -6
View File
@@ -94,7 +94,7 @@ func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (deliv
// template fits it uses the plain per-rule fallback.
func (t *NudgeTemplates) Nudge(c loop.Candidate) (body, mood string) {
rule := c.Rule.Name
family := t.family(rule)
family := t.pluralFamily(t.family(rule), c)
set, ok := t.file.Rules[family]
if !ok {
return fallbackNudge(c), "neutral"
@@ -155,6 +155,25 @@ func (t *NudgeTemplates) family(rule string) string {
return "default"
}
// pluralFamily swaps in the plural wording when {service} will hold a list.
// Russian agrees the verb with the subject, so one set of templates cannot
// serve both: "Сервис paperless не отвечает" and "Сервисы nginx, paperless не
// отвечают" differ in the noun, the verb and the adjective. Filling a list into
// the singular text is the kind of near-miss that reads as machine-written.
//
// Only service_down has a plural form today. A family with no "_many" set in
// the file is returned unchanged, so adding one is a data change.
func (t *NudgeTemplates) pluralFamily(family string, c loop.Candidate) string {
if len(loop.DownServices(c.State)) < 2 {
return family
}
many := family + "_many"
if _, ok := t.file.Rules[many]; ok {
return many
}
return family
}
// placeholderRE — the {name} slots a template may use.
var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`)
@@ -165,17 +184,25 @@ func nudgeValues(c loop.Candidate) map[string]string {
vals := map[string]string{}
rule := c.Rule.Name
// {service} — one fact per kuma monitor, keyed "service_down:<name>", so
// the name lives in the key SUFFIX and there is no fact called plain
// "service_down" to read. loop.DownServices is the same helper the rule
// fired on, which is what stops the message naming a service that is up.
// This used to read c.State.Fact(rule) — the pre-per-monitor aggregate —
// and so never filled, leaving the one nameless variant as the only
// fillable template every time (Vikunja #534).
if down := loop.DownServices(c.State); len(down) > 0 {
vals["service"] = strings.Join(down, ", ")
}
// {since} — only at hour scale. Below an hour the phrase would be minutes,
// and none of the templates read well with "сорок минут".
// and none of the templates read well with "сорок минут". service_down has
// no {since} to offer: its facts are keyed by monitor, and the rule is
// edge-triggered, so it fires on the transition rather than hours later.
if d, ok := c.State.Since(rule); ok && d >= time.Hour {
if s := ruSinceWords(d); s != "" {
vals["since"] = s
}
}
// {service} — the aggregate fact's key carries the service name.
if f, ok := c.State.Fact(rule); ok && f.Key != "" && f.Key != rule {
vals["service"] = f.Key
}
// {what} — the Russian suffix of "routine:таблетки" / "morning:утро".
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
vals["what"] = rule[i+1:]
+106 -6
View File
@@ -11,6 +11,102 @@ import (
"github.com/kami/maven/internal/store"
)
// downCand builds a service_down candidate the way a tick actually does it:
// one fact per kuma monitor under the prefix, carrying the source and value
// loop.DownServices checks. The old cand() shape wrote a single fact keyed
// plain "service_down", which mavpoll stopped producing, and that is why the
// tests passed through the whole of #534.
func downCand(names ...string) loop.Candidate {
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
st := loop.State{Now: now, Facts: map[string]store.Fact{}}
for _, n := range names {
key := loop.ServiceDownPrefix + n
st.Facts[key] = store.Fact{
Key: key, Ts: now.Add(-3 * time.Minute),
Source: loop.ServiceDownSource, Value: `"down"`,
}
}
return loop.Candidate{
Rule: loop.Rule{Name: "service_down", Severity: loop.Sev4},
Severity: loop.Sev4, State: st,
}
}
// The nudge he reads on telegram must name what broke. It is a sev4 that
// reaches him away from the box, so "a service is down" costs him a trip to
// kuma to learn anything at all.
func TestNudgeNamesTheDownService(t *testing.T) {
// Lowercased before matching: a name that opens the sentence is
// capitalized by capitalizeFirst, which is wanted.
nt := newTestTemplates(t, 5)
for i := 0; i < 40; i++ {
body, _ := nt.Nudge(downCand("paperless"))
if !strings.Contains(strings.ToLower(body), "paperless") {
t.Fatalf("body does not name the service: %q", body)
}
}
// Two down: both named, in the key order the rule itself uses.
for i := 0; i < 40; i++ {
body, _ := nt.Nudge(downCand("nginx", "paperless"))
low := strings.ToLower(body)
if !strings.Contains(low, "nginx") || !strings.Contains(low, "paperless") {
t.Fatalf("body drops a service: %q", body)
}
}
}
// Russian agrees the verb with the subject, so a list of services cannot go
// into the singular sentence. One down takes the singular set, two or more
// take service_down_many.
func TestNudgeAgreesWithTheServiceCount(t *testing.T) {
nt := newTestTemplates(t, 9)
// "упал " keeps its trailing space: "упали" starts with "упал", and the
// plural must not read as the singular by prefix.
singular := []string{"не отвечает", "недоступен", "лежит", "упал "}
plural := []string{"не отвечают", "недоступны", "лежат", "упали"}
for i := 0; i < 60; i++ {
body, _ := nt.Nudge(downCand("paperless"))
if !containsAny(body, singular) {
t.Fatalf("one down, no singular verb: %q", body)
}
if containsAny(body, plural) {
t.Fatalf("one down, plural wording: %q", body)
}
}
for i := 0; i < 60; i++ {
body, _ := nt.Nudge(downCand("nginx", "paperless"))
if !containsAny(body, plural) {
t.Fatalf("two down, no plural verb: %q", body)
}
if containsAny(body, singular) {
t.Fatalf("two down, singular wording: %q", body)
}
}
}
func containsAny(s string, subs []string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// Nothing down means no template fits, and the fallback answers rather than
// the picker inventing a name.
func TestNudgeServiceDownWithoutFacts(t *testing.T) {
nt := newTestTemplates(t, 5)
body, mood := nt.Nudge(downCand())
if body != "Сервис не отвечает." {
t.Fatalf("fallback body %q", body)
}
if mood != "neutral" {
t.Fatalf("mood %q", mood)
}
}
// cand builds a candidate the way a tick would.
func cand(rule string, sinceMin int, factKey string) loop.Candidate {
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
@@ -36,7 +132,7 @@ func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates {
func TestNudgeTemplatesLoad(t *testing.T) {
nt := newTestTemplates(t, 1)
for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning", "default"} {
for _, rule := range []string{"water", "meal", "break", "service_down", "service_down_many", "netdata_critical", "routine", "morning", "default"} {
set, ok := nt.file.Rules[rule]
if !ok {
t.Errorf("no templates for %q", rule)
@@ -46,8 +142,12 @@ func TestNudgeTemplatesLoad(t *testing.T) {
t.Errorf("%s: only %d variants", rule, len(set.Variants))
}
// Every rule needs one variant that needs no value, or a candidate
// without context has nothing to say. routine and morning are exempt:
// they always carry a name and must always say it.
// without context has nothing to say. routine, morning and
// service_down are exempt: they always carry a name and must always
// say it. service_down's predicate cannot fire without a down fact,
// so loop.DownServices always has something to fill {service} with,
// and the nameless variant it used to carry was the bug (#534) —
// {service} never filled, so that variant was the only fillable one.
plain := 0
seen := map[string]bool{}
for _, v := range set.Variants {
@@ -59,7 +159,7 @@ func TestNudgeTemplatesLoad(t *testing.T) {
}
seen[v] = true
}
if plain == 0 && rule != "routine" && rule != "morning" {
if plain == 0 && rule != "routine" && rule != "morning" && !strings.HasPrefix(rule, "service_down") {
t.Errorf("%s: every variant needs a placeholder value", rule)
}
}
@@ -102,8 +202,8 @@ func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
cand("water", 0, ""), // no duration
cand("water", 30, ""), // under an hour
cand("water", 200, ""), // hours
cand("service_down", 3, "vaultwarden"),
cand("service_down", 3, ""), // no service name
downCand("vaultwarden"),
downCand(), // nothing down: the fallback answers
cand("routine:таблетки", 0, ""),
cand("morning:утро", 0, ""),
cand("unknown_rule", 0, ""),
+15 -3
View File
@@ -5,7 +5,8 @@
"Hand-written Russian nudges. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives (выпейте), never он/его about him.",
"One short sentence. No questions, no emoji, no pet names, no emotional support.",
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine and morning: those only exist for rules like routine:таблетки that always carry a name, and a routine nudge that drops the name is useless.",
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine, morning and service_down: those only exist for rules that always carry a name, and one that drops the name is useless.",
"A rule may carry a second set named <rule>_many, used when {service} holds more than one name. Russian agrees the verb with the subject, so the plural needs its own wording rather than a list dropped into the singular sentence. Only service_down has one.",
"mood must be one of: neutral, happy, thinking, tired, confused."
],
"rules": {
@@ -62,13 +63,24 @@
"{service} не отвечает, сервис нужно поднимать.",
"Сервис {service} недоступен.",
"Проверь {service}: сервис не отвечает.",
"Сервис перестал отвечать.",
"Сервис {service} лежит, нужно смотреть.",
"{service} не отвечает уже {since}.",
"Мониторинг сообщает: {service} лежит.",
"Сервис {service} не отвечает, посмотри логи."
]
},
"service_down_many": {
"mood": "neutral",
"variants": [
"Сервисы {service} не отвечают.",
"{service} упали — сервисы не отвечают.",
"{service} не отвечают, сервисы нужно поднимать.",
"Сервисы {service} недоступны.",
"Проверь {service}: сервисы не отвечают.",
"Сервисы {service} лежат, нужно смотреть.",
"Мониторинг сообщает: {service} лежат.",
"Сервисы {service} не отвечают, посмотри логи."
]
},
"netdata_critical": {
"mood": "neutral",
"variants": [