Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b90952e55 |
@@ -7,20 +7,9 @@ talking over unix sockets; one resident small model for routing + phrasing; whis
|
|||||||
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`,
|
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`,
|
||||||
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
||||||
|
|
||||||
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
**Resident model:** currently **Qwen3.5-0.8B** (`Q4_K_M`), the smallest checkpoint in the gguf
|
||||||
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
|
library, picked for CPU/iGPU latency. The **target** is the locally CPT'd **Qwen3-1.7B**; that
|
||||||
67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the
|
training is still in flight (Vikunja #122), so no such gguf exists yet. Model files live in
|
||||||
talk fixture. See `MODEL-BAKEOFF-31-07-2026.md`. It is a Thinking variant, so `n_ctx` is 4096
|
|
||||||
— reasoning tokens need the room, and 4096 is what the scores above were measured at.
|
|
||||||
|
|
||||||
The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight).
|
|
||||||
Stock already speaks good Russian; what it gets wrong is the persona — it writes `я рад`,
|
|
||||||
masculine, where Maven needs `рада`. That is what the CPT is for.
|
|
||||||
|
|
||||||
**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on 2026-07-31 and
|
|
||||||
both are unusable in Russian: the 350M routes at 5.2% (worse than guessing) and answers
|
|
||||||
"столица Франции?" with the invented non-word "Сторзит"; the 230M replies to Russian in
|
|
||||||
Spanish. Their strong published IFEval/BFCL numbers are English-only. Model files live in
|
|
||||||
`/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's
|
`/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's
|
||||||
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
||||||
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
||||||
|
|||||||
+3
-118
@@ -1,18 +1,8 @@
|
|||||||
# Resident model bake-off — 31-07-2026
|
# Resident model bake-off — 31-07-2026
|
||||||
|
|
||||||
**Outcome: the resident model is stock Qwen3-1.7B** (`UD-Q4_K_XL`). Two sweeps ran this
|
**Recommendation: keep Qwen3.5-0.8B.** LFM2.5-1.2B is worse at routing (52.6% vs 60.5%
|
||||||
evening and the second one changed the answer — read to the end before acting on any table
|
intent accuracy), and the loss is almost entirely Russian (18/61 vs 22/61 RU, while EN is a
|
||||||
here. [Second sweep](#second-sweep-same-evening--five-models-and-a-resident-model-change)
|
wash). It is also 2.4× slower. The Thinking variant is far worse again.
|
||||||
is the one that holds.
|
|
||||||
|
|
||||||
## First sweep — LFM2.5-1.2B vs Qwen3.5-0.8B
|
|
||||||
|
|
||||||
**Verdict, scoped to this pair: keep Qwen3.5-0.8B over LFM2.5-1.2B.** LFM2.5-1.2B is worse
|
|
||||||
at routing (52.6% vs 60.5% intent accuracy), and the loss is almost entirely Russian
|
|
||||||
(18/61 vs 22/61 RU, while EN is a wash). It is also 2.4× slower. The Thinking variant is
|
|
||||||
far worse again. This verdict still stands as written — it rejects LFM2.5-1.2B. It is
|
|
||||||
**not** a recommendation to keep 0.8B as the resident model; the second sweep replaced it
|
|
||||||
with Qwen3-1.7B.
|
|
||||||
|
|
||||||
Settles Vikunja **#278 / #250**.
|
Settles Vikunja **#278 / #250**.
|
||||||
|
|
||||||
@@ -109,108 +99,3 @@ thinking trace costs time without buying accuracy on a short enum classification
|
|||||||
Routing only. LFM2.5 might still phrase better, and phrasing is the resident model's other
|
Routing only. LFM2.5 might still phrase better, and phrasing is the resident model's other
|
||||||
job — that needs its own fixture. But routing is the load-bearing path and Maven is
|
job — that needs its own fixture. But routing is the load-bearing path and Maven is
|
||||||
Russian-first, so on the evidence here the switch is not worth making.
|
Russian-first, so on the evidence here the switch is not worth making.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Second sweep, same evening — five models, and a resident-model change
|
|
||||||
|
|
||||||
The sections above compared LFM2.5-1.2B against Qwen3.5-0.8B on routing and concluded
|
|
||||||
"the switch is not worth making". That still holds. This sweep asked a different
|
|
||||||
question — whether a *smaller* model could work, since LFM2.5's published
|
|
||||||
instruction-following scores beat Qwen3.5-0.8B badly — and answered it, plus found a
|
|
||||||
better resident model by accident.
|
|
||||||
|
|
||||||
**Outcome: the resident model is now stock Qwen3-1.7B.** Sub-500M is a dead end.
|
|
||||||
|
|
||||||
## Routing — 77 Russian cases, one run each
|
|
||||||
|
|
||||||
| model | on disk | llm-only (full) | llm-only (intent) | cascade + fallback |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| LFM2.5-230M-Q8_0 | 246 MB | 23.4% | 33.8% | 36.4% |
|
|
||||||
| LFM2.5-350M-Q8_0 | 379 MB | 2.6% | **5.2%** | 20.8% |
|
|
||||||
| Qwen3.5-0.8B-Q4_K_M | 527 MB | 36.4% | 59.7% | 61.0% |
|
|
||||||
| Qwen3.5-2B-UD-Q4_K_XL | 1.34 GB | 42.9% | 62.3% | 63.6% |
|
|
||||||
| **Qwen3-1.7B-UD-Q4_K_XL (stock)** | 1.13 GB | **44.2%** | **67.5%** | **72.7%** |
|
|
||||||
|
|
||||||
Qwen3-1.7B wins every column, including against a model 20% larger than it.
|
|
||||||
|
|
||||||
## Talk fixture — 27 cases, three runs each, idle box
|
|
||||||
|
|
||||||
| | Qwen3.5-0.8B | Qwen3-1.7B stock |
|
|
||||||
|---|---|---|
|
|
||||||
| composite | 13, 11, 8 | **20, 21, 18** |
|
|
||||||
| address | 21, 18, 18 | **26, 25, 23** |
|
|
||||||
| feminine | 27, 25, 26 | 26, 27, 26 |
|
|
||||||
| lang | 27, 27, 26 | 26, 27, 27 |
|
|
||||||
| ontopic | 16, 19, 19 | **22, 23, 23** |
|
|
||||||
| canned fallbacks | 8, 5, 6 | **0, 2, 0** |
|
|
||||||
|
|
||||||
This also fills the row `TALK-EVAL-31-07-2026.md` had to void for contamination:
|
|
||||||
**600ch/1024tok on Qwen3.5-0.8B scores 13, 11, 8.**
|
|
||||||
|
|
||||||
`address` is the headline. It sat at 18-22 of 27 on the 0.8B no matter how the prompt
|
|
||||||
was worded — the prompt explicitly forbids "вы" and the model writes `вашей`,
|
|
||||||
`подождите`, `делаете` anyway. That was read as "prompting is out of levers", and it
|
|
||||||
was really "0.8B is out of capacity". The 1.7B mostly holds the constraint.
|
|
||||||
|
|
||||||
The fallback column matters too: 5-8 of 27 turns on the 0.8B end in a hardcoded
|
|
||||||
`"не знаю."`, meaning it failed to emit parseable JSON about a quarter of the time.
|
|
||||||
The 1.7B does that 0-2 times.
|
|
||||||
|
|
||||||
## Latency — the long tail is not the Thinking block
|
|
||||||
|
|
||||||
| | p50 | p95 |
|
|
||||||
|---|---|---|
|
|
||||||
| Qwen3.5-0.8B | 2.4s, 2.9s, 2.0s | 17.4s, 17.6s, 17.4s |
|
|
||||||
| Qwen3-1.7B stock | 2.7s, 2.6s, 2.8s | 16.4s, 6.6s, 3.9s |
|
|
||||||
|
|
||||||
p50 is flat across a 2× size difference. The first instinct on seeing the 1.7B's
|
|
||||||
16s p95 was "that is the reasoning trace, cap it" — wrong. The 0.8B's p95 is a
|
|
||||||
consistent 17s and the 1.7B beat it in two of three runs. The tail is shared and
|
|
||||||
lives somewhere else. Do not spend time on `/no_think` on this evidence.
|
|
||||||
|
|
||||||
## Sub-500M: not close, and the benchmarks say otherwise for a reason
|
|
||||||
|
|
||||||
LFM2.5-350M publishes IFEval 76.96 against Qwen3.5-0.8B's 59.94, and BFCLv3 44.11
|
|
||||||
against 35.08 — better at instruction-following and structured output, at 2/3 the
|
|
||||||
size. Those numbers are real and they are **English**. Every benchmark in that
|
|
||||||
table except Multi-IF is English-only.
|
|
||||||
|
|
||||||
In Russian, with a 300-token budget and temperature 0:
|
|
||||||
|
|
||||||
- **350M**, «Столица Франции? Ответь кратко.» → *«Сторзит в Париже.»* — `Сторзит` is
|
|
||||||
not a word; it is invented morphology.
|
|
||||||
- **350M**, asked to read back a reminder → a fortune cookie about being attentive
|
|
||||||
and confident. No reminder in it.
|
|
||||||
- **230M**, «Привет, как дела?» → answered **in Spanish**.
|
|
||||||
|
|
||||||
The 230M beating the 350M six-fold on routing (33.8% vs 5.2%) is the other tell:
|
|
||||||
when the larger sibling collapses like that it is format compliance failing, not
|
|
||||||
reasoning.
|
|
||||||
|
|
||||||
This is a pretraining gap, not a fine-tuning gap. Teaching Russian to a 350M from
|
|
||||||
near-zero is not an afternoon on a Colab, which was the premise worth checking.
|
|
||||||
|
|
||||||
## Why this vindicates the 1.7B CPT
|
|
||||||
|
|
||||||
Stock Qwen3-1.7B, untrained and unprompted, answers all three probes in fluent
|
|
||||||
correct Russian. What it gets wrong is the persona: *«Привет! Я рад, что ты здесь»*
|
|
||||||
— `рад` is masculine and Maven needs `рада`. That is the right kind of remaining
|
|
||||||
problem, and it is exactly what the CPT (Vikunja #122) is for.
|
|
||||||
|
|
||||||
The 1.7B was the correct model choice. What was wrong was treating it as a
|
|
||||||
**blocker**: stock already beats what was deployed, so it ships now and gets
|
|
||||||
swapped again when the CPT lands.
|
|
||||||
|
|
||||||
## Caveats
|
|
||||||
|
|
||||||
- Routing is one run per model, not three. The gaps between families are far larger
|
|
||||||
than the run-to-run spread seen on the talk fixture, but the 2B-vs-1.7B gap (62.3
|
|
||||||
vs 67.5) is not safe to call on one run.
|
|
||||||
- The routing numbers only reach production once the LLM router is wired on. It is
|
|
||||||
still `nil`.
|
|
||||||
- `/mnt/hdd1/llms/LFM2.5/Qwen3-1.7B-UD-Q4_K_XL.gguf` is a 293 MB truncated download
|
|
||||||
in the wrong directory. The good 1.13 GB copy is in `qwen3/`. Delete the stray one.
|
|
||||||
- Harness: `scratchpad/bakeoff.sh`, one server at a time, health-checked before each
|
|
||||||
run, `/v1/models` recorded per run. Never run two LLM consumers at once — see the
|
|
||||||
contamination note in `TALK-EVAL-31-07-2026.md`.
|
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Conversational phrasing eval — 31-07-2026
|
||||||
|
|
||||||
|
Every score measured tonight, on the three paths the nudge eval never touched:
|
||||||
|
chat, query-with-notes, and general knowledge.
|
||||||
|
|
||||||
|
**Short version: the plumbing got fixed and the score barely moved.** Grammar and
|
||||||
|
Russian prompts together took the composite from ~9 to ~14 of 27. Everything
|
||||||
|
still failing is the model not knowing things or not holding a constraint, and
|
||||||
|
prompting is out of levers. Settles the measurement half of Vikunja #395 / #398 /
|
||||||
|
#400.
|
||||||
|
|
||||||
|
## How to reproduce
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# llama-server: -c 4096 -ngl 99 -t 6, model /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf
|
||||||
|
MAVEN_LLM_URL=http://127.0.0.1:18099 no_proxy=127.0.0.1,localhost \
|
||||||
|
deps/go/go/bin/go test -count=1 -timeout 40m \
|
||||||
|
-run TestLLMTalkBaseline ./internal/phraser/eval/ -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Three runs per configuration, always. The fixture is 27 cases, so one reply
|
||||||
|
changing moves the composite by 3.7 points — a single run cannot tell a real
|
||||||
|
change from sampling noise. This was learned the expensive way: an earlier claim
|
||||||
|
that "one nudge case fails every run" turned out to be three different cases
|
||||||
|
across three runs.
|
||||||
|
|
||||||
|
**Run the box otherwise idle.** See the contamination note at the bottom.
|
||||||
|
|
||||||
|
## Composite, per configuration
|
||||||
|
|
||||||
|
| config | overall /27 | chat /9 | query /9 | knowledge /9 | canned fallbacks |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| baseline, no grammar | 7, 12, 7 | 1, 1, 0 | 2, 4, 2 | 4, 7, 5 | 0, 0, 0 |
|
||||||
|
| + GBNF grammar (#398) | 14, 15, 8 | 1, 3, 0 | 5, 6, 3 | 8, 6, 5 | 0, 0, 0 |
|
||||||
|
| + Russian prompts (#400) | 11, 17, 15 | 1, 5, 3 | 5, 6, 8 | 5, 6, 4 | 0, 0, 0 |
|
||||||
|
| + truncation fix, 1000ch/768tok | 12, 13, 10 | 2, 2, 1 | 7, 7, 5 | 3, 4, 4 | 3, 3, 6 |
|
||||||
|
| + rebalanced, 600ch/1024tok | **void — contaminated** | | | | |
|
||||||
|
|
||||||
|
"Canned fallbacks" counts replies that came back as the hardcoded `"не знаю."`
|
||||||
|
or `"поговорили."`. It is not a check, it is a health signal: those strings mean
|
||||||
|
the phraser gave up, and the eval scores them as ordinary bad replies.
|
||||||
|
|
||||||
|
## Per-check
|
||||||
|
|
||||||
|
| check | no grammar | + grammar | + RU prompts | + truncation fix |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| nonempty | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||||
|
| ellipsis | 20, 19, 23 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||||
|
| lang | 13, 16, 15 | 23, 26, 26 | 25, 26, 25 | 26, 27, 27 |
|
||||||
|
| feminine | — | — | 25, 24, 26 | 25, 25, 27 |
|
||||||
|
| address | — | — | 21, 22, 22 | 22, 21, 22 |
|
||||||
|
| ontopic | — | — | 17, 24, 18 | 17, 19, 14 |
|
||||||
|
|
||||||
|
`nonempty` reading 27/27 everywhere is not good news — it was a broken check.
|
||||||
|
It tested for a non-blank string, so replies of literally `{` and `"15-16"`
|
||||||
|
passed it. Fixed on `overnight/fix-truncation`; it needs a letter now.
|
||||||
|
|
||||||
|
## What each change actually bought
|
||||||
|
|
||||||
|
**GBNF grammar (#398) — the biggest single win.** Qwen3.5-0.8B writes
|
||||||
|
`Thinking Process:` as plain text with no tags, `stripThink` only handles
|
||||||
|
`</think>`, so the JSON never closed and the plain-text fallback shipped the
|
||||||
|
literal reasoning. `ellipsis` went 20→27 and `lang` 13→26. The router had been
|
||||||
|
using a grammar for ages; the phraser asking nicely in the prompt was the
|
||||||
|
oversight.
|
||||||
|
|
||||||
|
**Russian prompts (#400) — modest, plus a large latency win.** Chat 1.3→3.0
|
||||||
|
average, query 4.7→6.3, knowledge 6.3→5.0. All inside the run-to-run spread, so
|
||||||
|
"probably better on the paths it targeted, not provable in three runs". p50
|
||||||
|
latency dropped from ~11.5s to ~2.3s and that part is consistent across all
|
||||||
|
three runs — shorter prompts, and she stopped emitting English reasoning first.
|
||||||
|
|
||||||
|
**Truncation fix — necessary, and did not help the score.** Two real bugs
|
||||||
|
(replies of `{`, and a `nonempty` check that passed them), both fixed, and the
|
||||||
|
composite went nowhere. A complete rambling wrong answer fails the same checks a
|
||||||
|
truncated one did. Worth doing anyway: the daemon was shipping `{` to a
|
||||||
|
text-to-speech voice.
|
||||||
|
|
||||||
|
## The truncation bug, since the cause was counter-intuitive
|
||||||
|
|
||||||
|
The grammar's `string ::= ... {0,400}` rule was the cause, not the token cap.
|
||||||
|
Measured against Qwen3.5-0.8B at three caps — 256, 768 and 2048 — the reply came
|
||||||
|
back **exactly 400 characters every time, cut mid-word** (`"Нужно записать и,"`).
|
||||||
|
|
||||||
|
Then I raised the bound to 1000 while the cap was 768 tokens and made it worse:
|
||||||
|
Russian runs ~1.5 characters per token here, so generation died on the *token*
|
||||||
|
cap instead, mid-object, and the new guard correctly refused it and shipped
|
||||||
|
`"не знаю."` — 3, 3 and 6 fallbacks per run, from zero. **The two limits have to
|
||||||
|
agree.** 600 characters needs ~400 tokens; the cap is 1024.
|
||||||
|
|
||||||
|
## Where the remaining failures live
|
||||||
|
|
||||||
|
`address` is stuck at 21-22 of 27 and `ontopic` at 14-19. Both resist prompting.
|
||||||
|
|
||||||
|
**The prompt now explicitly forbids exactly what she does.** It says never "вы",
|
||||||
|
use the singular — and she writes `вашей`, `подождите`, `делаете`, `хотите`,
|
||||||
|
`напишите`. Telling a 0.8B "never do X" does not work. Same for
|
||||||
|
`feminine`: `я готов`, `я понял`, `я нашел`, `я заметил`, `я сказал`.
|
||||||
|
|
||||||
|
**Some of `ontopic` is the fixture, not the model.** `chat-how-are-you` got
|
||||||
|
`"Привет! Я здесь, чтобы поговорить. Как дела сегодня?"` — a fine reply that
|
||||||
|
fails because `want_any` is `[норм, хорош, порядк, тут, работ]`. It fails in
|
||||||
|
every run, so it inflates the count. The `ontopic` column currently measures the
|
||||||
|
fixture as much as the model. Not fixed yet, deliberately: changing it would
|
||||||
|
break comparability with the runs above.
|
||||||
|
|
||||||
|
**Two replies worth reading, because they are not fixable by prompting:**
|
||||||
|
|
||||||
|
- Thunder and lightning: *"Скорость молнии — 8-10 тысяч километров в секунду, но
|
||||||
|
звук — 300 метров в секунду, что делает молнию громче."* Confidently wrong,
|
||||||
|
and it concludes lightning is *louder* rather than sound being *slower*.
|
||||||
|
- "расскажи обо мне": *"Ты — прекрасное существо, с душой и вниманием… Спасибо за
|
||||||
|
твою улыбку… О тебе — заповедь любви."* Sycophantic filler, zero information,
|
||||||
|
and precisely the "not a relationship" non-goal.
|
||||||
|
- Boiling an egg: `"15-16"` one run, `"1"` another. No unit, wrong number.
|
||||||
|
|
||||||
|
The first argues for reading instead of recalling (#403 — Kiwix retrieval scores
|
||||||
|
8/8 on the same questions given English keywords). The second and third argue
|
||||||
|
for templates on the paths where correctness matters (#392).
|
||||||
|
|
||||||
|
## Contamination note — how the last row got voided
|
||||||
|
|
||||||
|
I started the query-rewrite agent against the same llama-server the sweep was
|
||||||
|
using, and assumed contention would only affect latency. It did not. The
|
||||||
|
knowledge path collapsed to 0 of 9 with eight canned `"не знаю."` replies, p95
|
||||||
|
tripled to 23.7s, and **the report still said "0 errors"**.
|
||||||
|
|
||||||
|
That is Vikunja #397, and it is worse than filed: a merely *busy* server
|
||||||
|
produces a clean-looking report with a third of the fixture silently answering
|
||||||
|
`"не знаю."`. `PhraseChat` and `PhraseQuery` swallow every failure and return a
|
||||||
|
hardcoded string, so infrastructure trouble is indistinguishable from bad
|
||||||
|
phrasing in the score. The talk test guards the *start* and *end* of a run with
|
||||||
|
a model check, which catches a dead server but not a loaded one.
|
||||||
|
|
||||||
|
**Until #397 is fixed, treat any run made on a busy box as void.**
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- Re-run 600ch/1024tok clean, to fill the void row.
|
||||||
|
- Score `Qwen3.5-2B-UD-Q4_K_XL` (already at `/mnt/hdd1/llms/qwen3.5/`, never
|
||||||
|
measured) on this fixture and the router fixture. Not the 4B — too big for
|
||||||
|
this box, owner's call.
|
||||||
|
- Newer sub-500M candidates (LFM2.5 200M/300M) are worth a run for routing.
|
||||||
|
Note `MODEL-BAKEOFF-31-07-2026.md` found LFM2.5-**1.2B** worse than
|
||||||
|
Qwen3.5-0.8B at Russian routing and 2.4× slower — but those are a different,
|
||||||
|
older generation, so that result does not predict the small ones.
|
||||||
|
- Fix `chat-how-are-you`'s `want_any`, and re-baseline once, so `ontopic`
|
||||||
|
measures the model.
|
||||||
|
- #397 first if anything, since it decides whether any of the above is
|
||||||
|
trustworthy.
|
||||||
@@ -278,7 +278,6 @@ func run(args []string) error {
|
|||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
LLMNudges: cfg.Phraser.LLMNudges,
|
|
||||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
@@ -449,7 +448,6 @@ func run(args []string) error {
|
|||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
LLMNudges: cfg.Phraser.LLMNudges,
|
|
||||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
|
|||||||
+3
-4
@@ -6,12 +6,11 @@
|
|||||||
"state_dir": "/var/lib/maven",
|
"state_dir": "/var/lib/maven",
|
||||||
|
|
||||||
"phraser": {
|
"phraser": {
|
||||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
"model_path": "/opt/maven/models/llm/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf",
|
||||||
"bin_path": "llama-server",
|
"bin_path": "llama-server",
|
||||||
"n_gpu_layers": 99,
|
"n_gpu_layers": 99,
|
||||||
"n_ctx": 4096,
|
"n_ctx": 2048,
|
||||||
"timeout": "60s",
|
"timeout": "60s"
|
||||||
"llm_nudges": false
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"telegram": {
|
"telegram": {
|
||||||
|
|||||||
@@ -369,12 +369,6 @@ type PhraserConfig struct {
|
|||||||
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
||||||
NCtx int `json:"n_ctx,omitempty"`
|
NCtx int `json:"n_ctx,omitempty"`
|
||||||
Timeout Duration `json:"timeout,omitempty"`
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
// LLMNudges — let the model word nudges again. Off by default: nudges are
|
|
||||||
// worded from hand-written Russian templates now (the model broke the
|
|
||||||
// persona and invented units). Chat, query and reminder phrasing always go
|
|
||||||
// through the model regardless. See phraser.Config.LLMNudges.
|
|
||||||
LLMNudges bool `json:"llm_nudges,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||||
|
|||||||
@@ -35,27 +35,6 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nudges come from templates unless the config says otherwise.
|
|
||||||
func TestPhraserLLMNudgesDefaultsOff(t *testing.T) {
|
|
||||||
p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`)
|
|
||||||
c, err := Load(p)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
if c.Phraser.LLMNudges {
|
|
||||||
t.Error("llm_nudges defaults on; templates must be the default")
|
|
||||||
}
|
|
||||||
|
|
||||||
p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`)
|
|
||||||
c, err = Load(p)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
if !c.Phraser.LLMNudges {
|
|
||||||
t.Error("llm_nudges:true did not parse")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadDurationsParse(t *testing.T) {
|
func TestLoadDurationsParse(t *testing.T) {
|
||||||
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
||||||
c, err := Load(p)
|
c, err := Load(p)
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
package eval
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math/rand"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestTemplateNudges scores the hand-written Russian templates on the same
|
|
||||||
// fixture the model is scored on. No model, no network — it runs in milliseconds.
|
|
||||||
//
|
|
||||||
// The bar is every case, not most of them: the templates are hand-written, so a
|
|
||||||
// failure is a bug in one line of Russian, not model variance.
|
|
||||||
func TestTemplateNudges(t *testing.T) {
|
|
||||||
f, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
// Fixed seed: the score must not depend on which variant came up.
|
|
||||||
nt, err := phraser.NewNudgeTemplates(rand.NewSource(20260731))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewNudgeTemplates: %v", err)
|
|
||||||
}
|
|
||||||
rep, err := Score(context.Background(), "ru templates", nt, f)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Score: %v", err)
|
|
||||||
}
|
|
||||||
t.Log("\n" + rep.String())
|
|
||||||
t.Log("\n" + rep.Messages())
|
|
||||||
if rep.Passed != rep.Total {
|
|
||||||
t.Errorf("templates scored %d/%d, want every case:\n%s",
|
|
||||||
rep.Passed, rep.Total, rep.Failures())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestTemplateNudgesEverySeed — one seed passing could be luck. Every variant of
|
|
||||||
// every rule has to pass every check, so sweep seeds until each has been used.
|
|
||||||
func TestTemplateNudgesEverySeed(t *testing.T) {
|
|
||||||
f, err := Load()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
for seed := int64(0); seed < 60; seed++ {
|
|
||||||
nt, err := phraser.NewNudgeTemplates(rand.NewSource(seed))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewNudgeTemplates: %v", err)
|
|
||||||
}
|
|
||||||
rep, err := Score(context.Background(), "ru templates", nt, f)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Score: %v", err)
|
|
||||||
}
|
|
||||||
if rep.Passed != rep.Total {
|
|
||||||
t.Errorf("seed %d: %d/%d\n%s", seed, rep.Passed, rep.Total, rep.Failures())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,8 +35,6 @@ func newGrammarSpy(t *testing.T) *grammarSpy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// callAllPhrasingPaths hits every path that expects the JSON contract.
|
// callAllPhrasingPaths hits every path that expects the JSON contract.
|
||||||
// LLMNudges must be set on the phraser under test: nudges come from templates
|
|
||||||
// by default and never reach the model at all.
|
|
||||||
func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) {
|
func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -60,7 +58,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
|||||||
t.Fatal("responseGrammar is empty")
|
t.Fatal("responseGrammar is empty")
|
||||||
}
|
}
|
||||||
spy := newGrammarSpy(t)
|
spy := newGrammarSpy(t)
|
||||||
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||||
|
|
||||||
callAllPhrasingPaths(t, p)
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
@@ -76,7 +74,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
|||||||
|
|
||||||
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
||||||
spy := newGrammarSpy(t)
|
spy := newGrammarSpy(t)
|
||||||
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true})
|
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true})
|
||||||
|
|
||||||
callAllPhrasingPaths(t, p)
|
callAllPhrasingPaths(t, p)
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ type LLMPhraser struct {
|
|||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
|
|
||||||
// tmpl — the hand-written Russian nudges. Default path for nudges; see
|
|
||||||
// Config.LLMNudges. nil only if the template file failed to load.
|
|
||||||
tmpl *NudgeTemplates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -50,19 +46,6 @@ type Config struct {
|
|||||||
// nil ⇒ no block, the prompts stand alone.
|
// nil ⇒ no block, the prompts stand alone.
|
||||||
ContextBlock func() string
|
ContextBlock func() string
|
||||||
|
|
||||||
// LLMNudges puts the model back in charge of nudge wording.
|
|
||||||
//
|
|
||||||
// Off by default, and that is a deliberate deprecation of LLM-phrased
|
|
||||||
// nudges: hand-written templates (nudges_ru_v1.json) word every nudge now.
|
|
||||||
// A nudge has nothing to be creative about, and measured over many runs the
|
|
||||||
// 0.8B broke the persona (formal "вы", plural imperatives, masculine
|
|
||||||
// self-reference) and invented facts and units. Templates score 15/15 on the
|
|
||||||
// nudge fixture, the model 11-13/15.
|
|
||||||
//
|
|
||||||
// The LLM path is kept, not deleted: flip this on to get it back. Chat,
|
|
||||||
// query and reminder phrasing are untouched and still go through the model.
|
|
||||||
LLMNudges bool
|
|
||||||
|
|
||||||
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
||||||
// The escape hatch exists because the target resident model — the
|
// The escape hatch exists because the target resident model — the
|
||||||
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
||||||
@@ -88,7 +71,6 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
client: &http.Client{Timeout: cfg.Timeout},
|
client: &http.Client{Timeout: cfg.Timeout},
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
tmpl: loadNudgeTemplates(),
|
|
||||||
}
|
}
|
||||||
if err := p.start(ctx); err != nil {
|
if err := p.start(ctx); err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
@@ -110,22 +92,9 @@ func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser {
|
|||||||
client: &http.Client{Timeout: cfg.Timeout},
|
client: &http.Client{Timeout: cfg.Timeout},
|
||||||
port: strings.TrimSuffix(baseURL, "/"),
|
port: strings.TrimSuffix(baseURL, "/"),
|
||||||
cancel: func() {},
|
cancel: func() {},
|
||||||
tmpl: loadNudgeTemplates(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadNudgeTemplates loads the Russian nudge templates. A broken template file
|
|
||||||
// must not stop the daemon booting, so a failure logs and leaves the LLM path
|
|
||||||
// in charge of nudges.
|
|
||||||
func loadNudgeTemplates() *NudgeTemplates {
|
|
||||||
nt, err := NewNudgeTemplates(nil)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("phraser: nudge templates unavailable, using the model: %v", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return nt
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *LLMPhraser) start(ctx context.Context) error {
|
func (p *LLMPhraser) start(ctx context.Context) error {
|
||||||
args := []string{
|
args := []string{
|
||||||
"-m", p.cfg.ModelPath,
|
"-m", p.cfg.ModelPath,
|
||||||
@@ -216,10 +185,6 @@ func (p *LLMPhraser) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
||||||
// Templates first — see Config.LLMNudges for why this is the default.
|
|
||||||
if !p.cfg.LLMNudges && p.tmpl != nil {
|
|
||||||
return p.tmpl.PhraseNudge(ctx, c)
|
|
||||||
}
|
|
||||||
prompt := buildNudgePrompt(c)
|
prompt := buildNudgePrompt(c)
|
||||||
resp, err := p.chat(ctx, prompt)
|
resp, err := p.chat(ctx, prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
package phraser
|
|
||||||
|
|
||||||
// Hand-written Russian nudges instead of generated ones.
|
|
||||||
//
|
|
||||||
// Why: on a nudge there is nothing to be creative about. Measured over many
|
|
||||||
// runs, Qwen3.5-0.8B breaks the persona (formal "вы", plural imperatives,
|
|
||||||
// masculine self-reference) and invents facts and units — it once told him to
|
|
||||||
// boil an egg for "90-95 секунд". A nudge is five words of known content, so
|
|
||||||
// wording it with a model buys nothing and risks the persona every time.
|
|
||||||
//
|
|
||||||
// The wording lives in nudges_ru_v1.json so it can be edited without touching
|
|
||||||
// Go. This file only picks one and fills in the values.
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
_ "embed"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/delivery"
|
|
||||||
"github.com/kami/maven/internal/loop"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed nudges_ru_v1.json
|
|
||||||
var nudgeTemplateJSON []byte
|
|
||||||
|
|
||||||
// NudgeTemplateSchemaVersion — the version this code understands.
|
|
||||||
const NudgeTemplateSchemaVersion = 1
|
|
||||||
|
|
||||||
type nudgeRuleSet struct {
|
|
||||||
Mood string `json:"mood"`
|
|
||||||
Variants []string `json:"variants"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type nudgeTemplateFile struct {
|
|
||||||
SchemaVersion int `json:"schema_version"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Notes []string `json:"notes"`
|
|
||||||
Rules map[string]nudgeRuleSet `json:"rules"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// NudgeTemplates picks a hand-written Russian nudge for a candidate.
|
|
||||||
//
|
|
||||||
// Safe for concurrent use. Random, but never the same variant twice in a row
|
|
||||||
// for the same rule — being nagged with identical words is what makes a nudge
|
|
||||||
// easy to tune out.
|
|
||||||
type NudgeTemplates struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
rnd *rand.Rand
|
|
||||||
last map[string]string // rule family -> the text used last time
|
|
||||||
file nudgeTemplateFile
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewNudgeTemplates loads the embedded template file. Pass a source to make the
|
|
||||||
// picking reproducible in tests; nil means seed from the clock.
|
|
||||||
func NewNudgeTemplates(src rand.Source) (*NudgeTemplates, error) {
|
|
||||||
var f nudgeTemplateFile
|
|
||||||
if err := json.Unmarshal(nudgeTemplateJSON, &f); err != nil {
|
|
||||||
return nil, fmt.Errorf("nudge templates: parse: %w", err)
|
|
||||||
}
|
|
||||||
if f.SchemaVersion != NudgeTemplateSchemaVersion {
|
|
||||||
return nil, fmt.Errorf("nudge templates: schema_version %d, want %d",
|
|
||||||
f.SchemaVersion, NudgeTemplateSchemaVersion)
|
|
||||||
}
|
|
||||||
if len(f.Rules) == 0 {
|
|
||||||
return nil, fmt.Errorf("nudge templates: no rules")
|
|
||||||
}
|
|
||||||
if src == nil {
|
|
||||||
src = rand.NewSource(time.Now().UnixNano())
|
|
||||||
}
|
|
||||||
return &NudgeTemplates{
|
|
||||||
rnd: rand.New(src),
|
|
||||||
last: map[string]string{},
|
|
||||||
file: f,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PhraseNudge implements the nudge half of the Phraser interface, so the
|
|
||||||
// templates can be scored by the same harness as the model.
|
|
||||||
func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
|
||||||
body, mood := t.Nudge(c)
|
|
||||||
return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: body, Mood: mood}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nudge returns the text and the mood for one candidate. Never fails: if no
|
|
||||||
// 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)
|
|
||||||
set, ok := t.file.Rules[family]
|
|
||||||
if !ok {
|
|
||||||
return fallbackNudge(c), "neutral"
|
|
||||||
}
|
|
||||||
vals := nudgeValues(c)
|
|
||||||
|
|
||||||
// Only variants whose placeholders all have a value.
|
|
||||||
usable := make([]string, 0, len(set.Variants))
|
|
||||||
for _, v := range set.Variants {
|
|
||||||
if text, ok := fillTemplate(v, vals); ok {
|
|
||||||
usable = append(usable, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(usable) == 0 {
|
|
||||||
return fallbackNudge(c), "neutral"
|
|
||||||
}
|
|
||||||
|
|
||||||
mood = set.Mood
|
|
||||||
if mood == "" {
|
|
||||||
mood = "neutral"
|
|
||||||
}
|
|
||||||
return t.pick(family, usable), mood
|
|
||||||
}
|
|
||||||
|
|
||||||
// pick chooses at random, skipping whatever this rule said last time.
|
|
||||||
func (t *NudgeTemplates) pick(family string, usable []string) string {
|
|
||||||
t.mu.Lock()
|
|
||||||
defer t.mu.Unlock()
|
|
||||||
|
|
||||||
choices := usable
|
|
||||||
if len(usable) > 1 {
|
|
||||||
choices = make([]string, 0, len(usable))
|
|
||||||
for _, v := range usable {
|
|
||||||
if v != t.last[family] {
|
|
||||||
choices = append(choices, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(choices) == 0 { // every variant equals the last one
|
|
||||||
choices = usable
|
|
||||||
}
|
|
||||||
}
|
|
||||||
got := choices[t.rnd.Intn(len(choices))]
|
|
||||||
t.last[family] = got
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
|
|
||||||
// family maps a rule name to a block in the template file: an exact match
|
|
||||||
// first, then the prefix of "routine:зарядка" / "morning:утро", then "default".
|
|
||||||
func (t *NudgeTemplates) family(rule string) string {
|
|
||||||
if _, ok := t.file.Rules[rule]; ok {
|
|
||||||
return rule
|
|
||||||
}
|
|
||||||
if i := strings.IndexByte(rule, ':'); i > 0 {
|
|
||||||
if _, ok := t.file.Rules[rule[:i]]; ok {
|
|
||||||
return rule[:i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
// placeholderRE — the {name} slots a template may use.
|
|
||||||
var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`)
|
|
||||||
|
|
||||||
// nudgeValues collects what this candidate can fill in. A key missing here
|
|
||||||
// means every template needing it is skipped, so nothing half-filled is ever
|
|
||||||
// spoken.
|
|
||||||
func nudgeValues(c loop.Candidate) map[string]string {
|
|
||||||
vals := map[string]string{}
|
|
||||||
rule := c.Rule.Name
|
|
||||||
|
|
||||||
// {since} — only at hour scale. Below an hour the phrase would be minutes,
|
|
||||||
// and none of the templates read well with "сорок минут".
|
|
||||||
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:]
|
|
||||||
}
|
|
||||||
return vals
|
|
||||||
}
|
|
||||||
|
|
||||||
// fillTemplate substitutes the placeholders. Returns false when a value is
|
|
||||||
// missing, so a raw "{since}" can never reach the text-to-speech voice.
|
|
||||||
func fillTemplate(tmpl string, vals map[string]string) (string, bool) {
|
|
||||||
missing := false
|
|
||||||
out := placeholderRE.ReplaceAllStringFunc(tmpl, func(m string) string {
|
|
||||||
name := m[1 : len(m)-1]
|
|
||||||
v, ok := vals[name]
|
|
||||||
if !ok || v == "" {
|
|
||||||
missing = true
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
})
|
|
||||||
if missing || strings.ContainsAny(out, "{}%") {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return capitalizeFirst(out), true
|
|
||||||
}
|
|
||||||
|
|
||||||
// capitalizeFirst — a placeholder can start the sentence, and "полтора часа без
|
|
||||||
// перерыва" should be spoken as a sentence, not a fragment.
|
|
||||||
func capitalizeFirst(s string) string {
|
|
||||||
for i, r := range s {
|
|
||||||
return string(unicode.ToUpper(r)) + s[i+len(string(r)):]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// hourWords — hours spelled out. "3 ч" is fine on a screen and wrong in a
|
|
||||||
// Russian voice, so the number goes out as words.
|
|
||||||
var hourWords = []string{
|
|
||||||
"ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь",
|
|
||||||
"девять", "десять", "одиннадцать", "двенадцать", "тринадцать",
|
|
||||||
"четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать",
|
|
||||||
"девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три",
|
|
||||||
}
|
|
||||||
|
|
||||||
// hourPlural — час / часа / часов by Russian counting rules.
|
|
||||||
func hourPlural(h int) string {
|
|
||||||
if h%100 >= 11 && h%100 <= 14 {
|
|
||||||
return "часов"
|
|
||||||
}
|
|
||||||
switch h % 10 {
|
|
||||||
case 1:
|
|
||||||
return "час"
|
|
||||||
case 2, 3, 4:
|
|
||||||
return "часа"
|
|
||||||
default:
|
|
||||||
return "часов"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ruSinceWords — "полтора часа", "два с половиной часа", "семь часов".
|
|
||||||
// Empty string means "do not say it" (under an hour, or over a day).
|
|
||||||
func ruSinceWords(d time.Duration) string {
|
|
||||||
if d < time.Hour {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
h := int(d.Hours())
|
|
||||||
m := int(d.Minutes()) % 60
|
|
||||||
if m >= 45 {
|
|
||||||
h++
|
|
||||||
m = 0
|
|
||||||
}
|
|
||||||
if h >= len(hourWords) {
|
|
||||||
return "больше суток"
|
|
||||||
}
|
|
||||||
if h == 1 {
|
|
||||||
if m >= 15 {
|
|
||||||
return "полтора часа"
|
|
||||||
}
|
|
||||||
return "час"
|
|
||||||
}
|
|
||||||
if m >= 15 {
|
|
||||||
return hourWords[h] + " с половиной часа"
|
|
||||||
}
|
|
||||||
return hourWords[h] + " " + hourPlural(h)
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
package phraser
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math/rand"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/loop"
|
|
||||||
"github.com/kami/maven/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
st := loop.State{Now: now, Facts: map[string]store.Fact{}}
|
|
||||||
if sinceMin > 0 || factKey != "" {
|
|
||||||
key := rule
|
|
||||||
if factKey != "" {
|
|
||||||
key = factKey
|
|
||||||
}
|
|
||||||
st.Facts[rule] = store.Fact{Key: key, Ts: now.Add(-time.Duration(sinceMin) * time.Minute)}
|
|
||||||
}
|
|
||||||
return loop.Candidate{Rule: loop.Rule{Name: rule, Severity: loop.Sev1}, Severity: loop.Sev1, State: st}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates {
|
|
||||||
t.Helper()
|
|
||||||
nt, err := NewNudgeTemplates(rand.NewSource(seed))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewNudgeTemplates: %v", err)
|
|
||||||
}
|
|
||||||
return nt
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNudgeTemplatesLoad(t *testing.T) {
|
|
||||||
nt := newTestTemplates(t, 1)
|
|
||||||
for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning", "default"} {
|
|
||||||
set, ok := nt.file.Rules[rule]
|
|
||||||
if !ok {
|
|
||||||
t.Errorf("no templates for %q", rule)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if len(set.Variants) < 5 {
|
|
||||||
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.
|
|
||||||
plain := 0
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, v := range set.Variants {
|
|
||||||
if !placeholderRE.MatchString(v) {
|
|
||||||
plain++
|
|
||||||
}
|
|
||||||
if seen[v] {
|
|
||||||
t.Errorf("%s: duplicate variant %q", rule, v)
|
|
||||||
}
|
|
||||||
seen[v] = true
|
|
||||||
}
|
|
||||||
if plain == 0 && rule != "routine" && rule != "morning" {
|
|
||||||
t.Errorf("%s: every variant needs a placeholder value", rule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The whole point of the picker: never the same words twice in a row.
|
|
||||||
func TestNudgeNoImmediateRepeat(t *testing.T) {
|
|
||||||
nt := newTestTemplates(t, 7)
|
|
||||||
prev := ""
|
|
||||||
for i := 0; i < 200; i++ {
|
|
||||||
body, _ := nt.Nudge(cand("water", 200, ""))
|
|
||||||
if body == prev {
|
|
||||||
t.Fatalf("repeat at %d: %q", i, body)
|
|
||||||
}
|
|
||||||
prev = body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same seed, same sequence — otherwise the fixture score would drift run to run.
|
|
||||||
func TestNudgeDeterministicWithSeed(t *testing.T) {
|
|
||||||
var runs [2][]string
|
|
||||||
for r := range runs {
|
|
||||||
nt := newTestTemplates(t, 42)
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
body, _ := nt.Nudge(cand("break", 100, ""))
|
|
||||||
runs[r] = append(runs[r], body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range runs[0] {
|
|
||||||
if runs[0][i] != runs[1][i] {
|
|
||||||
t.Fatalf("run %d differs: %q vs %q", i, runs[0][i], runs[1][i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A variant is only used when its value exists, and nothing half-filled ships.
|
|
||||||
func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
|
|
||||||
nt := newTestTemplates(t, 3)
|
|
||||||
cases := []loop.Candidate{
|
|
||||||
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
|
|
||||||
cand("routine:таблетки", 0, ""),
|
|
||||||
cand("morning:утро", 0, ""),
|
|
||||||
cand("unknown_rule", 0, ""),
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
for i := 0; i < 40; i++ {
|
|
||||||
body, mood := nt.Nudge(c)
|
|
||||||
if body == "" {
|
|
||||||
t.Fatalf("%s: empty body", c.Rule.Name)
|
|
||||||
}
|
|
||||||
if strings.ContainsAny(body, "{}%") {
|
|
||||||
t.Fatalf("%s: unfilled template %q", c.Rule.Name, body)
|
|
||||||
}
|
|
||||||
if mood != "neutral" {
|
|
||||||
t.Fatalf("%s: mood %q", c.Rule.Name, mood)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The routine name must actually land in the text.
|
|
||||||
func TestNudgeSubstitutesWhat(t *testing.T) {
|
|
||||||
nt := newTestTemplates(t, 11)
|
|
||||||
for i := 0; i < 40; i++ {
|
|
||||||
body, _ := nt.Nudge(cand("routine:таблетки", 0, ""))
|
|
||||||
if !strings.Contains(strings.ToLower(body), "таблетки") {
|
|
||||||
t.Fatalf("routine text lost the name: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRuSinceWords(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
min int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{30, ""},
|
|
||||||
{60, "час"},
|
|
||||||
{95, "полтора часа"},
|
|
||||||
{150, "два с половиной часа"},
|
|
||||||
{190, "три часа"},
|
|
||||||
{240, "четыре часа"},
|
|
||||||
{430, "семь часов"},
|
|
||||||
{660, "одиннадцать часов"},
|
|
||||||
{60 * 30, "больше суток"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
got := ruSinceWords(time.Duration(c.min) * time.Minute)
|
|
||||||
if got != c.want {
|
|
||||||
t.Errorf("%d min: got %q want %q", c.min, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Templates are the default: a nudge must not reach the model at all.
|
|
||||||
func TestLLMPhraserUsesTemplatesByDefault(t *testing.T) {
|
|
||||||
spy := newGrammarSpy(t)
|
|
||||||
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
|
||||||
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PhraseNudge: %v", err)
|
|
||||||
}
|
|
||||||
if len(spy.grammars) != 0 {
|
|
||||||
t.Errorf("nudge hit the model %d times, want 0", len(spy.grammars))
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.ToLower(pn.Body), "вод") {
|
|
||||||
t.Errorf("nudge is not the water template: %q", pn.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ...and the flag brings the model back.
|
|
||||||
func TestLLMNudgesFlagRestoresTheModel(t *testing.T) {
|
|
||||||
spy := newGrammarSpy(t)
|
|
||||||
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
|
||||||
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PhraseNudge: %v", err)
|
|
||||||
}
|
|
||||||
if len(spy.grammars) != 1 {
|
|
||||||
t.Fatalf("nudge hit the model %d times, want 1", len(spy.grammars))
|
|
||||||
}
|
|
||||||
if pn.Body != "ага" {
|
|
||||||
t.Errorf("body = %q, want the model's reply", pn.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNudgeTemplatesPhraseNudge(t *testing.T) {
|
|
||||||
nt := newTestTemplates(t, 5)
|
|
||||||
pn, err := nt.PhraseNudge(context.Background(), cand("water", 200, ""))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("PhraseNudge: %v", err)
|
|
||||||
}
|
|
||||||
if pn.Body == "" || pn.Summary != pn.Body || pn.Mood != "neutral" {
|
|
||||||
t.Fatalf("bad nudge: %+v", pn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"name": "russian nudge templates v1",
|
|
||||||
"notes": [
|
|
||||||
"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.",
|
|
||||||
"mood must be one of: neutral, happy, thinking, tired, confused."
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"water": {
|
|
||||||
"mood": "neutral",
|
|
||||||
"variants": [
|
|
||||||
"Ты не пил воду {since} — выпей стакан.",
|
|
||||||
"Пора выпить воды.",
|
|
||||||
"Стакан воды не помешает.",
|
|
||||||
"Воду ты не пил уже {since}.",
|
|
||||||
"Напоминаю про воду.",
|
|
||||||
"Сходи за водой, дела подождут.",
|
|
||||||
"Сделай глоток воды, пока помнишь.",
|
|
||||||
"Между делом выпей воды.",
|
|
||||||
"Вода — простое дело: выпей стакан.",
|
|
||||||
"Отвлекись на стакан воды."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"meal": {
|
|
||||||
"mood": "neutral",
|
|
||||||
"variants": [
|
|
||||||
"Ты не ел {since} — поешь.",
|
|
||||||
"Пора поесть, сделай перекус.",
|
|
||||||
"Еда важнее ещё одного часа за столом.",
|
|
||||||
"Без еды уже {since}, поешь.",
|
|
||||||
"Напоминаю про еду — поешь.",
|
|
||||||
"Возьми перерыв на обед.",
|
|
||||||
"Сделай себе перекус, это пять минут.",
|
|
||||||
"Поешь, потом вернёшься к работе.",
|
|
||||||
"Поешь нормально, а не на ходу.",
|
|
||||||
"Еды не было {since} — разогрей что-нибудь."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"break": {
|
|
||||||
"mood": "neutral",
|
|
||||||
"variants": [
|
|
||||||
"Ты за столом {since} — встань и разомнись.",
|
|
||||||
"Пора сделать перерыв.",
|
|
||||||
"Встань на пять минут.",
|
|
||||||
"{since} без перерыва — отойди от экрана.",
|
|
||||||
"Напоминаю про перерыв.",
|
|
||||||
"Разомни спину, потом продолжишь.",
|
|
||||||
"Короткая пауза не сорвёт дела.",
|
|
||||||
"Отойди от компьютера на минуту.",
|
|
||||||
"Сидишь без перерыва {since}.",
|
|
||||||
"Встань, пройдись, вернись."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"service_down": {
|
|
||||||
"mood": "neutral",
|
|
||||||
"variants": [
|
|
||||||
"Сервис {service} не отвечает.",
|
|
||||||
"{service} упал — сервис не отвечает.",
|
|
||||||
"{service} не отвечает, сервис нужно поднимать.",
|
|
||||||
"Сервис {service} недоступен.",
|
|
||||||
"Проверь {service}: сервис не отвечает.",
|
|
||||||
"Сервис перестал отвечать.",
|
|
||||||
"Сервис {service} лежит, нужно смотреть.",
|
|
||||||
"{service} не отвечает уже {since}.",
|
|
||||||
"Мониторинг сообщает: {service} лежит.",
|
|
||||||
"Сервис {service} не отвечает, посмотри логи."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"netdata_critical": {
|
|
||||||
"mood": "neutral",
|
|
||||||
"variants": [
|
|
||||||
"Netdata: критический алярм, проверь диск.",
|
|
||||||
"Критический алярм в netdata — посмотри диск.",
|
|
||||||
"Netdata поднял тревогу по диску.",
|
|
||||||
"Проверь диск: netdata ругается.",
|
|
||||||
"Алярм от netdata, критический.",
|
|
||||||
"Netdata: критический уровень, дело в диске.",
|
|
||||||
"Диск требует внимания — критический алярм в netdata.",
|
|
||||||
"Критический алярм: проверь место на диске.",
|
|
||||||
"Netdata сообщает о критической проблеме с диском.",
|
|
||||||
"Открой netdata: там критический алярм по диску."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"routine": {
|
|
||||||
"mood": "neutral",
|
|
||||||
"variants": [
|
|
||||||
"По распорядку: {what}.",
|
|
||||||
"Пора — {what}.",
|
|
||||||
"Напоминаю: {what}.",
|
|
||||||
"В списке на сейчас: {what}.",
|
|
||||||
"{what} — сейчас самое время.",
|
|
||||||
"Не пропусти: {what}.",
|
|
||||||
"{what}: пора сделать.",
|
|
||||||
"Сейчас по плану {what}.",
|
|
||||||
"Твой распорядок: {what}.",
|
|
||||||
"{what} — по распорядку сейчас."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"morning": {
|
|
||||||
"mood": "neutral",
|
|
||||||
"variants": [
|
|
||||||
"{what} — пора начать день.",
|
|
||||||
"{what}: пройди утренний список.",
|
|
||||||
"Начни {what} со списка.",
|
|
||||||
"{what}. Осталось пройти чеклист.",
|
|
||||||
"Утренний список ещё не пройден: {what}.",
|
|
||||||
"{what}: первый пункт списка за тобой.",
|
|
||||||
"{what} идёт, а список стоит.",
|
|
||||||
"{what}: не забудь про утренние дела.",
|
|
||||||
"По утреннему чеклисту ещё есть дела: {what}.",
|
|
||||||
"{what} — утренний список дел ещё ждёт."
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"default": {
|
|
||||||
"mood": "neutral",
|
|
||||||
"variants": [
|
|
||||||
"Напоминаю: есть дело.",
|
|
||||||
"Пора вернуться к отложенному делу.",
|
|
||||||
"Одно дело ждёт тебя.",
|
|
||||||
"Напоминаю про дело из списка.",
|
|
||||||
"В списке осталось дело.",
|
|
||||||
"Дело всё ещё не сделано."
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user