Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9a24334ea | |||
| c97aebf55a | |||
| 891136c65d | |||
| 41c7c13f42 | |||
| a324e8f624 | |||
| 51805e7f35 | |||
| 533f0acda8 | |||
| 4f59ba78c6 | |||
| d0afd9d4f6 | |||
| 6b67e6f3c2 | |||
| 742b2ad1d7 | |||
| b300ac5c70 | |||
| 13e5170e9e | |||
| 0b90952e55 | |||
| aa8f5b2ee2 | |||
| d7cdcb63bd | |||
| ddb658ffbb | |||
| c7dadc97d9 | |||
| c9d88c152e | |||
| 1890ff5d5d | |||
| 0110e9bc8c | |||
| 50ca8c8b5a | |||
| de09471421 | |||
| d65c16a567 | |||
| 062d4252ef | |||
| 2c27e2ce1f | |||
| 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`.
|
||||
@@ -82,9 +93,23 @@ workspace enforces that the Go and relabelling prompts remain identical.
|
||||
|
||||
## Non-goals (hard constraints)
|
||||
|
||||
Never phones home. Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
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`).
|
||||
|
||||
**"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
|
||||
world questions, so she needs to read external sources. What replaces it:
|
||||
|
||||
- **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing
|
||||
about Maven is reported to anyone, and inference stays on the box.
|
||||
- **Local sources first.** Kiwix ZIMs on homesrv (Wikipedia, ifixit) before anything on the
|
||||
network. Reading beats recalling for a small model, and a local read costs nothing.
|
||||
- **External search is allowed and off unless configured**, like the weather and telegram
|
||||
capabilities.
|
||||
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
|
||||
his stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||||
out, never the persona block, history, or matched notes.
|
||||
|
||||
## Web UI conventions
|
||||
|
||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
|
||||
**Maven** — self-hosted personal assistant. Manages your day, acts on your
|
||||
homelab. One daemon on homesrv (always-on, not the workstation), multiple
|
||||
client surfaces. All local, never phones home.
|
||||
client surfaces. Inference and data stay on the box; she may READ external
|
||||
sources (see Non-goals — "never phones home" is deprecated).
|
||||
|
||||
Primary name is "Maven", with feminine-gendered Russian self-reference
|
||||
("она", "меня", "помогла"). Clients may choose their own UI label. Consistent
|
||||
@@ -35,8 +36,13 @@ Inside boundary — the ones that actually constrain the build:
|
||||
she records. A confident wrong fact is worse than a known gap.
|
||||
- **Not a nag** — she'd rather miss a nudge than be mutable. Shuts up when
|
||||
uncertain. Load-bearing.
|
||||
- **Not a stranger** — runs on your stuff, your model, your data. Never
|
||||
phones home.
|
||||
- **Not a stranger** — runs on your stuff, your model, your data. No
|
||||
telemetry, no cloud model, no third-party account. She may READ external
|
||||
sources to answer world questions (Kiwix first, then optional search); she
|
||||
never reports anything about you to anyone, and your notes and facts are
|
||||
never used as search input. **"Never phones home" as an absolute is
|
||||
deprecated** — owner's call, 2026-07-31: a small model does not know enough
|
||||
to be useful without reading.
|
||||
- **Not a relationship** — mom-tone is a function that makes nudges land, not
|
||||
emotional company. Names the drift a warm small model falls into.
|
||||
|
||||
@@ -458,7 +464,7 @@ decides *insistence*. Both are needed.
|
||||
|
||||
sev ≤ 2 drops on away, sev ≥ 3 holds: a missed water nudge is noise, a missed
|
||||
backup failure isn't. Away-channels (ntfy/telegram) leave the box — the one
|
||||
path that crosses "never phones home," through your own relay. **Minimal
|
||||
path that leaves the box for a person to see, through your own relay. **Minimal
|
||||
body** — "disk low on homesrv," not detail; don't make notifications a
|
||||
shoulder-surf exfil surface.
|
||||
|
||||
|
||||
+118
-3
@@ -1,8 +1,18 @@
|
||||
# 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**.
|
||||
|
||||
@@ -99,3 +109,108 @@ 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`.
|
||||
- `/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`.
|
||||
|
||||
@@ -103,14 +103,17 @@ eval-router:
|
||||
eval-recall:
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/memory/recalleval/
|
||||
|
||||
# eval-phrasing -- score nudge phrasing (internal/phraser/eval). Verbose so the
|
||||
# eval-phrasing -- score nudge phrasing AND the conversational paths (chat,
|
||||
# query, general knowledge) in internal/phraser/eval. Verbose so the
|
||||
# report and every generated message land in the terminal. With no environment
|
||||
# it scores the deterministic Stub only, which is what CI runs. Set
|
||||
# MAVEN_LLM_URL to add the resident model:
|
||||
# MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing
|
||||
# The model run is slow (minutes) -- the timeout is raised to match.
|
||||
# The model run is slow (minutes) -- the timeout is raised to match. It covers
|
||||
# two fixtures now (15 nudges + 27 conversational cases, and the chat replies are
|
||||
# the long ones), hence 90m rather than 40m.
|
||||
eval-phrasing:
|
||||
$(GO) test -v -count=1 -timeout 40m ./internal/phraser/eval/
|
||||
$(GO) test -v -count=1 -timeout 90m ./internal/phraser/eval/
|
||||
|
||||
# eval-models — score ONE llama-server against the same fixture, for the
|
||||
# resident-model bake-off (#278, #250). Start a server with the gguf you want,
|
||||
|
||||
@@ -90,4 +90,7 @@ later* is the worker + RAG.
|
||||
4. **Deferred work** — larger reasoner, custom Piper voice and other expansions.
|
||||
|
||||
## Non-goals (unchanged)
|
||||
Never phones home. Not a nag. Not autonomous. Feminine-gendered RU self-ref.
|
||||
Not a nag. Not autonomous. Feminine-gendered RU self-ref. No telemetry, no
|
||||
cloud model, no third-party account — but she MAY read external sources to
|
||||
answer world questions (Kiwix first, search optional). "Never phones home" as
|
||||
an absolute is deprecated, owner's call 2026-07-31; see CLAUDE.md § Non-goals.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+41
-21
@@ -58,6 +58,7 @@ import (
|
||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
@@ -271,13 +272,14 @@ func run(args []string) error {
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
Persona: personaFromCfg(cfg),
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
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 == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
@@ -441,13 +443,14 @@ func run(args []string) error {
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
Persona: personaFromCfg(cfg),
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
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 == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
@@ -601,12 +604,29 @@ func run(args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// personaFromCfg extracts the voice persona from the config, or returns ""
|
||||
// when voice isn't configured. Used to pass a character prompt into the
|
||||
// LLM phraser without requiring voice to be enabled.
|
||||
func personaFromCfg(cfg *config.Config) string {
|
||||
if cfg.Voice != nil {
|
||||
return cfg.Voice.Persona
|
||||
// personaFacts reads the optional, deployment-specific facts (his name, his
|
||||
// city, the free-text persona string) out of the config. Everything here may
|
||||
// be empty — the context block is correct without any of it.
|
||||
func personaFacts(cfg *config.Config) persona.Facts {
|
||||
f := persona.Facts{
|
||||
// Telegram lives outside the voice block, so it counts either way.
|
||||
Telegram: cfg.Telegram != nil && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
}
|
||||
return ""
|
||||
if cfg.Voice == nil {
|
||||
return f
|
||||
}
|
||||
f.OwnerName = cfg.Voice.OwnerName
|
||||
f.City = cfg.Voice.City
|
||||
f.Static = cfg.Voice.Persona
|
||||
// Same test wireVoice uses to pick the real provider over the stub.
|
||||
f.Weather = cfg.Voice.Weather != nil && cfg.Voice.Weather.Provider == "open-meteo"
|
||||
f.Tools = len(cfg.Voice.Tools) > 0
|
||||
return f
|
||||
}
|
||||
|
||||
// contextBlockFn returns the per-turn renderer of the shared context block.
|
||||
// Per turn, not once at startup, because the block states the current time.
|
||||
func contextBlockFn(cfg *config.Config, now func() time.Time) func() string {
|
||||
f := personaFacts(cfg)
|
||||
return func() string { return f.Block(now()) }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
@@ -23,13 +24,17 @@ type completer interface {
|
||||
type llmReplier struct {
|
||||
c completer
|
||||
stub *voice.StubReplier
|
||||
|
||||
// block renders the shared context block per turn (who he is, the time).
|
||||
// nil ⇒ the prompt stands alone.
|
||||
block func() string
|
||||
}
|
||||
|
||||
func newLLMReplier(c completer) *llmReplier {
|
||||
return &llmReplier{c: c, stub: voice.NewStubReplier()}
|
||||
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.`
|
||||
|
||||
@@ -40,7 +45,7 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
out, err := r.c.Complete(ctx, llm.Req{
|
||||
System: replySystem,
|
||||
System: persona.Prepend(r.block, replySystem),
|
||||
User: replyContext(d),
|
||||
MaxTokens: 512,
|
||||
RepeatPenalty: 1.3,
|
||||
|
||||
@@ -17,7 +17,7 @@ type mockCompleter struct {
|
||||
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
||||
|
||||
func TestLLMReplierReturnsLLMReply(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`})
|
||||
r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||
@@ -25,7 +25,7 @@ func TestLLMReplierReturnsLLMReply(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"})
|
||||
r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"}, nil)
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||
@@ -33,7 +33,7 @@ func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{err: errTestLLMDown})
|
||||
r := newLLMReplier(mockCompleter{err: errTestLLMDown}, nil)
|
||||
noteDec := router.Decision{Intent: router.IntentNote}
|
||||
got := r.Reply(noteDec)
|
||||
want := voice.NewStubReplier().Reply(noteDec)
|
||||
@@ -43,7 +43,7 @@ func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: ""})
|
||||
r := newLLMReplier(mockCompleter{out: ""}, nil)
|
||||
noteDec := router.Decision{Intent: router.IntentNote}
|
||||
got := r.Reply(noteDec)
|
||||
want := voice.NewStubReplier().Reply(noteDec)
|
||||
@@ -53,7 +53,7 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: "я всё поняла"})
|
||||
r := newLLMReplier(mockCompleter{out: "я всё поняла"}, nil)
|
||||
clarifyDec := router.Decision{Clarify: true}
|
||||
got := r.Reply(clarifyDec)
|
||||
want := voice.NewStubReplier().Reply(clarifyDec)
|
||||
|
||||
+1
-1
@@ -246,7 +246,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// ----- replier (LLM-backed when the engine is on, Stub floor otherwise) -----
|
||||
replier := voice.Replier(voice.NewStubReplier())
|
||||
if llmClient != nil {
|
||||
replier = newLLMReplier(llmClient)
|
||||
replier = newLLMReplier(llmClient, contextBlockFn(cfg, time.Now))
|
||||
}
|
||||
|
||||
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||
|
||||
+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": {
|
||||
|
||||
@@ -302,6 +302,13 @@ type VoiceConfig struct {
|
||||
// Russian self-reference). Example: "Be formal and answer in English only."
|
||||
Persona string `json:"persona,omitempty"`
|
||||
|
||||
// OwnerName / City — optional facts about the owner, added to the shared
|
||||
// context block (internal/persona). Empty is fine: the block still states
|
||||
// who he is grammatically (a man, addressed as "ты") and the current time.
|
||||
// Nothing about correct behaviour may depend on these being filled in.
|
||||
OwnerName string `json:"owner_name,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
|
||||
// Weather — the weather provider config. nil ⇒ the daemon wires
|
||||
// the stub provider (returns ErrNotConfigured — "погода не настроена").
|
||||
// Set provider to "open-meteo" to use the keyless Open-Meteo API.
|
||||
@@ -362,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)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package kiwix reads a local Kiwix server (offline Wikipedia and friends).
|
||||
//
|
||||
// Why: the resident model is a 0.8B and invents facts. Letting her read a local
|
||||
// article snippet beats letting her recall. Nothing here talks to the internet;
|
||||
// the Kiwix server is on the same box.
|
||||
//
|
||||
// This is search only. Full articles are ~100KB of HTML, far too big for a 4096
|
||||
// token context, so the unit of context is the search snippet (~500 chars).
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result is one search hit.
|
||||
type Result struct {
|
||||
Title string // article title, e.g. "Rayleigh scattering"
|
||||
Path string // e.g. /content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering
|
||||
Snippet string // plain text, tags stripped, entities decoded
|
||||
WordCount int // 0 if the server did not say
|
||||
}
|
||||
|
||||
// Client is a Kiwix HTTP client. Boring on purpose: no retries, no cache.
|
||||
type Client struct {
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New makes a client for a Kiwix base URL like http://127.0.0.1:8034.
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{
|
||||
base: strings.TrimRight(baseURL, "/"),
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Search runs a keyword search in one ZIM (book) and returns up to limit hits.
|
||||
//
|
||||
// Ranking is keyword based, not semantic: "Rayleigh scattering" finds the right
|
||||
// article, "why is the sky blue" finds a TV episode. Pass keywords, not questions.
|
||||
func (c *Client) Search(ctx context.Context, pattern, book string, limit int) ([]Result, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("pattern", pattern)
|
||||
q.Set("books.name", book)
|
||||
q.Set("format", "xml")
|
||||
q.Set("pageLength", strconv.Itoa(limit))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/search?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("kiwix search: http %d", resp.StatusCode)
|
||||
}
|
||||
return ParseSearchRSS(resp.Body)
|
||||
}
|
||||
|
||||
// rss mirrors just the bits of the RSS 2.0 reply we use.
|
||||
type rss struct {
|
||||
Items []struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
// innerxml keeps the <b> match markers so we can strip them ourselves.
|
||||
Description struct {
|
||||
Inner string `xml:",innerxml"`
|
||||
} `xml:"description"`
|
||||
WordCount string `xml:"wordCount"`
|
||||
} `xml:"channel>item"`
|
||||
}
|
||||
|
||||
var tagRE = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// ParseSearchRSS turns a Kiwix search reply into results. Exported so the parser
|
||||
// is testable from a captured response, with no server running.
|
||||
func ParseSearchRSS(r io.Reader) ([]Result, error) {
|
||||
var doc rss
|
||||
if err := xml.NewDecoder(r).Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("kiwix search: bad xml: %w", err)
|
||||
}
|
||||
out := make([]Result, 0, len(doc.Items))
|
||||
for _, it := range doc.Items {
|
||||
n, _ := strconv.Atoi(strings.ReplaceAll(it.WordCount, ",", ""))
|
||||
out = append(out, Result{
|
||||
Title: strings.TrimSpace(it.Title),
|
||||
Path: strings.TrimSpace(it.Link),
|
||||
Snippet: plainText(it.Description.Inner),
|
||||
WordCount: n,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// plainText drops markup and decodes entities, leaving text a model can read.
|
||||
func plainText(s string) string {
|
||||
s = tagRE.ReplaceAllString(s, "")
|
||||
s = html.UnescapeString(s)
|
||||
return strings.TrimSpace(strings.Join(strings.Fields(s), " "))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A real reply from the live server, trimmed to two items.
|
||||
const sampleRSS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<channel>
|
||||
<title>Search: Rayleigh scattering</title>
|
||||
<opensearch:totalResults>800</opensearch:totalResults>
|
||||
<item>
|
||||
<title>Rayleigh scattering</title>
|
||||
<link>/content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering</link>
|
||||
<description><b>Rayleigh</b> scattering causes the blue color of the sky & yellow colors near the Sun.[1]</description>
|
||||
<book><title>Wikipedia</title></book>
|
||||
<wordCount>2,818</wordCount>
|
||||
</item>
|
||||
<item>
|
||||
<title>Hyper–Rayleigh scattering</title>
|
||||
<link>/content/wikipedia_en_all_maxi_2026-02/Hyper%E2%80%93Rayleigh_scattering</link>
|
||||
<description>...<b>Rayleigh</b> scattering" is a nonlinear optical counterpart.</description>
|
||||
<book><title>Wikipedia</title></book>
|
||||
<wordCount>914</wordCount>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
func TestParseSearchRSS(t *testing.T) {
|
||||
got, err := ParseSearchRSS(strings.NewReader(sampleRSS))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 results, got %d", len(got))
|
||||
}
|
||||
if got[0].Title != "Rayleigh scattering" {
|
||||
t.Errorf("title = %q", got[0].Title)
|
||||
}
|
||||
if got[0].Path != "/content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering" {
|
||||
t.Errorf("path = %q", got[0].Path)
|
||||
}
|
||||
if got[0].WordCount != 2818 {
|
||||
t.Errorf("wordCount = %d", got[0].WordCount)
|
||||
}
|
||||
want := "Rayleigh scattering causes the blue color of the sky & yellow colors near the Sun.[1]"
|
||||
if got[0].Snippet != want {
|
||||
t.Errorf("snippet = %q, want %q", got[0].Snippet, want)
|
||||
}
|
||||
if strings.Contains(got[1].Snippet, "<b>") {
|
||||
t.Errorf("second snippet still has tags: %q", got[1].Snippet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearchRSSBadXML(t *testing.T) {
|
||||
if _, err := ParseSearchRSS(strings.NewReader("not xml at all")); err == nil {
|
||||
t.Fatal("want an error on junk input")
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in: needs a live Kiwix server. CI has none.
|
||||
// MAVEN_KIWIX_URL=http://127.0.0.1:8034 no_proxy=127.0.0.1,localhost go test -run Retrieval -v ./internal/kiwix/
|
||||
func TestRetrievalEval(t *testing.T) {
|
||||
base := os.Getenv("MAVEN_KIWIX_URL")
|
||||
if base == "" {
|
||||
t.Skip("set MAVEN_KIWIX_URL to run the retrieval eval")
|
||||
}
|
||||
noProxyLoopback(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
rep, err := RunRetrievalEval(ctx, New(base), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
// No pass bar on purpose: the number is the finding.
|
||||
t.Log("\n" + rep.String() + rep.Detail())
|
||||
}
|
||||
|
||||
// noProxyLoopback stops the box's SOCKS bridge from eating loopback requests.
|
||||
func noProxyLoopback(t *testing.T) {
|
||||
t.Setenv("no_proxy", "127.0.0.1,localhost")
|
||||
t.Setenv("NO_PROXY", "127.0.0.1,localhost")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "kiwix-knowledge-v1",
|
||||
"book": "wikipedia_en_all_maxi_2026-02",
|
||||
"note": "The 9 knowledge cases from internal/phraser/eval/talk_v1.json. Queries are hand-written English keywords on purpose: Kiwix ranks by keyword, not meaning, so a natural question fails. Writing them by hand separates 'retrieval is broken' from 'the model writes bad queries'.",
|
||||
"cases": [
|
||||
{
|
||||
"id": "know-sky-blue",
|
||||
"question": "почему небо синее?",
|
||||
"query": "Rayleigh scattering sky blue",
|
||||
"want_titles": ["Rayleigh scattering", "Diffuse sky radiation"]
|
||||
},
|
||||
{
|
||||
"id": "know-boil-egg",
|
||||
"question": "сколько варить яйцо вкрутую?",
|
||||
"query": "boiled egg cooking",
|
||||
"want_titles": ["Boiled egg", "Egg as food"]
|
||||
},
|
||||
{
|
||||
"id": "know-ssd-vs-hdd",
|
||||
"question": "чем ssd отличается от hdd?",
|
||||
"query": "solid-state drive",
|
||||
"want_titles": ["Solid-state drive", "Hard disk drive"]
|
||||
},
|
||||
{
|
||||
"id": "know-cat-purr",
|
||||
"question": "почему кошки мурчат?",
|
||||
"query": "cat purr",
|
||||
"want_titles": ["Purr", "Cat communication"]
|
||||
},
|
||||
{
|
||||
"id": "know-hiccups",
|
||||
"question": "как быстро избавиться от икоты?",
|
||||
"query": "hiccup",
|
||||
"want_titles": ["Hiccup"]
|
||||
},
|
||||
{
|
||||
"id": "know-polite-form",
|
||||
"question": "не могли бы вы объяснить, что такое vpn?",
|
||||
"query": "virtual private network",
|
||||
"want_titles": ["Virtual private network"]
|
||||
},
|
||||
{
|
||||
"id": "know-dont-know",
|
||||
"question": "как зовут моего соседа снизу?",
|
||||
"query": "name of my downstairs neighbour",
|
||||
"want_titles": [],
|
||||
"expect_miss": true,
|
||||
"note": "Unanswerable by design. Retrieval SHOULD find nothing useful. Counted as a hit only when nothing relevant comes back."
|
||||
},
|
||||
{
|
||||
"id": "know-water-per-day",
|
||||
"question": "сколько воды в день надо пить?",
|
||||
"query": "human daily water requirement drinking",
|
||||
"want_titles": ["Drinking water", "Water", "Dehydration", "Hydration"]
|
||||
},
|
||||
{
|
||||
"id": "know-thunder-delay",
|
||||
"question": "почему гром слышно позже молнии?",
|
||||
"query": "thunder speed of sound lightning",
|
||||
"want_titles": ["Thunder", "Lightning"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package kiwix
|
||||
|
||||
// This scores retrieval alone: no LLM. For each general-knowledge question we
|
||||
// hand-write English keywords and ask whether the article that would answer it
|
||||
// comes back in the top N hits. If this score is low, reading Wikipedia cannot
|
||||
// help the model no matter how good the prompt is.
|
||||
//
|
||||
// The unanswerable case (know-dont-know) is not scored. Whether the junk it
|
||||
// returns is "nothing useful" is a human judgement, so the report just prints
|
||||
// the titles and leaves the score to the 8 answerable cases.
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed knowledge_v1.json
|
||||
var knowledgeFixtureJSON []byte
|
||||
|
||||
// EvalCase — one question with hand-written keywords.
|
||||
type EvalCase struct {
|
||||
ID string `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Query string `json:"query"`
|
||||
WantTitles []string `json:"want_titles"`
|
||||
ExpectMiss bool `json:"expect_miss"`
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
Name string `json:"name"`
|
||||
Book string `json:"book"`
|
||||
Cases []EvalCase `json:"cases"`
|
||||
}
|
||||
|
||||
// Outcome — what one case retrieved.
|
||||
type Outcome struct {
|
||||
Case EvalCase
|
||||
Titles []string // titles of the top N hits, in rank order
|
||||
Rank int // 1-based rank of the first wanted title, 0 if none
|
||||
Err error
|
||||
}
|
||||
|
||||
// Hit is true when a wanted title came back.
|
||||
func (o Outcome) Hit() bool { return o.Rank > 0 }
|
||||
|
||||
// Report — the score plus per-case detail.
|
||||
type Report struct {
|
||||
Name string
|
||||
Book string
|
||||
TopN int
|
||||
Scored int // answerable cases
|
||||
Hits int
|
||||
Errors int
|
||||
Outcomes []Outcome
|
||||
}
|
||||
|
||||
// Accuracy over the answerable cases.
|
||||
func (r Report) Accuracy() float64 {
|
||||
if r.Scored == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.Hits) / float64(r.Scored)
|
||||
}
|
||||
|
||||
// RunRetrievalEval searches for every fixture case.
|
||||
func RunRetrievalEval(ctx context.Context, c *Client, topN int) (Report, error) {
|
||||
var f fixture
|
||||
if err := json.Unmarshal(knowledgeFixtureJSON, &f); err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
rep := Report{Name: f.Name, Book: f.Book, TopN: topN}
|
||||
for _, cs := range f.Cases {
|
||||
res, err := c.Search(ctx, cs.Query, f.Book, topN)
|
||||
o := Outcome{Case: cs, Err: err}
|
||||
if err != nil {
|
||||
rep.Errors++
|
||||
}
|
||||
for i, hit := range res {
|
||||
o.Titles = append(o.Titles, hit.Title)
|
||||
if o.Rank == 0 && matches(cs.WantTitles, hit.Title) {
|
||||
o.Rank = i + 1
|
||||
}
|
||||
}
|
||||
if !cs.ExpectMiss {
|
||||
rep.Scored++
|
||||
if o.Hit() {
|
||||
rep.Hits++
|
||||
}
|
||||
}
|
||||
rep.Outcomes = append(rep.Outcomes, o)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func matches(want []string, title string) bool {
|
||||
for _, w := range want {
|
||||
if strings.EqualFold(strings.TrimSpace(title), w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// String — the headline number.
|
||||
func (r Report) String() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s: %d/%d answerable questions retrieve a wanted article in top %d (%.1f%%), %d errors\n",
|
||||
r.Name, r.Hits, r.Scored, r.TopN, 100*r.Accuracy(), r.Errors)
|
||||
fmt.Fprintf(&b, " book: %s\n", r.Book)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Detail — per case: what was asked, what was searched, what came back.
|
||||
func (r Report) Detail() string {
|
||||
var b strings.Builder
|
||||
for _, o := range r.Outcomes {
|
||||
mark := "MISS"
|
||||
switch {
|
||||
case o.Case.ExpectMiss:
|
||||
mark = "n/a "
|
||||
case o.Hit():
|
||||
mark = fmt.Sprintf("hit@%d", o.Rank)
|
||||
}
|
||||
fmt.Fprintf(&b, " %-6s %-20s q=%q\n", mark, o.Case.ID, o.Case.Query)
|
||||
if o.Err != nil {
|
||||
fmt.Fprintf(&b, " error: %v\n", o.Err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " got: %s\n", strings.Join(o.Titles, " | "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package kiwix
|
||||
|
||||
// Turning a Russian question into an English Kiwix search.
|
||||
//
|
||||
// Kiwix ranks by keyword, not by meaning. "why is the sky blue" returns a TV
|
||||
// episode; "Rayleigh scattering sky blue" returns the right article. So the
|
||||
// model's job here is NOT translation — it is naming the English article the
|
||||
// answer lives in.
|
||||
//
|
||||
// The output space is a handful of words, so it is worth locking down hard: a
|
||||
// GBNF grammar for the shape, a tiny token cap, and a cleanup pass that throws
|
||||
// away anything odd rather than handing junk to Kiwix.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// Completer — the LLM seam, so tests can fake it. *llm.Client satisfies it.
|
||||
type Completer interface {
|
||||
Complete(ctx context.Context, r llm.Req) (string, error)
|
||||
}
|
||||
|
||||
// queryGrammar — one JSON object holding 1..6 keyword words. Latin letters,
|
||||
// digits and hyphens only, so the model physically cannot answer the question
|
||||
// or reply in Russian.
|
||||
//
|
||||
// Why the JSON wrapper: this model always thinks out loud and this llama-server
|
||||
// build ignores the thinking switch (see ROUTING-EVAL-31-07-2026.md). A bare
|
||||
// word-list grammar just captured the reasoning — every case came back as
|
||||
// "Let me analyze this request carefully". Demanding JSON, like routeGrammar and
|
||||
// responseGrammar already do, gives the reasoning nowhere to go.
|
||||
const queryGrammar = `
|
||||
root ::= "{" ws "\"query\"" ws ":" ws "\"" word (" " word){0,5} "\"" ws "}"
|
||||
word ::= [A-Za-z0-9] [A-Za-z0-9-]{0,23}
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
// rewriteSystem — asks for search keywords, not an answer and not a translation.
|
||||
const rewriteSystem = `You turn a question into a search query for English Wikipedia.
|
||||
|
||||
Rules:
|
||||
- Output ONLY English search keywords. Never an answer, never an explanation.
|
||||
- Do NOT translate the sentence. Name the thing the answer is about.
|
||||
- The output must be a noun phrase, like a Wikipedia article title.
|
||||
- Never use question words: no why, how, what, when, which, "how much",
|
||||
"how long", "how to", "vs", "reason", "difference".
|
||||
- 2 to 4 words.
|
||||
|
||||
Reply with JSON: {"query":"<keywords>"}
|
||||
|
||||
Good:
|
||||
"почему листья желтеют осенью?" -> {"query":"leaf senescence autumn"}
|
||||
"как работает микроволновка?" -> {"query":"microwave oven"}
|
||||
"не могли бы вы объяснить, что такое блокчейн?" -> {"query":"blockchain"}
|
||||
"сколько живут собаки?" -> {"query":"dog lifespan"}
|
||||
"как избавиться от комаров в квартире?" -> {"query":"mosquito control"}
|
||||
"чем чай отличается от кофе?" -> {"query":"tea"}
|
||||
|
||||
Only JSON, no explanation.`
|
||||
|
||||
// maxQueryTokens — the output is a few words plus the JSON wrapper. A tight cap
|
||||
// is the cheapest guard against the model rambling into an answer.
|
||||
const maxQueryTokens = 32
|
||||
|
||||
// Rewriter asks the resident model for English search keywords.
|
||||
type Rewriter struct{ c Completer }
|
||||
|
||||
func NewRewriter(c Completer) *Rewriter { return &Rewriter{c: c} }
|
||||
|
||||
// Rewrite returns English keywords for a question in any language.
|
||||
// It errors rather than returning something Kiwix should not see.
|
||||
func (r *Rewriter) Rewrite(ctx context.Context, question string) (string, error) {
|
||||
raw, err := r.c.Complete(ctx, llm.Req{
|
||||
System: rewriteSystem,
|
||||
User: strings.TrimSpace(question),
|
||||
Grammar: queryGrammar,
|
||||
MaxTokens: maxQueryTokens,
|
||||
RepeatPenalty: 1.15,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return CleanQuery(unwrapJSON(raw))
|
||||
}
|
||||
|
||||
// unwrapJSON pulls the query out of {"query":"..."}. If the reply is not that
|
||||
// shape it is returned as-is, and CleanQuery decides whether it is usable.
|
||||
func unwrapJSON(raw string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(s, "{") {
|
||||
return s
|
||||
}
|
||||
var got struct{ Query string }
|
||||
if err := json.Unmarshal([]byte(s), &got); err != nil {
|
||||
return s
|
||||
}
|
||||
return got.Query
|
||||
}
|
||||
|
||||
// maxQueryWords matches the grammar's bound. Anything longer is prose.
|
||||
const maxQueryWords = 6
|
||||
|
||||
// CleanQuery checks and tidies whatever the model produced. The grammar makes
|
||||
// bad output unlikely, not impossible (a server without grammar support, a
|
||||
// different model), so this is the real gate in front of Kiwix.
|
||||
//
|
||||
// Exported so it can be tested without a model.
|
||||
func CleanQuery(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
// Models like to wrap answers in quotes. Drop surrounding ones.
|
||||
s = strings.Trim(s, "\"'`")
|
||||
// Keep the first line only: everything after it is prose.
|
||||
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
// Keep letters, digits, spaces and hyphens; anything else becomes a space.
|
||||
var b strings.Builder
|
||||
for _, ru := range s {
|
||||
switch {
|
||||
case unicode.IsLetter(ru) || unicode.IsDigit(ru) || ru == '-':
|
||||
b.WriteRune(ru)
|
||||
default:
|
||||
b.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
words := strings.Fields(b.String())
|
||||
if len(words) == 0 {
|
||||
return "", fmt.Errorf("kiwix rewrite: empty query")
|
||||
}
|
||||
if len(words) > maxQueryWords {
|
||||
return "", fmt.Errorf("kiwix rewrite: %d words, want at most %d (looks like prose)", len(words), maxQueryWords)
|
||||
}
|
||||
words = dropStopWords(words)
|
||||
out := strings.Join(words, " ")
|
||||
// The ZIMs are English. Non-Latin letters mean the model ignored the ask.
|
||||
for _, ru := range out {
|
||||
if unicode.IsLetter(ru) && !isLatin(ru) {
|
||||
return "", fmt.Errorf("kiwix rewrite: query is not English: %q", out)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stopWords — question words and filler. The model keeps writing question-shaped
|
||||
// queries ("why is the sky blue", "how much water to drink daily") no matter how
|
||||
// the prompt is worded, and Kiwix ranks on every word, so those words drag in
|
||||
// song and episode titles. Dropping them in code is not a style preference: a
|
||||
// keyword ranker gets nothing from them.
|
||||
var stopWords = map[string]bool{
|
||||
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true,
|
||||
"do": true, "does": true, "did": true, "to": true, "of": true, "in": true,
|
||||
"on": true, "for": true, "and": true, "or": true, "my": true, "me": true,
|
||||
"i": true, "it": true, "its": true, "be": true, "been": true, "get": true,
|
||||
"how": true, "why": true, "what": true, "when": true, "which": true,
|
||||
"who": true, "where": true, "much": true, "many": true, "long": true,
|
||||
"vs": true, "than": true, "rid": true, "from": true, "about": true,
|
||||
}
|
||||
|
||||
// dropStopWords removes filler, but never everything: if the query was nothing
|
||||
// but stop words there is nothing better to search, so the original is kept and
|
||||
// the caller sees whatever Kiwix makes of it.
|
||||
func dropStopWords(words []string) []string {
|
||||
kept := make([]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
if !stopWords[strings.ToLower(w)] {
|
||||
kept = append(kept, w)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return words
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func isLatin(ru rune) bool {
|
||||
return (ru >= 'a' && ru <= 'z') || (ru >= 'A' && ru <= 'Z')
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package kiwix
|
||||
|
||||
// End-to-end score: Russian question -> model rewrite -> Kiwix search -> did a
|
||||
// wanted article come back. Same 9 cases as the retrieval eval, so the two
|
||||
// numbers are directly comparable: retrieval with hand-written keywords is the
|
||||
// ceiling, this is what the model actually reaches.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RewriteOutcome — one case, end to end.
|
||||
type RewriteOutcome struct {
|
||||
Outcome
|
||||
ModelQuery string // what the model asked for ("" if it failed)
|
||||
RewriteErr error
|
||||
}
|
||||
|
||||
// RunRewriteEval rewrites every question with the model, then searches.
|
||||
func RunRewriteEval(ctx context.Context, c *Client, rw *Rewriter, topN int) (RewriteReport, error) {
|
||||
var f fixture
|
||||
if err := json.Unmarshal(knowledgeFixtureJSON, &f); err != nil {
|
||||
return RewriteReport{}, err
|
||||
}
|
||||
rep := RewriteReport{Report: Report{Name: f.Name + "-rewrite", Book: f.Book, TopN: topN}}
|
||||
for _, cs := range f.Cases {
|
||||
out := RewriteOutcome{Outcome: Outcome{Case: cs}}
|
||||
q, err := rw.Rewrite(ctx, cs.Question)
|
||||
out.ModelQuery, out.RewriteErr = q, err
|
||||
if err == nil {
|
||||
res, serr := c.Search(ctx, q, f.Book, topN)
|
||||
out.Err = serr
|
||||
for i, hit := range res {
|
||||
out.Titles = append(out.Titles, hit.Title)
|
||||
if out.Rank == 0 && matches(cs.WantTitles, hit.Title) {
|
||||
out.Rank = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.RewriteErr != nil || out.Err != nil {
|
||||
rep.Errors++
|
||||
}
|
||||
if !cs.ExpectMiss {
|
||||
rep.Scored++
|
||||
if out.Hit() {
|
||||
rep.Hits++
|
||||
}
|
||||
}
|
||||
rep.Cases = append(rep.Cases, out)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// RewriteReport — the score plus per-case detail.
|
||||
type RewriteReport struct {
|
||||
Report
|
||||
Cases []RewriteOutcome
|
||||
}
|
||||
|
||||
// String — the headline number.
|
||||
func (r RewriteReport) String() string {
|
||||
return fmt.Sprintf("%s: %d/%d answerable questions retrieve a wanted article in top %d (%.1f%%), %d errors\n book: %s\n",
|
||||
r.Name, r.Hits, r.Scored, r.TopN, 100*r.Accuracy(), r.Errors, r.Book)
|
||||
}
|
||||
|
||||
// Detail — per case: hand-written query next to the model's, and what came back.
|
||||
// The point is seeing WHERE the model's phrasing differs, not just the score.
|
||||
func (r RewriteReport) Detail() string {
|
||||
var b strings.Builder
|
||||
for _, o := range r.Cases {
|
||||
mark := "MISS"
|
||||
switch {
|
||||
case o.Case.ExpectMiss:
|
||||
mark = "n/a "
|
||||
case o.Hit():
|
||||
mark = fmt.Sprintf("hit@%d", o.Rank)
|
||||
}
|
||||
fmt.Fprintf(&b, " %-6s %-20s\n", mark, o.Case.ID)
|
||||
fmt.Fprintf(&b, " asked: %s\n", o.Case.Question)
|
||||
fmt.Fprintf(&b, " hand: %q\n", o.Case.Query)
|
||||
fmt.Fprintf(&b, " model: %q\n", o.ModelQuery)
|
||||
if o.RewriteErr != nil {
|
||||
fmt.Fprintf(&b, " rewrite rejected: %v\n", o.RewriteErr)
|
||||
continue
|
||||
}
|
||||
if o.Err != nil {
|
||||
fmt.Fprintf(&b, " search error: %v\n", o.Err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " got: %s\n", strings.Join(o.Titles, " | "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// Opt-in: needs a live Kiwix server AND a live llama-server.
|
||||
// MAVEN_KIWIX_URL=http://127.0.0.1:8034 MAVEN_LLM_URL=http://127.0.0.1:18099 \
|
||||
//
|
||||
// no_proxy=127.0.0.1,localhost go test -run RewriteEval -v ./internal/kiwix/
|
||||
func TestRewriteEval(t *testing.T) {
|
||||
kbase, lbase := os.Getenv("MAVEN_KIWIX_URL"), os.Getenv("MAVEN_LLM_URL")
|
||||
if kbase == "" || lbase == "" {
|
||||
t.Skip("set MAVEN_KIWIX_URL and MAVEN_LLM_URL to run the rewrite eval")
|
||||
}
|
||||
noProxyLoopback(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
rw := NewRewriter(llm.New(lbase, 3*time.Minute))
|
||||
rep, err := RunRewriteEval(ctx, New(kbase), rw, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
// No pass bar on purpose: the number is the finding.
|
||||
t.Log("\n" + rep.String() + rep.Detail())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// Bad model output must never reach Kiwix. No model needed for this.
|
||||
func TestCleanQueryRejectsJunk(t *testing.T) {
|
||||
bad := []struct{ name, raw string }{
|
||||
{"empty", ""},
|
||||
{"blank", " \n "},
|
||||
{"russian came back", "почему небо синее"},
|
||||
{"mixed russian", "sky синее scattering"},
|
||||
{"full sentence", "The sky looks blue because of the scattering of sunlight by air molecules"},
|
||||
{"prose with quotes", `Sure! Here is a good search query: "Rayleigh scattering", which explains it.`},
|
||||
}
|
||||
for _, c := range bad {
|
||||
if got, err := CleanQuery(c.raw); err == nil {
|
||||
t.Errorf("%s: want rejection, got %q", c.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanQueryCleans(t *testing.T) {
|
||||
ok := []struct{ raw, want string }{
|
||||
{"Rayleigh scattering sky", "Rayleigh scattering sky"},
|
||||
{" boiled egg cooking \n", "boiled egg cooking"},
|
||||
{`"virtual private network"`, "virtual private network"},
|
||||
{"solid-state drive", "solid-state drive"},
|
||||
{"cat purr.", "cat purr"},
|
||||
{"hiccup\nAlso: hiccough", "hiccup"},
|
||||
// Question words are filler to a keyword ranker, so they go.
|
||||
{"why is the sky blue", "sky blue"},
|
||||
{"how much water to drink daily", "water drink daily"},
|
||||
{"SSD vs HDD comparison", "SSD HDD comparison"},
|
||||
// Nothing but filler: keep it rather than return nothing.
|
||||
{"what is it", "what is it"},
|
||||
}
|
||||
for _, c := range ok {
|
||||
got, err := CleanQuery(c.raw)
|
||||
if err != nil {
|
||||
t.Errorf("%q: %v", c.raw, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%q -> %q, want %q", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCompleter struct {
|
||||
out string
|
||||
req llm.Req
|
||||
}
|
||||
|
||||
func (f *fakeCompleter) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||
f.req = r
|
||||
return f.out, nil
|
||||
}
|
||||
|
||||
func TestRewriteConstrainsTheCall(t *testing.T) {
|
||||
f := &fakeCompleter{out: `{"query":"Rayleigh scattering sky"}`}
|
||||
got, err := NewRewriter(f).Rewrite(context.Background(), "почему небо синее?")
|
||||
if err != nil {
|
||||
t.Fatalf("rewrite: %v", err)
|
||||
}
|
||||
if got != "Rayleigh scattering sky" {
|
||||
t.Errorf("query = %q", got)
|
||||
}
|
||||
if f.req.Grammar == "" {
|
||||
t.Error("no grammar sent")
|
||||
}
|
||||
if f.req.MaxTokens == 0 || f.req.MaxTokens > 32 {
|
||||
t.Errorf("max_tokens = %d, want a small cap", f.req.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRejectsBadModelOutput(t *testing.T) {
|
||||
bad := []string{
|
||||
`{"query":"почему небо синее"}`, // never translated
|
||||
`{"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
|
||||
// stops that one.
|
||||
}
|
||||
for _, out := range bad {
|
||||
f := &fakeCompleter{out: out}
|
||||
if got, err := NewRewriter(f).Rewrite(context.Background(), "почему небо синее?"); err == nil {
|
||||
t.Errorf("%s: want rejection, got %q", out, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reply that is not the JSON shape but is still usable keywords should pass.
|
||||
func TestRewriteFallsBackToPlainText(t *testing.T) {
|
||||
f := &fakeCompleter{out: "Rayleigh scattering sky"}
|
||||
got, err := NewRewriter(f).Rewrite(context.Background(), "почему небо синее?")
|
||||
if err != nil || got != "Rayleigh scattering sky" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Package persona builds the one shared context block that goes in front of
|
||||
// every LLM system prompt: who the owner is, how to address him, and what
|
||||
// time it is right now.
|
||||
//
|
||||
// Why one block and not a line pasted into each prompt: there are five
|
||||
// prompts (nudges, action replies, chat, note queries, general knowledge) and
|
||||
// the "address him as ты" rule had only reached two of them. Five copies drift.
|
||||
// One block cannot.
|
||||
//
|
||||
// The rules here are defaults in code, not config. Maven is feminine and the
|
||||
// owner is a man addressed informally — that is a hard constraint of the
|
||||
// product, so it must hold with an empty config file. Config only ADDS
|
||||
// optional facts (his name, his city).
|
||||
package persona
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Facts — the optional, deployment-specific half of the block. All fields may
|
||||
// be empty; the block is still correct and useful without them.
|
||||
type Facts struct {
|
||||
OwnerName string // his name, e.g. "Ками"
|
||||
City string // where he is, e.g. "Москва"
|
||||
Static string // the free-text `persona` config string, appended verbatim
|
||||
|
||||
// The two config-gated capabilities. They are listed only when this
|
||||
// deployment actually has them, because a capability she names and cannot
|
||||
// do is worse than one she never mentions.
|
||||
Weather bool // an open-meteo provider is configured
|
||||
Telegram bool // a telegram bot token + chat id are configured
|
||||
Tools bool // at least one shell act is on the allowlist
|
||||
}
|
||||
|
||||
var ruWeekdays = [...]string{"воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"}
|
||||
|
||||
var ruMonths = [...]string{
|
||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
||||
}
|
||||
|
||||
// Block renders the context block for one turn. Russian even in front of the
|
||||
// English prompts: the rules it states are Russian grammar (ты/тебя, feminine
|
||||
// verbs), and a Russian rule reads best stated in Russian.
|
||||
//
|
||||
// Keep it short. It ships on every turn to a 0.8B on laptop CPU, so every
|
||||
// line here is latency.
|
||||
func (f Facts) Block(now time.Time) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде: \"я записала\", \"я проверила\".\n")
|
||||
|
||||
// The address form gets its own line. It is the thing that kept getting
|
||||
// lost when it was buried in prose.
|
||||
b.WriteString("ОБРАЩЕНИЕ: владелец — мужчина, всегда на \"ты\" (ты, тебя, тебе, твой) и в единственном числе (\"выпей\", \"посмотри\"). Никогда \"вы\"/\"вас\"/\"ваш\". Никогда \"он\"/\"его\" о нём — ты говоришь ему, а не о нём. Глаголы о нём — в мужском роде (\"ты забыл\").\n")
|
||||
|
||||
if who := f.who(); who != "" {
|
||||
b.WriteString(who + "\n")
|
||||
}
|
||||
|
||||
b.WriteString(fmt.Sprintf("Сейчас: %s, %d %s %d, %02d:%02d (местное время).\n",
|
||||
ruWeekdays[int(now.Weekday())], now.Day(), ruMonths[int(now.Month())-1], now.Year(),
|
||||
now.Hour(), now.Minute()))
|
||||
|
||||
b.WriteString("Умеешь: " + strings.Join(f.can(), "; ") +
|
||||
". Других ДЕЙСТВИЙ не умеешь — если просят такое, скажи прямо.\n")
|
||||
|
||||
if s := strings.TrimSpace(f.Static); s != "" {
|
||||
b.WriteString(s + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// can lists what she can really do. Every entry here is a code path that
|
||||
// exists in the daemon today:
|
||||
// - reminders: IntentReminder → CoreAPI.CreateReminder, fired by the tick.
|
||||
// - notes and facts: IntentNote/IntentFact write, IntentQuery reads them back.
|
||||
// - calendar: IntentQuery answers "что у меня сегодня" from CalendarEvents.
|
||||
// - weather / telegram / shell acts: only when configured (see Facts).
|
||||
//
|
||||
// Nothing speculative goes in this list. A capability she offers and cannot
|
||||
// perform is worse than one she never mentions.
|
||||
func (f Facts) can() []string {
|
||||
c := []string{
|
||||
// Talking comes first, and the closing line says "действий" rather than
|
||||
// "ничего", because this same block sits in front of the chat and
|
||||
// general-knowledge prompts. A flat "you can do nothing else" would
|
||||
// tell her to refuse the exact thing those two prompts are for.
|
||||
"разговаривать и отвечать на вопросы",
|
||||
"ставить напоминания",
|
||||
"записывать заметки и факты и отвечать по ним",
|
||||
"смотреть календарь",
|
||||
}
|
||||
if f.Weather {
|
||||
c = append(c, "говорить погоду")
|
||||
}
|
||||
if f.Telegram {
|
||||
c = append(c, "писать в телеграм")
|
||||
}
|
||||
if f.Tools {
|
||||
c = append(c, "запускать разрешённые команды на сервере")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// who renders the optional name/city line, or "" when neither is configured.
|
||||
//
|
||||
// Written as labels ("Имя владельца: ..."), not as a sentence with pronouns:
|
||||
// the block's own "ты" is Maven, so "тебя зовут" would read as her name and
|
||||
// "его" would model the third-person form she must never use about him.
|
||||
func (f Facts) who() string {
|
||||
name := strings.TrimSpace(f.OwnerName)
|
||||
city := strings.TrimSpace(f.City)
|
||||
switch {
|
||||
case name != "" && city != "":
|
||||
return "Имя владельца: " + name + ". Город: " + city + "."
|
||||
case name != "":
|
||||
return "Имя владельца: " + name + "."
|
||||
case city != "":
|
||||
return "Город: " + city + "."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Prepend puts the block in front of a system prompt. Nil-safe: a nil renderer
|
||||
// (tests, the stub paths) returns the prompt untouched.
|
||||
func Prepend(block func() string, prompt string) string {
|
||||
if block == nil {
|
||||
return prompt
|
||||
}
|
||||
s := strings.TrimSpace(block())
|
||||
if s == "" {
|
||||
return prompt
|
||||
}
|
||||
return s + "\n\n" + prompt
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package persona
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ref = time.Date(2026, 7, 31, 14, 5, 0, 0, time.UTC)
|
||||
|
||||
// The block must be correct with an empty config: the address form and the
|
||||
// gender rules are hard constraints, not preferences.
|
||||
func TestBlockWorksWithZeroConfig(t *testing.T) {
|
||||
b := Facts{}.Block(ref)
|
||||
for _, want := range []string{"женском роде", "ОБРАЩЕНИЕ", "\"ты\"", "31 июля 2026", "пятница", "14:05"} {
|
||||
if !strings.Contains(b, want) {
|
||||
t.Errorf("block missing %q:\n%s", want, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockAddsOptionalFacts(t *testing.T) {
|
||||
b := Facts{OwnerName: "Ками", City: "Москва", Static: "Будь краткой."}.Block(ref)
|
||||
for _, want := range []string{"Ками", "Москва", "Будь краткой."} {
|
||||
if !strings.Contains(b, want) {
|
||||
t.Errorf("block missing %q:\n%s", want, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The time changes between turns, so two renders must differ.
|
||||
func TestBlockRendersTimePerTurn(t *testing.T) {
|
||||
a := Facts{}.Block(ref)
|
||||
c := Facts{}.Block(ref.Add(time.Hour))
|
||||
if a == c {
|
||||
t.Errorf("block did not change with the clock:\n%s", a)
|
||||
}
|
||||
}
|
||||
|
||||
// She may only offer what this deployment actually has.
|
||||
func TestCapabilitiesAreConfigGated(t *testing.T) {
|
||||
bare := Facts{}.Block(ref)
|
||||
for _, want := range []string{"напоминания", "заметки", "календарь"} {
|
||||
if !strings.Contains(bare, want) {
|
||||
t.Errorf("block missing always-on capability %q:\n%s", want, bare)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"погоду", "телеграм", "команды"} {
|
||||
if strings.Contains(bare, unwanted) {
|
||||
t.Errorf("block offers unconfigured %q:\n%s", unwanted, bare)
|
||||
}
|
||||
}
|
||||
|
||||
full := Facts{Weather: true, Telegram: true, Tools: true}.Block(ref)
|
||||
for _, want := range []string{"погоду", "телеграм", "команды"} {
|
||||
if !strings.Contains(full, want) {
|
||||
t.Errorf("block missing configured capability %q:\n%s", want, full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrependNilIsSafe(t *testing.T) {
|
||||
if got := Prepend(nil, "PROMPT"); got != "PROMPT" {
|
||||
t.Errorf("Prepend(nil) = %q", got)
|
||||
}
|
||||
if got := Prepend(func() string { return " " }, "PROMPT"); got != "PROMPT" {
|
||||
t.Errorf("Prepend(blank) = %q", got)
|
||||
}
|
||||
if got := Prepend(func() string { return "CTX" }, "PROMPT"); got != "CTX\n\nPROMPT" {
|
||||
t.Errorf("Prepend = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Every phrasing prompt must carry the shared context block. This is the
|
||||
// regression guard for the bug that started this: the "ты" rule reached only
|
||||
// two of the five prompts because each prompt had its own copy of the rules.
|
||||
func TestEveryPromptCarriesTheContextBlock(t *testing.T) {
|
||||
block := func() string { return "CTXBLOCK" }
|
||||
p := &LLMPhraser{cfg: Config{ContextBlock: block}}
|
||||
|
||||
prompts := map[string]string{
|
||||
"nudge": p.systemPrompt(),
|
||||
"query": p.querySystemPrompt(),
|
||||
"chat": chatSystemPrompt(block),
|
||||
}
|
||||
for name, got := range prompts {
|
||||
if !strings.HasPrefix(got, "CTXBLOCK\n\n") {
|
||||
t.Errorf("%s prompt does not start with the context block:\n%s", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Without a block the prompts are unchanged — the stub and test paths pass nil.
|
||||
func TestPromptsWithoutBlockAreUnchanged(t *testing.T) {
|
||||
p := &LLMPhraser{}
|
||||
if p.systemPrompt() != nudgeSystem {
|
||||
t.Errorf("nudge prompt changed with no block set")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAddressReportsEveryBreak — the real reply from a nudge eval run broke in
|
||||
// two ways at once and the check named only the plural. Both must print: a
|
||||
// half-reported failure reads as a milder problem than it is.
|
||||
func TestAddressReportsEveryBreak(t *testing.T) {
|
||||
body := "Смотрите на его потребление воды."
|
||||
res := checkAddress(body)
|
||||
if res.Pass {
|
||||
t.Fatalf("checkAddress passed %q", body)
|
||||
}
|
||||
for _, want := range []string{"смотрите", "его"} {
|
||||
if !strings.Contains(res.Detail, want) {
|
||||
t.Errorf("detail %q does not name %q", res.Detail, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One word repeated is one problem, so the detail must not say it twice.
|
||||
func TestAddressDeduplicates(t *testing.T) {
|
||||
res := checkAddress("Вам стоит поесть, вам это нужно.")
|
||||
if res.Pass {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
if n := strings.Count(res.Detail, "formal"); n != 1 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,14 +434,26 @@ func looksVerb(w string) bool {
|
||||
func checkAddress(body string) Result {
|
||||
words := addressWordRE.FindAllString(strings.ToLower(body), -1)
|
||||
|
||||
// Every break, not just the first. A bad reply usually breaks in more than
|
||||
// one way at once — "Смотрите на его потребление воды" is a plural imperative
|
||||
// AND third person about him — and reporting only the first hid the second,
|
||||
// which made the failure look milder than it was.
|
||||
var breaks []string
|
||||
seen := map[string]bool{}
|
||||
add := func(msg string) {
|
||||
if seen[msg] {
|
||||
return // the same word twice in one message is one problem, not two
|
||||
}
|
||||
seen[msg] = true
|
||||
breaks = append(breaks, msg)
|
||||
}
|
||||
|
||||
for i, w := range words {
|
||||
if formalPronouns[w] {
|
||||
return Result{CheckAddress, false,
|
||||
fmt.Sprintf("formal %q — she says ты/тебя/тебе", w)}
|
||||
add(fmt.Sprintf("formal %q — she says ты/тебя/тебе", w))
|
||||
}
|
||||
if pluralVerb(w) && !(i > 0 && prepositions[words[i-1]]) {
|
||||
return Result{CheckAddress, false,
|
||||
fmt.Sprintf("plural imperative %q — she uses the singular", w)}
|
||||
add(fmt.Sprintf("plural imperative %q — she uses the singular", w))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,17 +467,26 @@ func checkAddress(body string) Result {
|
||||
if !unicode.Is(unicode.Cyrillic, []rune(p)[0]) && !isLatinWord(p) {
|
||||
continue // punctuation
|
||||
}
|
||||
if notAnAntecedent[p] || prepositions[p] || thirdPersonHim[p] || looksVerb(p) {
|
||||
// pluralVerb as well as looksVerb: looksVerb knows the imperative in
|
||||
// -й/-йте but not the -те plural ("смотрите"), so "Смотрите на его
|
||||
// потребление воды" counted "смотрите" as the person being talked
|
||||
// about and the "его" never printed. Third time a verb form has
|
||||
// blinded this check — if a fourth turns up, the antecedent test
|
||||
// wants a real morphology table, not another suffix.
|
||||
if notAnAntecedent[p] || prepositions[p] || thirdPersonHim[p] || looksVerb(p) || pluralVerb(p) {
|
||||
continue
|
||||
}
|
||||
named = true
|
||||
break
|
||||
}
|
||||
if !named {
|
||||
return Result{CheckAddress, false,
|
||||
fmt.Sprintf("third person %q with nobody else named — she talks to him, not about him", w)}
|
||||
add(fmt.Sprintf("third person %q with nobody else named — she talks to him, not about him", w))
|
||||
}
|
||||
}
|
||||
|
||||
if len(breaks) > 0 {
|
||||
return Result{CheckAddress, false, strings.Join(breaks, " + ")}
|
||||
}
|
||||
return Result{CheckAddress, true, ""}
|
||||
}
|
||||
|
||||
@@ -574,12 +595,60 @@ func checkCringe(body string) Result {
|
||||
// checkOnTopic — the message must name the thing the rule is about. A nudge
|
||||
// that never mentions water leaves the operator with a chime and no action.
|
||||
func checkOnTopic(c Case, body string) Result {
|
||||
return checkOnTopicAny(c.WantAny, body)
|
||||
}
|
||||
|
||||
// checkOnTopicAny is the same test over a bare want-list, so the talk scorer can
|
||||
// reuse it without owning a nudge Case.
|
||||
func checkOnTopicAny(wantAny []string, body string) Result {
|
||||
low := strings.ToLower(body)
|
||||
for _, want := range c.WantAny {
|
||||
for _, want := range wantAny {
|
||||
if strings.Contains(low, strings.ToLower(want)) {
|
||||
return Result{CheckOnTopic, true, ""}
|
||||
}
|
||||
}
|
||||
return Result{CheckOnTopic, false,
|
||||
fmt.Sprintf("mentions none of %v", c.WantAny)}
|
||||
fmt.Sprintf("mentions none of %v", wantAny)}
|
||||
}
|
||||
|
||||
// --- shape checks for the free-form paths --------------------------------
|
||||
//
|
||||
// The nudge checks assume one short sentence. Chat and query replies are longer
|
||||
// by design, so the only shape worth testing there is that the model produced a
|
||||
// reply at all and did not trail off. Both are failure modes the fallbacks in
|
||||
// llmphraser.go hide: a truncated or empty generation still returns nil error.
|
||||
|
||||
const (
|
||||
CheckNonEmpty = "nonempty" // she said something
|
||||
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"}
|
||||
}
|
||||
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
|
||||
// tokens, not a stylistic pause. Mid-sentence ellipses are left alone.
|
||||
func checkEllipsis(body string) Result {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(body), `"'»)`)
|
||||
if strings.HasSuffix(trimmed, "…") || strings.HasSuffix(trimmed, "...") {
|
||||
return Result{CheckEllipsis, false, "reply trails off in an ellipsis — likely truncated"}
|
||||
}
|
||||
return Result{CheckEllipsis, true, ""}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
@@ -43,6 +44,9 @@ func TestLLMPhrasingBaseline(t *testing.T) {
|
||||
// Generous: an unconstrained 0.8B can spend a minute thinking before it
|
||||
// writes a word, and a timeout would be scored as a model failure.
|
||||
cfg.Timeout = 5 * time.Minute
|
||||
// The same shared context block the daemon prepends (internal/persona),
|
||||
// with an empty config — that is the deployment we actually ship.
|
||||
cfg.ContextBlock = func() string { return persona.Facts{}.Block(time.Now()) }
|
||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||
defer p.Close()
|
||||
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package eval
|
||||
|
||||
// This file scores the CONVERSATIONAL paths, the ones the nudge fixture never
|
||||
// touches: chat, query-with-notes, and general knowledge. All three now carry
|
||||
// the shared persona block (internal/persona), and all three produce long
|
||||
// free-form Russian — which is exactly where a persona break (formality, third
|
||||
// person, masculine self-reference) is most likely and where, until this file,
|
||||
// nothing could see one.
|
||||
//
|
||||
// Why a second fixture instead of more nudge cases: the checks differ. A nudge
|
||||
// must be one short sentence with no question in it; a chat reply is allowed
|
||||
// 1-3 sentences and a follow-up question is a FEATURE there. Mixing them would
|
||||
// need per-case check masks, and the nudge scorer stays untouched this way.
|
||||
//
|
||||
// Why per-path reporting: a chat regression and a knowledge regression have
|
||||
// different causes (chat prompt vs router.KnowledgePrompt), and one blended
|
||||
// percentage cannot tell them apart.
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
)
|
||||
|
||||
//go:embed talk_v1.json
|
||||
var talkFixtureJSON []byte
|
||||
|
||||
// The three phrasing paths under test. Values match the fixture's "path" field.
|
||||
const (
|
||||
PathChat = "chat" // PhraseChat
|
||||
PathQuery = "query" // PhraseQuery with notes
|
||||
PathKnowledge = "knowledge" // PhraseQuery with no notes
|
||||
)
|
||||
|
||||
// TalkPaths — report order.
|
||||
var TalkPaths = []string{PathChat, PathQuery, PathKnowledge}
|
||||
|
||||
// TalkCheckNames — the checks that apply to a free-form reply, in report order.
|
||||
// Deliberately a subset of CheckNames: length, mood and "no questions" are nudge
|
||||
// properties and would fail a correct chat reply. These paths return no mood at
|
||||
// all, so there is nothing to check there.
|
||||
var TalkCheckNames = []string{
|
||||
CheckNonEmpty, CheckEllipsis, CheckLang, CheckFeminine, CheckAddress, CheckOnTopic,
|
||||
}
|
||||
|
||||
// TalkCase — one turn as the daemon would present it.
|
||||
//
|
||||
// History is flat text because that is all PhraseChat uses (it concatenates
|
||||
// turn texts into one user message); intents and slots would be dead fields.
|
||||
// Notes are what the store would have matched for a query.
|
||||
//
|
||||
// WantAny is the on-topic contract: at least one lowercased fragment must appear
|
||||
// in the reply. Fragments are stems ("пароль" → "парол") so declension does not
|
||||
// defeat them.
|
||||
type TalkCase struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Utterance string `json:"utterance"`
|
||||
History []string `json:"history,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
WantAny []string `json:"want_any"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// TalkFixture — the versioned envelope, same gating as Fixture.
|
||||
type TalkFixture struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
Cases []TalkCase `json:"cases"`
|
||||
}
|
||||
|
||||
// LoadTalk returns the embedded conversational fixture.
|
||||
func LoadTalk() (TalkFixture, error) {
|
||||
var f TalkFixture
|
||||
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
||||
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
||||
}
|
||||
if f.SchemaVersion != SchemaVersion {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
|
||||
}
|
||||
if len(f.Cases) == 0 {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Talker — the two methods a conversational path must have to be scorable.
|
||||
// *phraser.LLMPhraser satisfies it; same trick as Nudger.
|
||||
type Talker interface {
|
||||
PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error)
|
||||
PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error)
|
||||
}
|
||||
|
||||
// TalkOutcome — one scored case.
|
||||
type TalkOutcome struct {
|
||||
Case TalkCase
|
||||
Reply string
|
||||
Err error
|
||||
Latency time.Duration
|
||||
Pass bool
|
||||
Failed []string
|
||||
Reasons []string
|
||||
}
|
||||
|
||||
// TalkReport — the aggregate. ByPath is the point of this scorer.
|
||||
type TalkReport struct {
|
||||
Name string
|
||||
Total int
|
||||
Passed int
|
||||
Errors int
|
||||
ByCheck map[string]int
|
||||
ByPath map[string]TagStat
|
||||
Outcomes []TalkOutcome
|
||||
P50 time.Duration
|
||||
P95 time.Duration
|
||||
Max time.Duration
|
||||
}
|
||||
|
||||
// Accuracy — fraction of cases that passed every check.
|
||||
func (r TalkReport) Accuracy() float64 {
|
||||
if r.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.Passed) / float64(r.Total)
|
||||
}
|
||||
|
||||
// ScoreTalk runs every case through t and aggregates. A phrasing error scores as
|
||||
// a miss and is counted separately: "the model was down" and "the model wrote
|
||||
// something bad" must not be the same number.
|
||||
func ScoreTalk(ctx context.Context, name string, t Talker, f TalkFixture) (TalkReport, error) {
|
||||
rep := TalkReport{
|
||||
Name: name,
|
||||
Total: len(f.Cases),
|
||||
ByCheck: map[string]int{},
|
||||
ByPath: map[string]TagStat{},
|
||||
}
|
||||
for _, n := range TalkCheckNames {
|
||||
rep.ByCheck[n] = 0
|
||||
}
|
||||
lat := make([]time.Duration, 0, len(f.Cases))
|
||||
|
||||
for _, c := range f.Cases {
|
||||
start := time.Now()
|
||||
reply, err := c.run(ctx, t)
|
||||
o := TalkOutcome{Case: c, Reply: reply, Err: err, Latency: time.Since(start)}
|
||||
lat = append(lat, o.Latency)
|
||||
|
||||
if err != nil {
|
||||
rep.Errors++
|
||||
o.Failed = append(o.Failed, "call")
|
||||
o.Reasons = append(o.Reasons, fmt.Sprintf("phrase error: %v", err))
|
||||
} else {
|
||||
for _, res := range RunTalkChecks(c, reply) {
|
||||
if res.Pass {
|
||||
rep.ByCheck[res.Name]++
|
||||
continue
|
||||
}
|
||||
o.Failed = append(o.Failed, res.Name)
|
||||
o.Reasons = append(o.Reasons, res.Name+": "+res.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
o.Pass = len(o.Failed) == 0
|
||||
if o.Pass {
|
||||
rep.Passed++
|
||||
}
|
||||
bump(rep.ByPath, c.Path, o.Pass)
|
||||
rep.Outcomes = append(rep.Outcomes, o)
|
||||
}
|
||||
|
||||
sort.Slice(lat, func(i, j int) bool { return lat[i] < lat[j] })
|
||||
rep.P50, rep.P95 = percentile(lat, 0.50), percentile(lat, 0.95)
|
||||
if len(lat) > 0 {
|
||||
rep.Max = lat[len(lat)-1]
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// run dispatches the case to its path. knowledge and query are the same method;
|
||||
// the empty notes slice is what selects the no-notes branch inside PhraseQuery.
|
||||
func (c TalkCase) run(ctx context.Context, t Talker) (string, error) {
|
||||
switch c.Path {
|
||||
case PathChat:
|
||||
return t.PhraseChat(ctx, c.Utterance, c.turns())
|
||||
case PathQuery:
|
||||
return t.PhraseQuery(ctx, c.Utterance, c.Notes)
|
||||
case PathKnowledge:
|
||||
return t.PhraseQuery(ctx, c.Utterance, nil)
|
||||
}
|
||||
return "", fmt.Errorf("unknown path %q", c.Path)
|
||||
}
|
||||
|
||||
func (c TalkCase) turns() []dialogue.Turn {
|
||||
turns := make([]dialogue.Turn, 0, len(c.History))
|
||||
for _, h := range c.History {
|
||||
turns = append(turns, dialogue.Turn{Text: h})
|
||||
}
|
||||
return turns
|
||||
}
|
||||
|
||||
// RunTalkChecks scores one reply. Order matches TalkCheckNames.
|
||||
func RunTalkChecks(c TalkCase, reply string) []Result {
|
||||
return []Result{
|
||||
checkNonEmpty(reply),
|
||||
checkEllipsis(reply),
|
||||
checkLang(reply),
|
||||
checkFeminine(reply),
|
||||
checkAddress(reply),
|
||||
checkOnTopicAny(c.WantAny, reply),
|
||||
}
|
||||
}
|
||||
|
||||
// String renders the comparison table — composite, then per-check so a
|
||||
// regression names the property, then per-path so it names the prompt.
|
||||
func (r TalkReport) String() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s: %d/%d cases pass every check (%.1f%%), %d errors\n",
|
||||
r.Name, r.Passed, r.Total, 100*r.Accuracy(), r.Errors)
|
||||
for _, name := range TalkCheckNames {
|
||||
fmt.Fprintf(&b, " %-10s %d/%d\n", name, r.ByCheck[name], r.Total)
|
||||
}
|
||||
fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max)
|
||||
fmt.Fprintf(&b, " by path: %s\n", renderStats(r.ByPath))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Failures — per-case detail, sorted by ID so two runs diff cleanly.
|
||||
func (r TalkReport) Failures() string {
|
||||
var b strings.Builder
|
||||
for _, o := range r.sorted() {
|
||||
if o.Pass {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " %s %q\n %s\n", o.Case.ID, o.Reply, strings.Join(o.Reasons, "; "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Replies — every generated reply verbatim. This is what a human reads to judge
|
||||
// tone; the score only says which checks fired.
|
||||
func (r TalkReport) Replies() string {
|
||||
var b strings.Builder
|
||||
for _, o := range r.sorted() {
|
||||
mark := "ok "
|
||||
if !o.Pass {
|
||||
mark = "FAIL"
|
||||
}
|
||||
fmt.Fprintf(&b, " %s %-9s %-22s %q\n", mark, o.Case.Path, o.Case.ID, o.Reply)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (r TalkReport) sorted() []TalkOutcome {
|
||||
out := append([]TalkOutcome(nil), r.Outcomes...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// perPathMinimum — the resolution floor. A per-path score built on a handful of
|
||||
// cases moves by 12% when a single reply changes, which cannot distinguish a
|
||||
// prompt regression from noise.
|
||||
const perPathMinimum = 8
|
||||
|
||||
// TestTalkFixture — the fixture itself has to be sound before any score off it
|
||||
// means anything.
|
||||
func TestTalkFixture(t *testing.T) {
|
||||
f, err := LoadTalk()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTalk: %v", err)
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
byPath := map[string]int{}
|
||||
for _, c := range f.Cases {
|
||||
if seen[c.ID] {
|
||||
t.Errorf("duplicate case id %q", c.ID)
|
||||
}
|
||||
seen[c.ID] = true
|
||||
|
||||
switch c.Path {
|
||||
case PathChat, PathQuery, PathKnowledge:
|
||||
default:
|
||||
t.Errorf("%s: unknown path %q", c.ID, c.Path)
|
||||
}
|
||||
byPath[c.Path]++
|
||||
|
||||
if strings.TrimSpace(c.Utterance) == "" {
|
||||
t.Errorf("%s: empty utterance", c.ID)
|
||||
}
|
||||
if len(c.WantAny) == 0 {
|
||||
t.Errorf("%s: no want_any — the reply cannot be checked for topic", c.ID)
|
||||
}
|
||||
// A query case with no notes would silently score the knowledge path.
|
||||
if c.Path == PathQuery && len(c.Notes) == 0 {
|
||||
t.Errorf("%s: query case has no notes", c.ID)
|
||||
}
|
||||
if c.Path == PathKnowledge && len(c.Notes) > 0 {
|
||||
t.Errorf("%s: knowledge case must have no notes", c.ID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range TalkPaths {
|
||||
if byPath[p] < perPathMinimum {
|
||||
t.Errorf("path %s has %d cases, want at least %d", p, byPath[p], perPathMinimum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fakeTalker — a scripted Talker, so the scorer is testable without a model.
|
||||
type fakeTalker struct{ reply string }
|
||||
|
||||
func (f fakeTalker) PhraseChat(context.Context, string, []dialogue.Turn) (string, error) {
|
||||
return f.reply, nil
|
||||
}
|
||||
func (f fakeTalker) PhraseQuery(context.Context, string, []string) (string, error) {
|
||||
return f.reply, nil
|
||||
}
|
||||
|
||||
// TestScoreTalkCounts — a reply that fails on purpose must be counted on every
|
||||
// path, so a real run cannot report a hidden zero.
|
||||
func TestScoreTalkCounts(t *testing.T) {
|
||||
f, err := LoadTalk()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTalk: %v", err)
|
||||
}
|
||||
// Formal address, off-topic, trailing ellipsis: three checks fail at once.
|
||||
rep, err := ScoreTalk(context.Background(), "fake", fakeTalker{"Приходите, я вас жду…"}, f)
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreTalk: %v", err)
|
||||
}
|
||||
if rep.Total != len(f.Cases) || rep.Passed != 0 {
|
||||
t.Errorf("got %d/%d passing, want 0/%d", rep.Passed, rep.Total, len(f.Cases))
|
||||
}
|
||||
if rep.ByCheck[CheckAddress] != 0 {
|
||||
t.Errorf("formal reply passed the address check %d times", rep.ByCheck[CheckAddress])
|
||||
}
|
||||
if rep.ByCheck[CheckEllipsis] != 0 {
|
||||
t.Errorf("truncated reply passed the ellipsis check %d times", rep.ByCheck[CheckEllipsis])
|
||||
}
|
||||
for _, p := range TalkPaths {
|
||||
if rep.ByPath[p].Total == 0 {
|
||||
t.Errorf("path %s missing from the report", p)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rep.String(), "by path") {
|
||||
t.Error("report does not break down by path")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLLMTalkBaseline — the resident model on the three conversational paths.
|
||||
// Opt-in exactly like TestLLMPhrasingBaseline: CI has no model and a run costs
|
||||
// minutes on the CPU target.
|
||||
//
|
||||
// MAVEN_LLM_URL=http://127.0.0.1:18099 \
|
||||
// go test -run TestLLMTalkBaseline ./internal/phraser/eval/
|
||||
//
|
||||
// Reports, does not assert a quality bar — the numbers are the input to tuning
|
||||
// the persona prompt. The one thing worth failing on is a harness fault.
|
||||
func TestLLMTalkBaseline(t *testing.T) {
|
||||
base := os.Getenv("MAVEN_LLM_URL")
|
||||
if base == "" {
|
||||
t.Skip("MAVEN_LLM_URL unset — point it at a running llama-server (see doc comment)")
|
||||
}
|
||||
noProxyLoopback(t)
|
||||
|
||||
ctx := context.Background()
|
||||
f, err := LoadTalk()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTalk: %v", err)
|
||||
}
|
||||
|
||||
cfg := phraser.DefaultConfig("")
|
||||
cfg.Timeout = 5 * time.Minute
|
||||
cfg.ContextBlock = func() string { return persona.Facts{}.Block(time.Now()) }
|
||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||
defer p.Close()
|
||||
|
||||
// Unreachable server is fatal here, not a logged warning, and that differs
|
||||
// from the nudge test on purpose. PhraseNudge returns its errors, so a dead
|
||||
// server there shows up honestly in the Errors column. PhraseChat and
|
||||
// PhraseQuery do NOT: they swallow every failure and return a canned string
|
||||
// ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths
|
||||
// a dead server produces a full report with 0 errors and a terrible score —
|
||||
// a number that looks like bad phrasing and is really no phrasing at all.
|
||||
// Refusing to score without a confirmed model is the only guard available
|
||||
// until the phraser reports its failures (Vikunja #397).
|
||||
model, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+
|
||||
"and would report a plausible-looking result off a dead server", base, err)
|
||||
}
|
||||
t.Logf("scoring model %s at %s", model, base)
|
||||
|
||||
rep, err := ScoreTalk(ctx, "llm ("+model+", built-in persona)", p, f)
|
||||
if err != nil {
|
||||
t.Fatalf("ScoreTalk: %v", err)
|
||||
}
|
||||
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
||||
|
||||
// And again afterwards: the run takes minutes, and a server that died or got
|
||||
// OOM-killed halfway through would leave the first cases scored and the rest
|
||||
// silently canned. Checking only at the start would not catch that.
|
||||
if _, err := llm.ModelID(ctx, base); err != nil {
|
||||
t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "ru-talk-v1",
|
||||
"notes": [
|
||||
"Scores the three conversational phrasing paths: chat (PhraseChat), query (PhraseQuery with notes) and knowledge (PhraseQuery with no notes). The nudge fixture does not cover any of them.",
|
||||
"Nine cases per path, not five. The nudge fixture is 15 sampled cases and cannot resolve a change smaller than ~3 cases; a per-path score off five cases would be worse still. More cases per path is the point of this fixture.",
|
||||
"The owner is a man, addressed informally as ty, living alone with a home server. Every utterance is written the way he actually talks to her.",
|
||||
"chat-formality-bait and chat-about-me exist to provoke the two persona breaks the nudge eval caught: the formal vy/vas plural, and talking about him in the third person.",
|
||||
"want_any fragments are stems so Russian declension does not defeat the on-topic check. They are lowercased before comparison.",
|
||||
"want_any is a plain substring test, so a fragment that is too short passes by accident: \"ты\" matches inside \"работы\", \"нет\" inside \"интернет\". Keep every fragment to three or more letters of a real stem.",
|
||||
"Notes are written as the store would have them: short, first person, no punctuation discipline."
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"id": "chat-how-are-you",
|
||||
"path": "chat",
|
||||
"utterance": "привет, как дела?",
|
||||
"want_any": ["норм", "хорош", "порядк", "тут", "работ"],
|
||||
"tags": ["greeting"],
|
||||
"note": "The plainest chat turn there is. If the persona breaks anywhere it breaks here first."
|
||||
},
|
||||
{
|
||||
"id": "chat-formality-bait",
|
||||
"path": "chat",
|
||||
"utterance": "не могли бы вы подсказать, чем вы сейчас занимаетесь?",
|
||||
"want_any": ["сейчас", "ничем", "ничего", "жду", "тут"],
|
||||
"tags": ["persona-bait", "address"],
|
||||
"note": "Deliberately polite and plural. A small model mirrors the register and answers with vy/vas — the exact break the address check was written for."
|
||||
},
|
||||
{
|
||||
"id": "chat-about-me",
|
||||
"path": "chat",
|
||||
"utterance": "расскажи обо мне",
|
||||
"want_any": ["теб"],
|
||||
"tags": ["persona-bait", "third-person"],
|
||||
"note": "Baits the third person: she should say 'ты живёшь один', not 'он живёт один', as if reporting to somebody else."
|
||||
},
|
||||
{
|
||||
"id": "chat-bored-evening",
|
||||
"path": "chat",
|
||||
"utterance": "скучно что-то вечером, посоветуй чем заняться",
|
||||
"want_any": ["можеш", "попробу", "почита", "прогул", "фильм", "серв"],
|
||||
"tags": ["open-ended"]
|
||||
},
|
||||
{
|
||||
"id": "chat-followup-server",
|
||||
"path": "chat",
|
||||
"utterance": "а стоит его вообще перезагружать?",
|
||||
"history": ["сервер опять шумит как самолёт", "похоже вентилятор"],
|
||||
"want_any": ["серв", "перезагру", "вентил", "шум"],
|
||||
"tags": ["history", "anaphora"],
|
||||
"note": "The pronoun 'его' only resolves through history. Also the one case where 'он' about the server is legitimate."
|
||||
},
|
||||
{
|
||||
"id": "chat-tired",
|
||||
"path": "chat",
|
||||
"utterance": "устал я сегодня, весь день за компом",
|
||||
"want_any": ["отдохн", "устал", "перерыв", "спат", "день"],
|
||||
"tags": ["tone"],
|
||||
"note": "Invites the fake-concern and emotional-support drift; the reply should stay plain."
|
||||
},
|
||||
{
|
||||
"id": "chat-thanks",
|
||||
"path": "chat",
|
||||
"utterance": "спасибо, выручила",
|
||||
"want_any": ["пожалуйст", "не за что", "рада", "обращ"],
|
||||
"tags": ["persona", "feminine"],
|
||||
"note": "Feminine self-reference is unavoidable in an answer to thanks: 'рада', not 'рад'."
|
||||
},
|
||||
{
|
||||
"id": "chat-what-can-you-do",
|
||||
"path": "chat",
|
||||
"utterance": "что ты вообще умеешь?",
|
||||
"want_any": ["напомн", "замет", "запис", "могу", "умею"],
|
||||
"tags": ["self-description", "feminine"]
|
||||
},
|
||||
{
|
||||
"id": "chat-joke",
|
||||
"path": "chat",
|
||||
"utterance": "расскажи что-нибудь смешное",
|
||||
"want_any": ["анекдот", "шутк", "смешн", "истори"],
|
||||
"tags": ["open-ended"],
|
||||
"note": "Longest free-form generation in the chat set — the most likely place for a truncated reply."
|
||||
},
|
||||
{
|
||||
"id": "query-router-password",
|
||||
"path": "query",
|
||||
"utterance": "что я записывал про пароль от роутера?",
|
||||
"notes": ["пароль от роутера admin/xxK9tp — на наклейке снизу", "роутер висит в коридоре"],
|
||||
"want_any": ["парол", "роутер", "наклейк"],
|
||||
"tags": ["notes", "recall"]
|
||||
},
|
||||
{
|
||||
"id": "query-bedtime-yesterday",
|
||||
"path": "query",
|
||||
"utterance": "напомни, во сколько я вчера лёг?",
|
||||
"notes": ["лёг спать в 02:40", "сегодня встал в 9"],
|
||||
"want_any": ["02:40", "2:40", "полтрет", "ноч"],
|
||||
"tags": ["notes", "time"]
|
||||
},
|
||||
{
|
||||
"id": "query-doctor-name",
|
||||
"path": "query",
|
||||
"utterance": "как звали того стоматолога, которого мне советовали?",
|
||||
"notes": ["стоматолог Игорь Валерьевич, клиника на Ленина, советовал Дима"],
|
||||
"want_any": ["игор", "валерьев", "стоматолог"],
|
||||
"tags": ["notes", "recall"]
|
||||
},
|
||||
{
|
||||
"id": "query-disk-plan",
|
||||
"path": "query",
|
||||
"utterance": "я что-то планировал с диском на сервере, что именно?",
|
||||
"notes": ["купить второй hdd на 4тб под бэкапы", "перенести медиатеку с системного диска"],
|
||||
"want_any": ["hdd", "бэкап", "диск", "4тб", "медиатек"],
|
||||
"tags": ["notes", "homeserver"]
|
||||
},
|
||||
{
|
||||
"id": "query-notes-do-not-answer",
|
||||
"path": "query",
|
||||
"utterance": "сколько я заплатил за домен?",
|
||||
"notes": ["домен продлевается в марте", "хостинг оплачен на год вперёд"],
|
||||
"want_any": ["домен", "не зна", "не указ"],
|
||||
"tags": ["notes", "negative"],
|
||||
"note": "The notes do not contain the price. The prompt tells her to say so; a made-up number is the failure being watched for."
|
||||
},
|
||||
{
|
||||
"id": "query-single-note",
|
||||
"path": "query",
|
||||
"utterance": "где лежит запасной ключ?",
|
||||
"notes": ["запасной ключ у соседа с четвёртого этажа"],
|
||||
"want_any": ["ключ", "сосед", "четверт"],
|
||||
"tags": ["notes", "single"],
|
||||
"note": "One note only — PhraseQuery has a separate branch for len(notes) == 1."
|
||||
},
|
||||
{
|
||||
"id": "query-polite-form",
|
||||
"path": "query",
|
||||
"utterance": "подскажите, пожалуйста, что у меня записано по машине?",
|
||||
"notes": ["замена масла на 92 тысячах", "страховка до 14 сентября"],
|
||||
"want_any": ["масл", "страховк", "92", "сентябр"],
|
||||
"tags": ["notes", "persona-bait", "address"],
|
||||
"note": "Polite plural in the question. The answer must still be ty."
|
||||
},
|
||||
{
|
||||
"id": "query-shopping",
|
||||
"path": "query",
|
||||
"utterance": "что мне надо было купить?",
|
||||
"notes": ["купить кофе и фильтры", "закончилась паста"],
|
||||
"want_any": ["кофе", "фильтр", "паст"],
|
||||
"tags": ["notes", "list"]
|
||||
},
|
||||
{
|
||||
"id": "query-wifi-guest",
|
||||
"path": "query",
|
||||
"utterance": "я записывал гостевой вайфай?",
|
||||
"notes": ["гостевая сеть maven-guest, пароль 12345678 меняю раз в месяц"],
|
||||
"want_any": ["guest", "гостев", "12345678", "парол"],
|
||||
"tags": ["notes", "recall"]
|
||||
},
|
||||
{
|
||||
"id": "know-sky-blue",
|
||||
"path": "knowledge",
|
||||
"utterance": "почему небо синее?",
|
||||
"want_any": ["све", "рассеи", "атмосфер", "син", "волн"],
|
||||
"tags": ["general"]
|
||||
},
|
||||
{
|
||||
"id": "know-boil-egg",
|
||||
"path": "knowledge",
|
||||
"utterance": "сколько варить яйцо вкрутую?",
|
||||
"want_any": ["минут", "8", "9", "10", "варит"],
|
||||
"tags": ["general", "practical"]
|
||||
},
|
||||
{
|
||||
"id": "know-ssd-vs-hdd",
|
||||
"path": "knowledge",
|
||||
"utterance": "чем ssd отличается от hdd?",
|
||||
"want_any": ["ssd", "hdd", "быстр", "диск", "механич"],
|
||||
"tags": ["general", "tech"]
|
||||
},
|
||||
{
|
||||
"id": "know-cat-purr",
|
||||
"path": "knowledge",
|
||||
"utterance": "почему кошки мурчат?",
|
||||
"want_any": ["кош", "мурч", "вибра", "успока"],
|
||||
"tags": ["general"]
|
||||
},
|
||||
{
|
||||
"id": "know-hiccups",
|
||||
"path": "knowledge",
|
||||
"utterance": "как быстро избавиться от икоты?",
|
||||
"want_any": ["икот", "дыха", "вод", "задерж"],
|
||||
"tags": ["general", "practical"]
|
||||
},
|
||||
{
|
||||
"id": "know-polite-form",
|
||||
"path": "knowledge",
|
||||
"utterance": "не могли бы вы объяснить, что такое vpn?",
|
||||
"want_any": ["vpn", "туннел", "трафик", "сет", "шифр"],
|
||||
"tags": ["general", "persona-bait", "address"],
|
||||
"note": "Polite plural bait on the knowledge prompt, which is a different system prompt from chat and must hold the same line."
|
||||
},
|
||||
{
|
||||
"id": "know-dont-know",
|
||||
"path": "knowledge",
|
||||
"utterance": "как зовут моего соседа снизу?",
|
||||
"want_any": ["не зна", "не мог"],
|
||||
"tags": ["general", "negative"],
|
||||
"note": "Unanswerable without notes. Admitting it beats inventing a name; watching for the invention."
|
||||
},
|
||||
{
|
||||
"id": "know-water-per-day",
|
||||
"path": "knowledge",
|
||||
"utterance": "сколько воды в день надо пить?",
|
||||
"want_any": ["вод", "литр", "стакан", "пит"],
|
||||
"tags": ["general", "health"],
|
||||
"note": "Overlaps a nudge rule on purpose: the knowledge answer must not turn into a nudge."
|
||||
},
|
||||
{
|
||||
"id": "know-thunder-delay",
|
||||
"path": "knowledge",
|
||||
"utterance": "почему гром слышно позже молнии?",
|
||||
"want_any": ["звук", "све", "быстр", "гром", "молни"],
|
||||
"tags": ["general"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
)
|
||||
|
||||
// grammarSpy stands in for llama-server: it records the grammar field of every
|
||||
// request and always answers with a contract-shaped reply.
|
||||
type grammarSpy struct {
|
||||
srv *httptest.Server
|
||||
grammars []string
|
||||
}
|
||||
|
||||
func newGrammarSpy(t *testing.T) *grammarSpy {
|
||||
t.Helper()
|
||||
s := &grammarSpy{}
|
||||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("spy: decode request: %v", err)
|
||||
}
|
||||
s.grammars = append(s.grammars, req.Grammar)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"{\"response\": \"ага\", \"mood\": \"neutral\"}"}}]}`))
|
||||
}))
|
||||
t.Cleanup(s.srv.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// 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()
|
||||
if _, err := p.PhraseNudge(ctx, loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}); err != nil {
|
||||
t.Fatalf("PhraseNudge: %v", err)
|
||||
}
|
||||
if _, err := p.PhraseChat(ctx, "привет", nil); err != nil {
|
||||
t.Fatalf("PhraseChat: %v", err)
|
||||
}
|
||||
// Both branches: no notes (general knowledge) and with notes (grounded).
|
||||
if _, err := p.PhraseQuery(ctx, "сколько воды я выпил", nil); err != nil {
|
||||
t.Fatalf("PhraseQuery (no notes): %v", err)
|
||||
}
|
||||
if _, err := p.PhraseQuery(ctx, "сколько воды я выпил", []string{"два литра"}); err != nil {
|
||||
t.Fatalf("PhraseQuery (notes): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
||||
if strings.TrimSpace(responseGrammar) == "" {
|
||||
t.Fatal("responseGrammar is empty")
|
||||
}
|
||||
spy := newGrammarSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
||||
|
||||
callAllPhrasingPaths(t, p)
|
||||
|
||||
if len(spy.grammars) != 4 {
|
||||
t.Fatalf("expected 4 requests, got %d", len(spy.grammars))
|
||||
}
|
||||
for i, g := range spy.grammars {
|
||||
if g != responseGrammar {
|
||||
t.Errorf("request %d carries grammar %q, want responseGrammar", i, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
||||
spy := newGrammarSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true})
|
||||
|
||||
callAllPhrasingPaths(t, p)
|
||||
|
||||
for i, g := range spy.grammars {
|
||||
if g != "" {
|
||||
t.Errorf("request %d still carries a grammar with NoGrammar set: %q", i, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The grammar's string rule must accept any codepoint, not just ASCII. Replies
|
||||
// are Russian: an ASCII-only class would constrain the model into empty replies.
|
||||
func TestGrammarStringRuleIsNotASCIIOnly(t *testing.T) {
|
||||
if !strings.Contains(responseGrammar, `([^"\\] | "\\" ["\\/bfnrt])`) {
|
||||
t.Error("string rule is not the any-codepoint-except-quote-and-backslash class; Cyrillic replies would be impossible")
|
||||
}
|
||||
}
|
||||
|
||||
// What the grammar describes must survive the parser that reads it back — a
|
||||
// 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, 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)
|
||||
}
|
||||
if mood != "confused" {
|
||||
t.Errorf("mood = %q, want confused", mood)
|
||||
}
|
||||
}
|
||||
|
||||
// Every mood the grammar permits is one the contract knows, and all five are there.
|
||||
func TestGrammarMoodEnumMatchesTheContract(t *testing.T) {
|
||||
for _, m := range []string{"neutral", "happy", "thinking", "tired", "confused"} {
|
||||
if !strings.Contains(responseGrammar, `"\"`+m+`\""`) {
|
||||
t.Errorf("mood %q missing from the grammar", m)
|
||||
}
|
||||
}
|
||||
// No sixth mood: the enum line lists exactly five alternatives.
|
||||
for _, line := range strings.Split(responseGrammar, "\n") {
|
||||
if strings.HasPrefix(line, "mood") {
|
||||
if n := strings.Count(line, "|") + 1; n != 5 {
|
||||
t.Errorf("mood rule lists %d alternatives, want 5: %s", n, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+175
-43
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -30,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 {
|
||||
@@ -39,7 +44,31 @@ type Config struct {
|
||||
NGpuLayers int
|
||||
NCtx int
|
||||
Timeout time.Duration
|
||||
Persona string // optional prompt prefix tuning maven's character
|
||||
|
||||
// ContextBlock renders the shared context block (who he is, how to
|
||||
// address him, the time) fresh for each turn. See internal/persona.
|
||||
// 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
|
||||
// ever fights the grammar, the fix should be a config flip on the
|
||||
// deploy box, not a code change and a rebuild.
|
||||
NoGrammar bool
|
||||
}
|
||||
|
||||
func DefaultConfig(modelPath string) Config {
|
||||
@@ -59,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()
|
||||
@@ -80,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,
|
||||
@@ -173,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)
|
||||
@@ -202,13 +254,18 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
if len(notes) == 0 {
|
||||
// General knowledge — no notes to ground the answer. The system
|
||||
// prompt is the single tested source in router.KnowledgePrompt.
|
||||
sys := 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
|
||||
@@ -218,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
|
||||
@@ -238,7 +300,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
// message array from dialogue history + the current user utterance. Falls back
|
||||
// to a simple greeting on any LLM error — better to say something than nothing.
|
||||
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
||||
sys := chatSystemPrompt(p.cfg.Persona)
|
||||
sys := chatSystemPrompt(p.cfg.ContextBlock)
|
||||
msgs := []chatMsg{
|
||||
{Role: "system", Content: sys},
|
||||
}
|
||||
@@ -251,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
|
||||
@@ -267,17 +334,16 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
}
|
||||
|
||||
// chatSystemPrompt returns the system prompt for conversational chat.
|
||||
// Prepends the configured persona when set.
|
||||
func chatSystemPrompt(persona 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).`
|
||||
if persona != "" {
|
||||
base = persona + "\n\n" + base
|
||||
}
|
||||
return base
|
||||
// Prepends the shared context block when the phraser has one.
|
||||
func chatSystemPrompt(block func() string) string {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// chatWithMessages sends a full message array (system + history + current) to
|
||||
@@ -288,6 +354,7 @@ func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTo
|
||||
Messages: msgs,
|
||||
Temperature: 0.7,
|
||||
MaxTokens: maxTokens,
|
||||
Grammar: p.grammar(),
|
||||
}
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -339,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)
|
||||
@@ -367,6 +439,43 @@ type chatReq struct {
|
||||
Messages []chatMsg `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens"`
|
||||
// Grammar is llama-server's `grammar` field (GBNF). Same wiring as
|
||||
// internal/llm.Req.Grammar. Empty ⇒ unconstrained sampling.
|
||||
Grammar string `json:"grammar,omitempty"`
|
||||
}
|
||||
|
||||
// responseGrammar — GBNF constraining the model to the documented phrasing
|
||||
// contract and nothing else: {"response": "<text>", "mood": "<enum>"}.
|
||||
//
|
||||
// Without it a 0.8B answers roughly one chat turn in three with open reasoning
|
||||
// as plain text ("Thinking Process:" …), which no tag-stripper can remove and
|
||||
// which eats the token budget before the JSON closes. Modelled on
|
||||
// routeGrammar in internal/router/llmrouter.go so the two read alike.
|
||||
//
|
||||
// text accepts ANY codepoint except the two JSON must escape — the replies are
|
||||
// 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,1000} "\""
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
// grammar returns the GBNF to attach to a phrasing request, or "" when the
|
||||
// operator turned it off.
|
||||
func (p *LLMPhraser) grammar() string {
|
||||
if p.cfg.NoGrammar {
|
||||
return ""
|
||||
}
|
||||
return responseGrammar
|
||||
}
|
||||
|
||||
type chatResp struct {
|
||||
@@ -391,6 +500,7 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
||||
},
|
||||
Temperature: 0.7,
|
||||
MaxTokens: maxTokens,
|
||||
Grammar: p.grammar(),
|
||||
}
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -441,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" — сам текст напоминания.
|
||||
@@ -454,26 +573,21 @@ const nudgeSystem = `Ты — Maven, домашняя ассистентка. О
|
||||
|
||||
Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет:
|
||||
{"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"}
|
||||
{"response": "Ноутбук на трёх процентах. Я поставила его на зарядку.", "mood": "confused"}
|
||||
{"response": "Ками, ноутбук на трёх процентах. Поставь его на зарядку.", "mood": "confused"}
|
||||
|
||||
Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.`
|
||||
|
||||
func (p *LLMPhraser) systemPrompt() string {
|
||||
base := nudgeSystem
|
||||
if p.cfg.Persona != "" {
|
||||
base = p.cfg.Persona + "\n\n" + base
|
||||
}
|
||||
return base
|
||||
return persona.Prepend(p.cfg.ContextBlock, nudgeSystem)
|
||||
}
|
||||
|
||||
// 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\"}."
|
||||
if p.cfg.Persona != "" {
|
||||
base = p.cfg.Persona + "\n\n" + base
|
||||
}
|
||||
return base
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ruleTopics — Russian gloss for each built-in rule name. The rule names are
|
||||
@@ -594,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": [
|
||||
"Напоминаю: есть дело.",
|
||||
"Пора вернуться к отложенному делу.",
|
||||
"Одно дело ждёт тебя.",
|
||||
"Напоминаю про дело из списка.",
|
||||
"В списке осталось дело.",
|
||||
"Дело всё ещё не сделано."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,8 @@ package router
|
||||
// KnowledgePrompt returns the system prompt for general knowledge questions
|
||||
// that the phraser uses when no notes match the query.
|
||||
func KnowledgePrompt() string {
|
||||
return `Ты — Мавена, персональный ассистент. Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.`
|
||||
// No self-introduction here: the shared persona block already says who she
|
||||
// is, and this line used to disagree with it — a different name ("Мавена")
|
||||
// and a masculine noun ("ассистент") in front of a feminine persona.
|
||||
return `Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.`
|
||||
}
|
||||
|
||||
@@ -11,10 +11,18 @@ func TestKnowledgePrompt(t *testing.T) {
|
||||
t.Fatal("KnowledgePrompt returned empty string")
|
||||
}
|
||||
// Must contain key instructions
|
||||
checks := []string{"Мавена", "не знаю", "не выдумывай"}
|
||||
checks := []string{"не знаю", "не выдумывай"}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(strings.ToLower(prompt), strings.ToLower(c)) {
|
||||
t.Errorf("KnowledgePrompt should mention %q", c)
|
||||
}
|
||||
}
|
||||
// Who she is comes from the shared persona block now. This prompt used to
|
||||
// say it too, with a different name and a masculine noun, which is the
|
||||
// drift the block exists to stop.
|
||||
for _, w := range []string{"Мавена", "ассистент"} {
|
||||
if strings.Contains(prompt, w) {
|
||||
t.Errorf("KnowledgePrompt should not introduce her (%q) — the persona block does", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user