Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ca5748699 | |||
| b43bb265b5 | |||
| b9a24334ea | |||
| c97aebf55a | |||
| 891136c65d | |||
| 41c7c13f42 | |||
| a324e8f624 | |||
| 51805e7f35 | |||
| 533f0acda8 | |||
| 4f59ba78c6 | |||
| d0afd9d4f6 | |||
| 6b67e6f3c2 | |||
| 13e5170e9e | |||
| 0b90952e55 | |||
| aa8f5b2ee2 | |||
| d7cdcb63bd | |||
| c7dadc97d9 | |||
| ee3e6a9eaf | |||
| 8acb8a97c6 |
@@ -7,9 +7,20 @@ 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`,
|
||||
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
||||
|
||||
**Resident model:** currently **Qwen3.5-0.8B** (`Q4_K_M`), the smallest checkpoint in the gguf
|
||||
library, picked for CPU/iGPU latency. The **target** is the locally CPT'd **Qwen3-1.7B**; that
|
||||
training is still in flight (Vikunja #122), so no such gguf exists yet. Model files live in
|
||||
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
||||
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
|
||||
67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the
|
||||
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
|
||||
`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`.
|
||||
@@ -58,19 +69,30 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g
|
||||
|
||||
## Routing — read this before touching the router
|
||||
|
||||
`internal/router/` has TWO layered engines and the committed default is an **interim
|
||||
stopgap, not the intended design** (see memory `routing-architecture-target`):
|
||||
`internal/router/` has TWO layered engines. **The LLM router is now the default and it is
|
||||
on in deploy** — this section used to say it was wired `nil`, which stopped being true on
|
||||
2026-07-31.
|
||||
|
||||
- **Target (REARCH.md):** LLM-as-router. One resident Qwen3-1.7B (`llmrouter.go`) emits
|
||||
GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is demoted
|
||||
from a routing gate to a RAG hint.
|
||||
- **Current stopgap:** `llmrouter` is wired `nil` (around `voice.go`), so the
|
||||
`classifier.go` + `embedder.go` nearest-neighbour cascade actually runs. It routes by
|
||||
similarity to frozen seed phrases — the known cause of weak RU query handling.
|
||||
- **LLM router (the intended design, REARCH.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
||||
emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is
|
||||
demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via
|
||||
`pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router`
|
||||
(`config.go`), `DefaultLLMRouter` is **on**, and `deploy/mavend.json` sets it `true`.
|
||||
- **Classifier cascade (the failure floor, not dead code):** `classifier.go` +
|
||||
`embedder.go` nearest-neighbour over frozen seed phrases. It runs when the LLM router is
|
||||
off, when there is no llama-server to talk to (`pickLLMRouter` logs that and degrades),
|
||||
and on any per-turn LLM error. Do not delete it — routing by seed similarity is the known
|
||||
cause of weak RU query handling, but a turn must never break on the model.
|
||||
|
||||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||||
|
||||
Measured on the 77-case RU fixture (`MODEL-BAKEOFF-31-07-2026.md`): the classifier scores
|
||||
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
|
||||
cascade at p50 ≈2.7s. Accuracy roughly doubled, latency is ~90× worse, and that trade was
|
||||
accepted deliberately. Still open: `Confidence: 1.0` is hardcoded in `llmrouter.go`, so the
|
||||
LLM path never asks for clarification (6/6 refusal cases missed) — Vikunja #359.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
@@ -83,7 +105,11 @@ workspace enforces that the Go and relabelling prompts remain identical.
|
||||
## Non-goals (hard constraints)
|
||||
|
||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
self-reference must use feminine forms (the user is male; see memory `maven-persona-gender`).
|
||||
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
||||
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
||||
(she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name
|
||||
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||||
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
||||
|
||||
**"Never phones home" is DEPRECATED** (owner's call, 2026-07-31). It used to be a hard
|
||||
constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer
|
||||
|
||||
+126
-4
@@ -1,15 +1,28 @@
|
||||
# Resident model bake-off — 31-07-2026
|
||||
|
||||
**Recommendation: keep Qwen3.5-0.8B.** 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.
|
||||
**Outcome: the resident model is stock Qwen3-1.7B** (`UD-Q4_K_XL`). Two sweeps ran this
|
||||
evening and the second one changed the answer — read to the end before acting on any table
|
||||
here. [Second sweep](#second-sweep-same-evening--five-models-and-a-resident-model-change)
|
||||
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**.
|
||||
|
||||
- Same fixture and scorer as `ROUTING-EVAL-31-07-2026.md`: `internal/router/eval/`
|
||||
(`ru_routing_v1.json`, 76 held-out cases).
|
||||
- Reproduce: `MAVEN_LLM_URL=http://127.0.0.1:<port> make eval-router`
|
||||
(`TestLLMRouterBaseline`). Note: there is no `make eval-models` target.
|
||||
(`TestLLMRouterBaseline`). (This line used to say there is no `make eval-models` target.
|
||||
There is one now — start a server with the gguf you want, then
|
||||
`make eval-models MAVEN_LLM_URL=http://127.0.0.1:<port>`. It runs only the LLM test, since
|
||||
the classifier baselines do not depend on the model.)
|
||||
- All three models served by the same `llama-server` flags — `-c 2048 -ngl 99 -t 6`, only
|
||||
`-m` and `--port` differ. One server at a time on an otherwise idle box, so latencies are
|
||||
real and not contention.
|
||||
@@ -99,3 +112,112 @@ 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
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
# 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`.~~ **Resolved the same evening:** the LLM router is wired at `voice.go:214`
|
||||
behind `voice.llm_router`, the default is on, and `deploy/mavend.json` sets it `true`.
|
||||
These numbers are the production path now, so the p50 ≈2.7s is a real per-turn cost and
|
||||
not a bench artifact.
|
||||
- ~~`/mnt/hdd1/llms/LFM2.5/Qwen3-1.7B-UD-Q4_K_XL.gguf` is a 293 MB truncated download
|
||||
in the wrong directory.~~ **Deleted 2026-07-31.** The good 1.13 GB copy in `qwen3/` is
|
||||
what `deploy/mavend.json` loads.
|
||||
- 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.
|
||||
+57
-6
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
@@ -21,8 +23,12 @@ const clarifyTTL = 90 * time.Second
|
||||
// raw utterance, chat and system have nothing to fill in. For those a clarify
|
||||
// decision keeps the canned "не поняла" reply — inventing a question for noise
|
||||
// is worse than admitting she missed it.
|
||||
// A reminder wants BOTH what to remind about and when. Subject first: "напомни
|
||||
// в 11" has a time and nothing to say at 11, and a reminder with no subject is
|
||||
// not worth setting. Order here is the order she asks in — she still only asks
|
||||
// about the first one missing.
|
||||
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
router.IntentReminder: {dialogue.SlotTime},
|
||||
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
|
||||
router.IntentFact: {dialogue.SlotKey},
|
||||
router.IntentAct: {dialogue.SlotFn},
|
||||
}
|
||||
@@ -35,7 +41,8 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
// questions, so there is no gender agreement to get wrong; the feminine
|
||||
// self-reference lives in the reply she gives when she drops the request.
|
||||
var clarifyQuestions = map[dialogue.Slot]string{
|
||||
dialogue.SlotTime: "На когда напомнить?",
|
||||
dialogue.SlotTime: "Когда?",
|
||||
dialogue.SlotText: "О чём напомнить?",
|
||||
dialogue.SlotKey: "Что записать?",
|
||||
dialogue.SlotFn: "Что сделать?",
|
||||
}
|
||||
@@ -45,11 +52,55 @@ var clarifyQuestions = map[dialogue.Slot]string{
|
||||
// landed. Feminine self-reference ("поняла"), as everywhere.
|
||||
const clarifyGaveUp = "Прости, я не поняла. Скажи, пожалуйста, по-другому."
|
||||
|
||||
// clarifyExpired — his answer came after the TTL, so the parked request is
|
||||
// already gone. Same tone as clarifyGaveUp, different reason: too much time
|
||||
// clarifyExpiredVariants — his answer came after the TTL, so the parked request
|
||||
// is already gone. Same tone as clarifyGaveUp, different reason: too much time
|
||||
// passed, not "I did not understand". Feminine self-reference ("ждала",
|
||||
// "отпустила"); he is addressed with a plain imperative.
|
||||
const clarifyExpired = "Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново."
|
||||
//
|
||||
// Five phrasings, not one. This is the line he hears whenever he walks off
|
||||
// mid-request, so it is the line that repeats most — and the same sentence every
|
||||
// time is what makes a house assistant sound like a kiosk. They all carry the
|
||||
// same two facts (the old request is gone; say it again if it still matters),
|
||||
// because the wording may vary and the meaning may not.
|
||||
//
|
||||
// Fixed templates rather than model output, for the same reason as
|
||||
// clarifyQuestions: this text has to be right every time, and it is not worth a
|
||||
// generation to say something this small.
|
||||
var clarifyExpiredVariants = []string{
|
||||
"Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново.",
|
||||
"Кажется, прошлая просьба уже не важна — я её отпустила. Если я ошибаюсь, повтори.",
|
||||
"Ты как-то резко замолчал, и я не стала ждать дальше. Если та просьба ещё нужна, скажи заново.",
|
||||
"Я не дождалась ответа и убрала прошлую просьбу. Повтори, если она всё ещё нужна.",
|
||||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random.
|
||||
func clarifyExpiredLine() string {
|
||||
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
|
||||
}
|
||||
|
||||
// isClarifyExpired reports whether s opens with any of the expiry lines. The
|
||||
// notice is glued in front of this turn's reply (see withNotice), so a caller
|
||||
// checking for it has to match a prefix, not the whole string.
|
||||
func isClarifyExpired(s string) bool {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
||||
// reply. "" ⇒ the notice was the whole thing.
|
||||
func trimClarifyExpired(s string) string {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||||
// out, and "" when nothing was parked. Call it right after
|
||||
@@ -63,7 +114,7 @@ func (h *reactiveHandler) clarifyExpiredNotice() string {
|
||||
return ""
|
||||
}
|
||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||
return clarifyExpired
|
||||
return clarifyExpiredLine()
|
||||
}
|
||||
|
||||
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
||||
|
||||
@@ -56,10 +56,13 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
||||
want string
|
||||
asked bool
|
||||
}{
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "На когда напомнить?", true},
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
|
||||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||||
{"reminder that already has a time", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "", false},
|
||||
// A time with nothing to say at that time is still half a reminder, so
|
||||
// the subject is what she asks about — not silence.
|
||||
{"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true},
|
||||
{"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false},
|
||||
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
||||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||||
}
|
||||
@@ -78,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "На когда напомнить?" {
|
||||
if !asked || question != "Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
@@ -152,7 +155,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatalf("answer %d must be consumed as an answer", i)
|
||||
}
|
||||
if reply != "На когда напомнить?" {
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
@@ -304,17 +307,17 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "как дела")
|
||||
if !strings.HasPrefix(reply, clarifyExpired) {
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||
}
|
||||
if strings.TrimSpace(strings.TrimPrefix(reply, clarifyExpired)) == "" {
|
||||
if trimClarifyExpired(reply) == "" {
|
||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
// The notice is said once, not on every later utterance.
|
||||
if reply := h.handleText(ctx, "как дела"); strings.Contains(reply, clarifyExpired) {
|
||||
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) {
|
||||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,6 +278,7 @@ func run(args []string) error {
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
@@ -448,6 +449,7 @@ func run(args []string) error {
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
|
||||
@@ -34,7 +34,7 @@ func newLLMReplier(c completer, block func() string) *llmReplier {
|
||||
return &llmReplier{c: c, stub: voice.NewStubReplier(), block: block}
|
||||
}
|
||||
|
||||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
||||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
||||
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
|
||||
Никогда не пиши "..." в поле response.`
|
||||
|
||||
|
||||
+4
-3
@@ -6,11 +6,12 @@
|
||||
"state_dir": "/var/lib/maven",
|
||||
|
||||
"phraser": {
|
||||
"model_path": "/opt/maven/models/llm/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf",
|
||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||
"bin_path": "llama-server",
|
||||
"n_gpu_layers": 99,
|
||||
"n_ctx": 2048,
|
||||
"timeout": "60s"
|
||||
"n_ctx": 4096,
|
||||
"timeout": "60s",
|
||||
"llm_nudges": false
|
||||
},
|
||||
|
||||
"telegram": {
|
||||
|
||||
@@ -369,6 +369,12 @@ type PhraserConfig struct {
|
||||
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
||||
NCtx int `json:"n_ctx,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
|
||||
|
||||
@@ -35,6 +35,27 @@ 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) {
|
||||
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
||||
c, err := Load(p)
|
||||
|
||||
@@ -82,7 +82,7 @@ type rss struct {
|
||||
Description struct {
|
||||
Inner string `xml:",innerxml"`
|
||||
} `xml:"description"`
|
||||
WordCount string `xml:"wordCount"`
|
||||
WordCount string `xml:"wordCount"`
|
||||
} `xml:"channel>item"`
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestRewriteConstrainsTheCall(t *testing.T) {
|
||||
func TestRewriteRejectsBadModelOutput(t *testing.T) {
|
||||
bad := []string{
|
||||
`{"query":"почему небо синее"}`, // never translated
|
||||
`{"query":""}`, // empty
|
||||
`{"query":""}`, // empty
|
||||
`{"query":"the sky is blue because sunlight is scattered by air"}`, // an answer
|
||||
// Note: a SHORT English prose fragment ("Let me analyze this request")
|
||||
// is under the word cap and cannot be caught here. The grammar is what
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A reply that starts a JSON object and never finishes it is a failed
|
||||
// generation, not a reply. Before this, the parser returned ("", "") for these
|
||||
// and every caller then shipped the raw fragment as the thing Maven said. A
|
||||
// real run produced replies of literally "{" and "{\n \"".
|
||||
func TestParseResponseMoodRejectsUnfinishedJSON(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{`,
|
||||
"{\n \"",
|
||||
`{"response": "неполн`,
|
||||
`{"response": "текст", "mood":`,
|
||||
} {
|
||||
text, mood, err := parseResponseMood(raw)
|
||||
if !errors.Is(err, errBrokenJSON) {
|
||||
t.Errorf("parseResponseMood(%q) err = %v, want errBrokenJSON", raw, err)
|
||||
}
|
||||
if text != "" || mood != "" {
|
||||
t.Errorf("parseResponseMood(%q) leaked %q/%q — a fragment must never come back as a reply", raw, text, mood)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bare prose is still fine. Small models sometimes answer without any JSON at
|
||||
// all, and that reply is usable — so the new error must not swallow it.
|
||||
func TestParseResponseMoodAllowsBareProse(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"норм, а ты как?",
|
||||
"вот что я нашла: ключ у соседа",
|
||||
} {
|
||||
text, mood, err := parseResponseMood(raw)
|
||||
if err != nil {
|
||||
t.Errorf("parseResponseMood(%q) err = %v, want nil", raw, err)
|
||||
}
|
||||
// No JSON means no fields; the caller ships raw as-is.
|
||||
if text != "" || mood != "" {
|
||||
t.Errorf("parseResponseMood(%q) = %q/%q, want empty", raw, text, mood)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The measured failure: the model wants more than 400 characters and the old
|
||||
// grammar cut it off mid-word. Guards the bound against being tightened back.
|
||||
func TestGrammarStringBoundHasRoomForARealAnswer(t *testing.T) {
|
||||
if !strings.Contains(responseGrammar, "{0,1000}") {
|
||||
t.Error("grammar string bound is not 1000; 400 truncated real replies mid-word (see the comment on responseGrammar)")
|
||||
}
|
||||
}
|
||||
@@ -31,3 +31,35 @@ func TestAddressDeduplicates(t *testing.T) {
|
||||
t.Errorf("detail repeats the same break %d times: %q", n, res.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// The fragments a real run produced. All of them scored as non-empty replies
|
||||
// before checkNonEmpty looked for letters.
|
||||
func TestNonEmptyNeedsLetters(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
"{",
|
||||
"{\n \"",
|
||||
"15-16",
|
||||
`{"`,
|
||||
" ",
|
||||
"...",
|
||||
} {
|
||||
if got := checkNonEmpty(body); got.Pass {
|
||||
t.Errorf("checkNonEmpty(%q) passed — that is not a reply", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And it must not start failing real replies. Latin counts as well as Cyrillic:
|
||||
// answers about ssd or vpn are legitimately part English.
|
||||
func TestNonEmptyAcceptsRealReplies(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
"норм, а ты как?",
|
||||
"вот что я нашла: ключ у соседа",
|
||||
"ssd быстрее hdd.",
|
||||
"9 минут.",
|
||||
} {
|
||||
if got := checkNonEmpty(body); !got.Pass {
|
||||
t.Errorf("checkNonEmpty(%q) failed: %s", body, got.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,11 +623,24 @@ const (
|
||||
CheckEllipsis = "ellipsis" // she finished the sentence
|
||||
)
|
||||
|
||||
// A reply needs words in it, not just characters. This check used to test for a
|
||||
// non-empty string, which scored 27/27 on a run where two replies were "{" and
|
||||
// "{\n \"" — punctuation passed as content. Braces, quotes, digits and spaces
|
||||
// are all empty in the only sense that matters.
|
||||
//
|
||||
// Digits alone fail too, and that is deliberate: the same run answered "сколько
|
||||
// варить яйцо вкрутую?" with "15-16". No unit, no words, and it is also the
|
||||
// wrong number. Whatever that is, it is not something she said.
|
||||
func checkNonEmpty(body string) Result {
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return Result{CheckNonEmpty, false, "empty reply"}
|
||||
}
|
||||
return Result{CheckNonEmpty, true, ""}
|
||||
for _, r := range body {
|
||||
if unicode.IsLetter(r) {
|
||||
return Result{CheckNonEmpty, true, ""}
|
||||
}
|
||||
}
|
||||
return Result{CheckNonEmpty, false, fmt.Sprintf("no letters in the reply %q — punctuation or digits only", strings.TrimSpace(body))}
|
||||
}
|
||||
|
||||
// checkEllipsis — a reply ending in "…" or "..." is a generation that ran out of
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
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,6 +35,8 @@ func newGrammarSpy(t *testing.T) *grammarSpy {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
@@ -58,7 +60,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
||||
t.Fatal("responseGrammar is empty")
|
||||
}
|
||||
spy := newGrammarSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
||||
|
||||
callAllPhrasingPaths(t, p)
|
||||
|
||||
@@ -74,7 +76,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
||||
|
||||
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
||||
spy := newGrammarSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true})
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true})
|
||||
|
||||
callAllPhrasingPaths(t, p)
|
||||
|
||||
@@ -97,7 +99,10 @@ func TestGrammarStringRuleIsNotASCIIOnly(t *testing.T) {
|
||||
// Russian body with an escaped quote inside, hand-built to test the contract.
|
||||
func TestGrammarShapedJSONParses(t *testing.T) {
|
||||
raw := `{"response": "он сказал \"привет\" и ушёл.\nвот так.", "mood": "confused"}`
|
||||
text, mood := parseResponseMood(raw)
|
||||
text, mood, err := parseResponseMood(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("grammar-shaped JSON did not parse: %v", err)
|
||||
}
|
||||
if want := "он сказал \"привет\" и ушёл.\nвот так."; text != want {
|
||||
t.Errorf("response = %q, want %q", text, want)
|
||||
}
|
||||
|
||||
+123
-26
@@ -31,6 +31,10 @@ type LLMPhraser struct {
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
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 {
|
||||
@@ -46,6 +50,19 @@ type Config struct {
|
||||
// nil ⇒ no block, the prompts stand alone.
|
||||
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).
|
||||
// The escape hatch exists because the target resident model — the
|
||||
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
||||
@@ -71,6 +88,7 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
cancel: cancel,
|
||||
tmpl: loadNudgeTemplates(),
|
||||
}
|
||||
if err := p.start(ctx); err != nil {
|
||||
cancel()
|
||||
@@ -92,9 +110,22 @@ func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser {
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
port: strings.TrimSuffix(baseURL, "/"),
|
||||
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 {
|
||||
args := []string{
|
||||
"-m", p.cfg.ModelPath,
|
||||
@@ -185,12 +216,21 @@ func (p *LLMPhraser) Close() 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)
|
||||
resp, err := p.chat(ctx, prompt)
|
||||
if err != nil {
|
||||
return delivery.PhrasedNudge{}, err
|
||||
}
|
||||
body, mood := parseResponseMood(resp)
|
||||
body, mood, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
// Truncated JSON. Not a nudge — use the plain Russian fallback.
|
||||
log.Printf("phraser: PhraseNudge: %v", perr)
|
||||
body, mood = "", ""
|
||||
}
|
||||
if body == "" {
|
||||
// fallback: try old body/summary format
|
||||
body, _ = parsePhrase(resp)
|
||||
@@ -216,11 +256,16 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
// prompt is the single tested source in router.KnowledgePrompt.
|
||||
sys := persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt())
|
||||
prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||
if err != nil || resp == "" {
|
||||
return "не знаю.", nil
|
||||
}
|
||||
if text, _ := parseResponseMood(resp); text != "" {
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
return "не знаю.", nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
return resp, nil
|
||||
@@ -230,17 +275,22 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
}
|
||||
sys := p.querySystemPrompt()
|
||||
prompt := fmt.Sprintf(
|
||||
`The user asks: "%s". Your notes matching the query contain: "%s". Answer them naturally and briefly. If the notes don't answer the question, say so.`,
|
||||
`Он спрашивает: "%s". В твоих заметках по этому вопросу написано: "%s". Ответь ему коротко и своими словами. Если в заметках ответа нет — так и скажи.`,
|
||||
utterance, strings.Join(notes, `"; "`),
|
||||
)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
|
||||
if err != nil {
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if err != nil || perr != nil {
|
||||
// Read the notes out rather than ship a broken fragment.
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
}
|
||||
if len(notes) == 1 {
|
||||
return "вот что я нашла: " + notes[0], nil
|
||||
}
|
||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
||||
}
|
||||
if text, _ := parseResponseMood(resp); text != "" {
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
return resp, nil
|
||||
@@ -263,12 +313,17 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
combined += utterance
|
||||
msgs = append(msgs, chatMsg{Role: "user", Content: strings.TrimSpace(combined)})
|
||||
|
||||
resp, err := p.chatWithMessages(ctx, msgs, 512)
|
||||
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
||||
if err != nil {
|
||||
log.Printf("phraser: PhraseChat: %v", err)
|
||||
return "поговорили.", nil
|
||||
}
|
||||
if text, _ := parseResponseMood(resp); text != "" {
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseChat: %v", perr)
|
||||
return "поговорили.", nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
// fallback: plain text without JSON
|
||||
@@ -281,11 +336,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
// chatSystemPrompt returns the system prompt for conversational chat.
|
||||
// Prepends the shared context block when the phraser has one.
|
||||
func chatSystemPrompt(block func() string) string {
|
||||
base := `You are maven, a self-hosted personal assistant. You're talking with your owner.
|
||||
Keep replies brief (1-3 sentences) and natural. You're helpful, curious, and a little warm.
|
||||
Respond in the user's language (Russian or English, matching their last message).
|
||||
Never roleplay emotions you don't have, but stay friendly.
|
||||
Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}. "response" is your reply text; "mood" reflects your tone (neutral/happy/thinking/tired/confused).`
|
||||
// No self-introduction here: the persona block prepended one line above
|
||||
// already says who she is, same as router.KnowledgePrompt.
|
||||
base := `Ты разговариваешь с хозяином. О себе говоришь в женском роде ("я подумала", "я рада"). Он мужчина: обращайся к нему на "ты", в мужском роде ("ты сказал", "ты забыл"). Никогда не "вы"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||||
|
||||
Отвечай по-русски, коротко: одна-три фразы, живым языком. Ты доброжелательная, тебе интересно, но чувства не изображай.
|
||||
|
||||
Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}. В "response" — твой ответ. В "mood" — ровно одно из: neutral, happy, thinking, tired, confused.`
|
||||
return persona.Prepend(block, base)
|
||||
}
|
||||
|
||||
@@ -349,7 +406,12 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
|
||||
if err != nil {
|
||||
return delivery.PhrasedReminder{}, err
|
||||
}
|
||||
body, mood := parseResponseMood(resp)
|
||||
body, mood, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
// Truncated JSON. Fall through to the reminder's own text.
|
||||
log.Printf("phraser: PhraseReminder: %v", perr)
|
||||
body, mood = "", ""
|
||||
}
|
||||
if body == "" {
|
||||
// fallback: try old body/summary format
|
||||
body, _ = parsePhrase(resp)
|
||||
@@ -394,10 +456,16 @@ type chatReq struct {
|
||||
// Russian, so an ASCII-only rule would make every reply empty. The escape rule
|
||||
// is what lets the model close a string it opened with a quote inside. Length
|
||||
// is bounded so a repetition loop truncates the field, not the JSON object.
|
||||
//
|
||||
// That bound was 400 and 400 was too tight. Measured against Qwen3.5-0.8B: on
|
||||
// "почему гром слышно позже молнии?" the reply came back exactly 400 characters
|
||||
// long, cut mid-word ("Нужно записать и,"), at every token cap from 256 to 2048.
|
||||
// So the token cap was never what stopped it — this rule was. 1000 characters is
|
||||
// roughly six Russian sentences, still short enough to stop a repetition loop.
|
||||
const responseGrammar = `
|
||||
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
|
||||
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
|
||||
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,400} "\""
|
||||
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,1000} "\""
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
@@ -483,12 +551,21 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
||||
// Russian only, feminine self-reference, second person masculine (the owner is
|
||||
// a man). She talks TO him, informally, singular — never "вы", never "он".
|
||||
// One short sentence — the nudge is spoken aloud.
|
||||
//
|
||||
// What the ban on обращения forbids is pet names ("дорогой", "милый"), not his
|
||||
// name: "Ками, ноутбук на трёх процентах" is exactly how she talks, and the
|
||||
// unqualified word read as forbidding that too. Hence "ласковые обращения".
|
||||
//
|
||||
// The examples also never claim a physical act. She has no hands and no smart
|
||||
// plug — she can tell him the battery is at three percent, she cannot put the
|
||||
// laptop on charge. An example that says she did teaches the model to invent
|
||||
// actions Maven never took, which is worse than a missing nudge.
|
||||
const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл").
|
||||
Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||||
|
||||
Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу.
|
||||
|
||||
Запрещено: обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов.
|
||||
Запрещено: ласковые обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов.
|
||||
|
||||
Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood".
|
||||
"response" — сам текст напоминания.
|
||||
@@ -496,7 +573,7 @@ const nudgeSystem = `Ты — Maven, домашняя ассистентка. О
|
||||
|
||||
Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет:
|
||||
{"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"}
|
||||
{"response": "Ноутбук на трёх процентах. Я поставила его на зарядку.", "mood": "confused"}
|
||||
{"response": "Ками, ноутбук на трёх процентах. Поставь его на зарядку.", "mood": "confused"}
|
||||
|
||||
Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.`
|
||||
|
||||
@@ -507,7 +584,9 @@ func (p *LLMPhraser) systemPrompt() string {
|
||||
// querySystemPrompt returns the system prompt for PhraseQuery (notes + general
|
||||
// knowledge). Prepends the configured persona when set.
|
||||
func (p *LLMPhraser) querySystemPrompt() string {
|
||||
base := "You are maven, a self-hosted personal assistant answering from your notes. Answer briefly and naturally in Russian starting with \"вот что я нашла: \". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
|
||||
// No self-introduction here: the persona block prepended one line above
|
||||
// already says who she is, same as router.KnowledgePrompt.
|
||||
base := "Ты отвечаешь ему по своим заметкам. Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде (\"нашла\", \"записала\"). Он мужчина, обращайся к нему на \"ты\". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
|
||||
return persona.Prepend(p.cfg.ContextBlock, base)
|
||||
}
|
||||
|
||||
@@ -629,21 +708,39 @@ type responseMood struct {
|
||||
Mood string `json:"mood"`
|
||||
}
|
||||
|
||||
// errBrokenJSON — the model started a JSON object and never finished it.
|
||||
// That is a failed generation, not a reply. Callers must use their fallback.
|
||||
var errBrokenJSON = fmt.Errorf("phraser: model output starts as JSON but does not parse")
|
||||
|
||||
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
|
||||
// of thinking tokens and extra text before/after the JSON block. Returns
|
||||
// ("", "") when no valid JSON is found.
|
||||
func parseResponseMood(raw string) (response, mood string) {
|
||||
// of thinking tokens and extra text before/after the JSON block.
|
||||
//
|
||||
// Three outcomes:
|
||||
// - parsed fine → the fields, nil error.
|
||||
// - output never looked like JSON → ("", "", nil). The caller may ship it
|
||||
// as-is; small models sometimes answer in bare prose and that is fine.
|
||||
// - output starts with "{" but does not parse → errBrokenJSON. The grammar
|
||||
// guarantees a valid *prefix*, so a generation that hits the token cap
|
||||
// mid-object comes back as a fragment like `{` or `{\n "`. Shipping that
|
||||
// as a reply is the bug this error exists to stop.
|
||||
func parseResponseMood(raw string) (response, mood string, err error) {
|
||||
cleaned := strings.TrimSpace(raw)
|
||||
start := strings.Index(cleaned, "{")
|
||||
end := strings.LastIndex(cleaned, "}")
|
||||
if start < 0 || end < 0 || end <= start {
|
||||
return "", ""
|
||||
if strings.HasPrefix(cleaned, "{") {
|
||||
return "", "", errBrokenJSON
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
var parsed responseMood
|
||||
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
|
||||
return "", ""
|
||||
if e := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); e != nil {
|
||||
if strings.HasPrefix(cleaned, "{") {
|
||||
return "", "", errBrokenJSON
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
return parsed.Response, parsed.Mood
|
||||
return parsed.Response, parsed.Mood, nil
|
||||
}
|
||||
|
||||
func parsePhrase(raw string) (body, summary string) {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"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