diff --git a/.gitignore b/.gitignore index a305ba3..4d7a279 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,11 @@ # Certs (private keys, don't commit) certs/ -# Dependencies (fetch/build, not vendored) +# Dependencies (fetch/build, not vendored). +# Both forms on purpose: 'deps/' misses a symlink named deps, and agents working +# in a git worktree symlink these in from the main checkout. deps/ +deps # ML models (large, downloaded separately) — specific dirs, not blanket, # because models/seeds/*.txt are small, tracked files the classifier needs. @@ -19,6 +22,9 @@ deps/ /models/stt/ /models/tts/ /models/llm/ +# Symlink forms, same reason as deps above. +/models/embedder +/models/llm # Runtime data *.db @@ -36,3 +42,6 @@ opencode.json # Test coverage output coverage.out + +# Agent worktrees and local agent state +.claude/ diff --git a/AGENTS.md b/AGENTS.md index 24de599..ad6c750 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,14 +43,17 @@ notes. Without it, the floor `HashEmbedder` is used — deterministic but weak (Russian recall rarely clears the confidence gate, many commands fall to "clarify"). -**Download the embedder** (ONNX, ~90 MB): +**Download the embedder** (ONNX, ~120 MB): ```sh make download-embedder ``` -This fetches `paraphrase-multilingual-MiniLM-L12-v2` (384-dim, 12-layer, -supports 50+ languages including Russian) to `models/embedder/`. +This fetches `multilingual-e5-small` (384-dim, 12-layer, Russian and English) +to `models/embedder/multilingual-e5-small/`. It is an asymmetric retrieval +model: the code puts `query: ` in front of a question and `passage: ` in front +of a stored note, which is how e5 was trained. The quantized file is the one +that is downloaded, deployed and measured. **Also need ONNX Runtime** (`libonnxruntime.so`): @@ -64,8 +67,8 @@ sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/ ```json "voice": { "embedder": { - "model_path": "models/embedder/model_quantized.onnx", - "tokenizer_path": "models/embedder/tokenizer.json", + "model_path": "models/embedder/multilingual-e5-small/model_quantized.onnx", + "tokenizer_path": "models/embedder/multilingual-e5-small/tokenizer.json", "lib_path": "/usr/local/lib/libonnxruntime.so" } } diff --git a/CLAUDE.md b/CLAUDE.md index ba7e6c5..f297282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/DESIGN.md b/DESIGN.md index 4c68984..b35f759 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. diff --git a/MODEL-BAKEOFF-31-07-2026.md b/MODEL-BAKEOFF-31-07-2026.md new file mode 100644 index 0000000..0a14993 --- /dev/null +++ b/MODEL-BAKEOFF-31-07-2026.md @@ -0,0 +1,216 @@ +# Resident model bake-off — 31-07-2026 + +**Outcome: the resident model is stock Qwen3-1.7B** (`UD-Q4_K_XL`). Two sweeps ran this +evening and the second one changed the answer — read to the end before acting on any table +here. [Second sweep](#second-sweep-same-evening--five-models-and-a-resident-model-change) +is the one that holds. + +## First sweep — LFM2.5-1.2B vs Qwen3.5-0.8B + +**Verdict, scoped to this pair: keep Qwen3.5-0.8B over LFM2.5-1.2B.** LFM2.5-1.2B is worse +at routing (52.6% vs 60.5% intent accuracy), and the loss is almost entirely Russian +(18/61 vs 22/61 RU, while EN is a wash). It is also 2.4× slower. The Thinking variant is +far worse again. This verdict still stands as written — it rejects LFM2.5-1.2B. It is +**not** a recommendation to keep 0.8B as the resident model; the second sweep replaced it +with Qwen3-1.7B. + +Settles Vikunja **#278 / #250**. + +- Same fixture and scorer as `ROUTING-EVAL-31-07-2026.md`: `internal/router/eval/` + (`ru_routing_v1.json`, 76 held-out cases). +- Reproduce: `MAVEN_LLM_URL=http://127.0.0.1: make eval-router` + (`TestLLMRouterBaseline`). Note: there is no `make eval-models` target. +- All three models served by the same `llama-server` flags — `-c 2048 -ngl 99 -t 6`, only + `-m` and `--port` differ. One server at a time on an otherwise idle box, so latencies are + real and not contention. +- Measured on top of the router prompt fix (`origin/overnight/router-prompt` merged in), so + the Qwen column is directly comparable to the numbers already recorded. + +## Results + +`llm-only` — the model alone. This is the column that measures the model. + +| | Qwen3.5-0.8B | LFM2.5-1.2B Instruct | LFM2.5-1.2B Thinking | +|---|---|---|---| +| **intent-only accuracy** | **60.5%** | 52.6% | 36.8% | +| full accuracy (intent+slots+gate) | **36.8%** | 32.9% | 21.1% | +| **RU** | **22/61** | 18/61 | 10/61 | +| EN | 6/15 | **7/15** | 6/15 | +| route errors | 0 | 0 | 0 | +| **p50 / p95 latency** | **1.05s / 1.71s** | 2.47s / 3.62s | 2.42s / 3.24s | +| missed clarify | 6 / 6 | 6 / 6 | 6 / 6 | + +`cascade+llm` — stage-0 → model → classifier floor, what #320 would actually ship. Same +ordering. + +| | Qwen3.5-0.8B | LFM2.5-1.2B Instruct | LFM2.5-1.2B Thinking | +|---|---|---|---| +| intent-only accuracy | **61.8%** | 55.3% | 38.2% | +| full accuracy | **46.1%** | 42.1% | 30.3% | +| RU / EN | **27/61** / 8/15 | 23/61 / **9/15** | 15/61 / 8/15 | +| route errors | 0 | 0 | 0 | +| p50 / p95 latency | **1.28s / 1.94s** | 2.18s / 2.72s | 2.27s / 3.19s | + +Full logs: the three runs are archived in the session scratchpad +(`qwen08.txt`, `lfm-instruct.txt`, `lfm-thinking.txt`). + +## Russian-specific failures — the owner's worry is confirmed + +LFM2.5's Russian loss is not spread out. It has one large, specific failure: **it hears +almost any Russian imperative or short phrase as `reminder`.** + +- `перезапусти докер` → reminder (want act) +- `включи вытяжку` → reminder (want act) +- `закрой жалюзи` → reminder (want act) +- `заметка: продлить домен в августе` → reminder (want note) +- `запиши что кран на кухне снова капает` → reminder (want note) +- `доброе утро` → reminder (want chat) +- `спасибо тебе` → reminder (want note/chat) +- `переходи в тихий режим` → reminder (want system) + +That is `note→reminder ×4`, `act→reminder ×4`, `chat→reminder ×2` in one run. Qwen's +equivalent failure axis is `query→fact ×8`, which is a narrower and already-understood bug. + +Two more Russian-side problems worth naming: + +1. **Fact keys come back empty or wrong in Russian.** `воды попил наконец`, `поужинал`, + `поспал часов пять` and `отметь что я позавтракал овсянкой` all returned an empty key. + `сходил в душ` and `отдохнул минут двадцать` both returned `water`. Qwen does not do this. +2. **It leaked German.** `slept about seven hours` produced the fact key + `"7 Stunden geschlafen"`. Grammar-valid, semantically garbage — a sign the multilingual + mix is not anchored where Maven needs it. + +The claimed tool-calling advantage did not show up here. `act` is the closest thing this +fixture has to a tool call, and LFM2.5 got it wrong more often than Qwen, mostly by calling +it a reminder. It also produced no `fn` slot on any act, same as Qwen. + +## The Thinking variant + +Not viable. 36.8% intent accuracy, 10/61 Russian, and no latency saving over Instruct — the +thinking trace costs time without buying accuracy on a short enum classification. With the +`enable_thinking=false` diagnostic it collapsed further to 28.9% with 2 route errors +(`query→reminder ×12`). Do not pursue. + +## Notes + +- Nothing crashed, nothing ignored the GBNF grammar, and no model produced unparseable JSON + in the shippable configurations. Zero route errors for both Instruct and Thinking in + `llm-only` and `cascade+llm`. The problem with LFM2.5 is what it decides, not whether it + can emit the contract. +- The `6 / 6` missed clarify is unchanged across all three models. No model fixes the missing + refusal lane — that is `Confidence: 1.0` hardcoded in `llmrouter.go` (Vikunja #359), not a + model property. +- The report labels every configuration `(0.8B)`; that string is hardcoded in the test, not a + reflection of which gguf was loaded. Model identity was confirmed per run via `/v1/models`. +- No Go code was changed for this measurement, and no bug was found that needed one. + +## What this does not settle + +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`. diff --git a/Makefile b/Makefile index f837391..5e34b96 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data -.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test run-stt run-tts run-web download-embedder deps-go eval-router +.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models all: build @@ -69,7 +69,20 @@ deps-go: done $(GO) version -test: +# fmt-check fails if any file needs gofmt. DESIGN.md has always said `make +# test` gates on gofmt and vet; it did not, so nine files quietly drifted. +# Run `gofmt -w` on whatever this prints. +fmt-check: + @bad=$$(gofmt -l internal cmd); \ + if [ -n "$$bad" ]; then \ + echo "these files need gofmt:"; echo "$$bad"; exit 1; \ + fi + +vet: + CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ + $(GO) vet ./internal/... ./cmd/... + +test: fmt-check vet CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ $(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/... @@ -83,6 +96,40 @@ MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxrunt eval-router: MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/ +# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval). +# Answers "can she find the note again when it matters": recall@1, recall@3, +# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as +# eval-router; without it only the deterministic hash ratchet runs. +eval-recall: + MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/memory/recalleval/ + +# 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. 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 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, +# then: +# +# make eval-models MAVEN_LLM_URL=http://127.0.0.1:18100 +# +# The report names carry the model llama-server reports, so runs from two +# checkpoints stay apart. Only the LLM test runs — the classifier baselines do +# not depend on the model and take the ONNX runtime with them. +MAVEN_LLM_URL ?= http://127.0.0.1:18099 + +eval-models: + MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \ + -run TestLLMRouterBaseline ./internal/router/eval/ + run-stt: build-stt LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \ ./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL) @@ -108,9 +155,13 @@ deps-piper: -o /tmp/piper.tar.gz tar -xzf /tmp/piper.tar.gz -C deps/ -EMBEDDER_DIR := $(shell pwd)/models/embedder -EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2/resolve/main/onnx/model_quantized.onnx -EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2/resolve/main/tokenizer.json +# multilingual-e5-small: an asymmetric retrieval model. It is trained to match +# a short question against a longer passage, which is what note recall is. +# The quantized file is the one we download, deploy and measure — see +# RECALL-EVAL-31-07-2026.md. +EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small +EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx +EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json download-embedder: mkdir -p $(EMBEDDER_DIR) diff --git a/PHRASING-EVAL-31-07-2026.md b/PHRASING-EVAL-31-07-2026.md new file mode 100644 index 0000000..0573665 --- /dev/null +++ b/PHRASING-EVAL-31-07-2026.md @@ -0,0 +1,169 @@ +# Phrasing evaluation — 31-07-2026 + +How Maven words a nudge, measured instead of argued. Counterpart to +`ROUTING-EVAL-31-07-2026.md`. + +- Fixture + scorer: `internal/phraser/eval/` (`nudges_v1.json`, 15 cases; `eval.go`, `checks.go`) +- Reproduce: `MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing` +- Model: Qwen3.5-0.8B Q4_K_M, the resident model. Not swapped. +- Commit: `a40bc55` (prompt fix) + +Every check is a string or length test a human can read and disagree with. No model +grades another model here. + +## Result + +| | before | after | +|---|---|---| +| **cases passing every check** | **0/15** | **13/15** | +| mood in enum | 6/15 | 15/15 | +| Russian | 2/15 | 14/15 | +| length (≤120 chars, ≤16 words) | 13/15 | 15/15 | +| feminine self-reference | 15/15 | 15/15 | +| no cringe | 13/15 | 15/15 | +| on topic | 6/15 | 13/15 | +| p50 latency | 11.4s | 11.4s | + +Latency did not move and is not good. 11s to word one nudge on this box. + +## The bug reproduced + +Yes, exactly as reported. 7 of 15 messages were the literal string `"..."`, and one was +`"full voice message"`. Both are text copied straight out of the prompt. + +The system prompt said: + +``` +Respond ONLY with valid JSON: {"response": "full voice message", "mood": "neutral"} +``` + +and the user prompt said: + +``` +Respond as JSON: {"response": "...", "mood": "..."} +``` + +A 0.8B does not read `"..."` as "put your answer here". It reads it as the answer. The +prompt was a worked example whose worked part was blank, so the model filled the slot by +copying. This is the whole of finding 1. + +## What else was wrong + +Four separate faults, all prompt-side: + +1. **Placeholder echo** (7 cases) — above. +2. **Wrong language** (13/15 failed the language check). The prompt was entirely English + and said "in the user's language (Russian or English)". The model picked English. It is + never English: the nudge is spoken by a Russian piper voice. +3. **Rule names are English identifiers.** `netdata_critical`, `service_down`, `break` went + into the prompt raw. The model cannot nudge about a topic it has not been told in words, + so 9/15 were off topic. The daemon knows what its own rules mean; now it says so. +4. **Mood invented** (`"warm"`, twice). The enum was listed in a parenthesis at the end of + an English sentence. Now it is its own line: "ровно одно из: neutral, happy, thinking, + tired, confused." + +Plus two non-prompt faults the run exposed: + +- **The no-parse fallback was English.** When the model returned nothing usable, the body + became `fmt.Sprintf("%s — %s", rule, sev)` — `"water — care"` — and that string went to + a Russian TTS. Now it falls back to plain Russian. +- **Durations were English.** `humanDur` returns "3 hours"; it was landing verbatim inside + Russian sentences. Nudges now use a Russian formatter. + +## Three iterations, and what each taught + +| | score | change | +|---|---|---| +| baseline | 0/15 | — | +| iter 1 | 2/15 | Russian prompt, filled-in examples, Russian durations | +| iter 2 | 11/15 | required keyword per rule, one example instead of five, Russian fallback | +| iter 3 | **13/15** | examples moved to topics that are not rules | + +The interesting step is 1 → 2. Fixing the placeholder did not fix the disease, it moved it: +the model stopped copying `"..."` and started copying my first example instead. Five nudges +in a row came back as `"Ты не пил воду три часа. Налей стакан."` regardless of the rule. + +**A small model copies the nearest concrete text in its prompt.** That is one failure mode +with two symptoms. The fix that stuck was making the examples about laundry and a laptop +battery — topics no rule ever produces, so copying them is visible in the score rather than +invisibly passing the water cases. + +## Do not oversell 13/15 + +Seven of the thirteen passes are the **deterministic fallback**, not the model: +`"Напоминаю: таблетки."`, `"Сервис не отвечает."`, `"Критический алярм: проверь диск."`, +`"Ты давно не пил воду."`. Those are strings this commit added to Go. The model returned +nothing parseable and the fallback scored. + +So the honest reading is roughly **6/15 from the model, 7/15 from a fallback, 2/15 failing**. +The prompt fix is real — `"..."` is nearly gone and the language and mood checks are clean — +but a large part of the jump is that failure now degrades into Russian instead of into +`"water — care"`. That is a genuine improvement for the operator and a weak one for the model. + +The two remaining failures: one `"..."` recurrence (`routine-stretch`) and one meal nudge +that never says food. + +## Tried and reverted: an example-led nudge prompt (#393) + +The idea was that a 0.8B copies examples better than it follows rules, so the nudge prompt +was rewritten to lead with five on-topic examples (water, break, pills, morning, service) and +the prose rules were compressed to pay for the tokens: 1190 chars down to 986. + +It measured **worse**, three runs each side, same llama-server, same fixture: + +| run | before | after | +|---|---|---| +| 1 | 12/15 (address 14) | 11/15 (address 13) | +| 2 | 13/15 (address 15) | 12/15 (address 15) | +| 3 | 14/15 (address 15) | 11/15 (address 12) | + +`feminine` and `hisgender` were 15/15 on all six runs, so they measure nothing here. The +regression is all in `address`: 44/45 before, 40/45 after. Formal "вы"/"ваше" and plural +imperatives came back, and so did `"..."`. + +Two likely causes, both about the same thing — **examples do not carry a prohibition**. The +old prompt spent a whole sentence on «говоришь на "ты", в единственном числе»; the new one +demoted that to one item in a long "никогда" list, and the model stopped obeying it. And +making the examples on-topic let their *wording* leak: a break case came back as +«Вы давно не пили воду. Выпей стакан.» — the water example, verbatim, in the wrong slot. +That is exactly the failure the laundry/laptop examples were chosen to avoid. + +Change reverted. What survives is the measurement: a rule the model must obey needs its own +sentence, and examples must stay off-topic. Also note the before side alone spans 12–14 of +15 — this fixture cannot resolve anything smaller than about three cases. + +## Broken, found, not fixed + +1. ~~**`checkFeminine` only catches half the constraint.**~~ **Fixed** (#381). It scanned for + masculine self-reference only, so three messages that addressed the *owner* in the feminine + ("ты давно не отдыхал**а**") scored clean. There is now a second check, `hisgender`: a + feminine past-tense verb (-ла/-лась) in a sentence addressed to him ("ты", "тебе", "твой") + fails, unless the verb is hers ("я заметила", "напомнила тебе"). It is a suffix rule, not a + parser — see the comment in `checks.go` for what it misses. A fresh 15-case run after adding + it scored **12/15** with `hisgender` 15/15; the model did not repeat the feminine address in + that sample, and the check is pinned by unit tests on the recorded bad strings instead. +2. **Grammar is not checked at all, and it is bad.** `"Он не ел 11 дней"` (it was 11 hours), + `"Сонуждились 7 дней"` (not a word), `"Они забыли воду"` (wrong person entirely). Every + one of these passes all six checks. The fixture measures properties, not fluency, and at + 0.8B fluency is the binding constraint. +3. **Unit confusion.** The model turns hours into days about a third of the time. The + prompt now says "11 ч"; it reads it as days. +4. **11s p50.** Unchanged and untouched here. A nudge the model takes eleven seconds to + word has missed its moment. Worth its own task. +5. **The keyword hint is close to teaching to the test.** `ruleKeywords` names the word the + on-topic check looks for. It is defensible — the daemon genuinely knows its rule topics + and the model genuinely cannot infer them from `netdata_critical` — but the on-topic + number is softer than the others because of it. + +## Next steps + +1. ~~**Add a second-person gender check**~~ — done, `hisgender` in `checks.go` (#381). +2. **Decide whether the fallback should count as a pass.** Right now `Score` cannot tell a + model answer from a fallback. Either mark fallback bodies in `PhrasedNudge` or count them + in their own column. Without that, any future prompt change can score well by failing + more. +3. **Attack the 11s.** Nudge phrasing is short and non-interactive; thinking off is the first + thing to try, as it was for routing (#376). +4. **Re-measure when #122 lands.** The CPT'd Qwen3-1.7B is the target. 13/15 with seven + fallbacks is the floor it has to beat, and the fluency problems above are the ones a + bigger, Russian-trained checkpoint should actually fix. diff --git a/REARCH.md b/REARCH.md index ce158a1..36e110e 100644 --- a/REARCH.md +++ b/REARCH.md @@ -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. diff --git a/RECALL-EVAL-31-07-2026.md b/RECALL-EVAL-31-07-2026.md new file mode 100644 index 0000000..ead570c --- /dev/null +++ b/RECALL-EVAL-31-07-2026.md @@ -0,0 +1,250 @@ +# Note recall evaluation — 31-07-2026 + +The operator's goal is that Maven "memorize/note things … and know more about me/world". This +measures whether the note/recall path delivers that. + +- Fixture + scorer: `internal/memory/recalleval/` (`ru_recall_v1.json`, 30 cases) +- Reproduce: `make eval-recall` — hash ratchet always, ONNX when `deps/` is present +- Commit: `43470ab` (harness) + +Each case inserts its own 3 notes **plus 12 shared filler notes** into a fresh store, embeds the +query, takes the top 3 — the read path `cmd/mavend/voice.go` runs for `IntentQuery`. Filler is +load-bearing: with 3 notes and a top-3 search, recall@3 is 100% by construction. 25 answerable +cases (paraphrased queries, homelab and preference content, 9 with a plausible second note) and 5 +that must recall **nothing**. `TestFixtureIsParaphrased` fails the build if a query shares over half +its words with its note; equal-score ties count as ties, not recall. + +## Results + +| | recall+hash (CI ratchet) | recall+onnx (deployed) | +|---|---|---| +| **recall@1** | 36.0% (9/25) | **60.0% (15/25)** | +| recall@3 | 76.0% (19/25) | 80.0% (20/25) | +| **answered after the 0.55 gate** | **0.0% (0/25)** | **48.0% (12/25)** | +| wrong note on top / tie on top | 9 / 7 | 10 / 0 | +| ranked first, then silenced by the gate | 9 | 3 | +| **false recall** | 0/5 | **1/5 (20%)** | +| top-1 score when right, min / median | n/a | 0.559 / 0.678 | +| top-1 when it must stay silent, median / max | 0.000 / 0.144 | 0.470 / **0.567** | +| RU / EN / `hard` cases passed | 4/24 / 1/6 / 0/11 | 13/24 / 3/6 / 2/11 | +| latency p50 / p95 / max | 49µs / 70µs | 59ms / 148ms / 194ms | + +Never compare a hash-embedder number to an ONNX one — the hash floor is lexical and exists only so +CI has a deterministic ratchet with no model files. + +## Findings + +### 1. Real recall is 48%, not 60% + +The right note ranks first 60% of the time, but the daemon only *says* it 48% of the time — three +more cases rank first and are then silenced by `voice.go:776`'s `queryMinScore`. **Roughly one +useful question in two gets "не знаю".** This is not a working memory yet. + +### 2. The gate cannot separate a real recall from a false one — the distributions overlap + +Right-note top-1 scores start at **0.559**. Must-stay-silent top-1 scores reach **0.567**. No +threshold keeps every real recall and rejects every false one. From the sweep: gate 0.50 → 13/25 +answered, 1/5 false; **0.55 (default) → 12/25, 1/5**; **0.60 → 10/25, 0/5**; 0.70 → 5/25, 0/5. What +the data says about `DefaultQueryMinScore` (`internal/config/config.go:392`): **0.55 is +slightly too loose** — it admits one confident wrong answer ("как зовут сестру моего коллеги" +recalls "выучил пару аккордов на гитаре" at 0.567), which the spec ranks as worse than a gap. 0.60 +silences all five and costs 8 points of real recall. Left alone as instructed; the overlap means +the threshold is the wrong dial anyway (finding 3). + +### 3. Filler notes outrank the right answer — the model scores similarity, not relevance + +`models/embedder/` is **paraphrase-multilingual-MiniLM-L12-v2** (`Makefile:119`), a *symmetric* +paraphrase model. It scores "do these sentences look alike", not "does this passage answer this +question", so question-shaped queries drift to whatever note is stylistically closest. "из-за чего +кончилось место" and "откуда берётся токен бота" both return `выучил пару аккордов на гитаре` +(0.730, 0.729); "как я восстановил конфиги" returns a bootloader note at 0.703 with the right note +not even in the top 3. An unrelated guitar note beating a homelab note at 0.73 is not a tuning +problem — an asymmetric retrieval model (`multilingual-e5-small`, with `query:` / `passage:` +prefixes) is the targeted fix, and it would move findings 1 and 2 together. Separately: +`deploy/mavend.json:39` loads a 470MB fp32 `model.onnx` while `make download-embedder` fetches +`model_quantized.onnx` — not the same file. + +`hard` cases score **2/11**: every one is a query where the operator did not reuse his own words. +That is the normal case weeks later, and exactly what DESIGN.md's "recall when relevant" promises. + +### 4. The memory-store recall branch is dead for notes + +`voice.go:776` only reaches `h.memStore.Search` when the notes-RAG top score is already below +`queryMinScore`, and `bestRecall` (`cmd/mavend/recall.go:19`) then applies the **same** gate to the +same vector. A note is indexed in both places with the same embedding, so if it failed the gate in +`QueryNotes` it fails again here — the branch can only ever return a **fact**. Its comment calls it +"additive"; for notes it is not. + +**Fixed (Vikunja #373).** The memory pass now runs *first*, as one search over notes and facts with +one gate, so whichever memory is clearly the best match answers — note or fact. The notes-only pass +stays behind it for notes the vector index does not hold. No threshold changed, so the set of +questions Maven answers is the same; only which memory answers them. The fixture gained two mixed +note+fact cases (`ru-mixed-031`, `ru-mixed-032`), which is why the counts below are out of 27 +answerable cases and not 25: hash recall@1 36.0% (9/25) → 37.0% (10/27), e5 recall@1 72.0% (18/25) → +70.4% (19/27) with answered-after-gate 68.0% → 66.7% and false recall unchanged at 1/5. + +### 5. Ranking has no recency or type signal, and the store is not the bottleneck + +`internal/store/notes.go:67` sorts by cosine and uses `ts` only to break an exact float tie, which +never happens; `kind` never enters the ranking. Meanwhile `TestPersistentStoreScoresTheSame` scores +sqlite-backed `store.MemoryStore` and `memory.InMemoryStore` identically — both full-scan cosine +(`internal/store/memory.go:64`) at ~150µs over 42 rows against a ~59ms query embed. An ANN index is +not the problem to solve. + +## Re-measured after the embedder swap — 31-07-2026, later the same day + +Changed: `models/embedder/` is now **multilingual-e5-small** (quantized, 118MB), with `query: ` in +front of a question and `passage: ` in front of a stored note (Vikunja #371). `deploy/mavend.json` +and `make download-embedder` now name the same file, and it is the quantized one — that is what the +column below measures (Vikunja #372). Everything else is unchanged: same fixture, same store, same +0.55 gate. The old column is the baseline and is left as it was. + +| | recall+onnx, MiniLM (baseline) | recall+onnx, e5-small (new) | +|---|---|---| +| **recall@1** | 60.0% (15/25) | **72.0% (18/25)** | +| recall@3 | 80.0% (20/25) | 84.0% (21/25) | +| **answered after the 0.55 gate** | 48.0% (12/25) | **72.0% (18/25)** | +| wrong note on top / tie on top | 10 / 0 | 7 / 0 | +| ranked first, then silenced by the gate | 3 | 0 | +| **false recall** | 1/5 (20%) | **5/5 (100%)** | +| top-1 score when right, min / median | 0.559 / 0.678 | 0.791 / 0.857 | +| top-1 when it must stay silent, median / max | 0.470 / 0.567 | 0.815 / 0.835 | +| RU / EN / `hard` cases passed | 13/24 / 3/6 / 2/11 | 14/24 / 4/6 / 5/11 | +| latency p50 / p95 / max | 59ms / 148ms / 194ms | 18ms / 37ms / 49ms | + +### What moved + +Ranking got better and got faster. Half the previously-unwinnable `hard` cases now pass (2/11 → +5/11), the guitar note no longer beats the docker-logs note, and the gate stops silencing notes that +already ranked first. The quantized e5 is also ~3x quicker than the fp32 MiniLM it replaces. + +### What got worse: the gate is now a no-op + +e5 packs every cosine into a narrow high band. Right-note scores start at 0.791; must-stay-silent +scores reach 0.835. **The distributions still overlap, and now they overlap above the gate**, so +0.55 admits everything and false recall goes from 1/5 to 5/5. The sweep: + +``` +gate 0.50–0.70: answered 18/25 (72%) false recall 5/5 +gate 0.80: answered 17/25 (68%) false recall 4/5 +gate 0.90: answered 0/25 ( 0%) false recall 0/5 +``` + +There is no value that keeps real recall and rejects made-up questions — same conclusion as before, +now with a wider band and no room at all. `query_min_score` was left at 0.55 as instructed. **The +recommendation is to leave it there and stop tuning it**: any number under ~0.79 is a no-op and +anything above starts cutting real recall long before it stops the false ones. The fix is a margin +gate (`top1 − top2 > δ`), next-steps item 3, which is now the top item. + +### The prefixes did not do the work + +A control run with both prefixes set to the empty string scored the **same** recall@1 (72%), a +slightly better recall@3 (88%) and the same 5/5 false recall. So on this fixture the gain comes from +the model, not from the `query:` / `passage:` split. The prefixes are kept because they are how e5 +was trained and the split is the right shape for the read path, but they are not worth defending on +this evidence — a bigger fixture may say otherwise. + +### Stored vectors from the old model are now junk + +Cosine between a MiniLM vector and an e5 vector means nothing. Every row already in `notes` and in +the vector memory table was written by the old model, so after this deploy they will score as noise +against a new query. A live database needs every note and fact re-embedded before recall works at +all. Filed as its own task. + +## Margin gate — 31-07-2026, third run + +Next-steps item 3, done. The absolute gate is replaced by a **margin gate**: answer only when the +top hit beats the runner-up by more than delta (`top1 − top2 > δ`). Same fixture, same e5 embedder, +same store as the run above. `internal/memory/gate.go` holds the check; both read paths call it +(`cmd/mavend/recall.go` and the notes-RAG branch in `voice.go`). New knob `voice.query_min_margin` +in `deploy/mavend.json`, default 0.008. + +### Why the absolute gate could not work, in one line of data + +The harness now prints the margin distributions, and they barely overlap where the raw scores +overlap completely: + +| | top-1 score | margin (top1 − top2) | +|---|---|---| +| right note first (n=18) | min 0.810, median 0.862, max 0.890 | min 0.001, median 0.029, max 0.053 | +| must stay silent (n=5) | min 0.795, median 0.815, max 0.835 | min 0.000, median 0.002, **max 0.019** | + +Four of the five must-be-silent cases have a margin at or under 0.002 — when there is nothing to +recall, e5 finds several notes equally close and no clear winner. That is the signal the absolute +score throws away. + +### The delta sweep + +Absolute gate held at 0.55 throughout. + +``` +delta 0.000: answered 18/25 (72%) false recall 5/5 +delta 0.002: answered 17/25 (68%) false recall 3/5 +delta 0.005: answered 17/25 (68%) false recall 2/5 +delta 0.008: answered 17/25 (68%) false recall 1/5 <- chosen +delta 0.010: answered 15/25 (60%) false recall 1/5 +delta 0.012: answered 14/25 (56%) false recall 1/5 +delta 0.015: answered 12/25 (48%) false recall 1/5 +delta 0.020: answered 11/25 (44%) false recall 0/5 +delta 0.025: answered 9/25 (36%) false recall 0/5 +delta 0.030: answered 8/25 (32%) false recall 0/5 +delta 0.040: answered 4/25 (16%) false recall 0/5 +delta 0.050: answered 2/25 ( 8%) false recall 0/5 +delta 0.060: answered 0/25 ( 0%) false recall 0/5 +``` + +### Chosen: δ = 0.008 + +It is the best point on the frontier, not a taste call. **0.008 dominates 0.010, 0.012 and 0.015 +outright** — same 1/5 false recall, 8 to 20 points more real recall. Everything below it buys recall +back only by admitting more false recalls (0.005 → 2/5, 0.002 → 3/5). The next real improvement is +0.020 at 0/5 false, and it costs 24 points of recall to get there. + +The brief's bar was "recall above 60% with false recall at 1/5 or better". 0.008 clears it with room: +68% and 1/5. + +### Before / after + +| | absolute gate 0.55 (previous) | margin gate δ=0.008 | +|---|---|---| +| recall@1 (ranking, ungated) | 72.0% (18/25) | 72.0% (18/25) — unchanged, the gate does not rank | +| **answered after the gate** | 72.0% (18/25) | **68.0% (17/25)** | +| **false recall** | **5/5 (100%)** | **1/5 (20%)** | +| fixture cases passed | 18/30 | **21/30** | + +Four false recalls removed for one real answer. That is the trade the spec asks for — she is not a +guesser-of-truth. The one survivor is `en-pref-025` ("should i be offered wine"), which recalls a +filler note at 0.796 with a 0.019 margin: the widest silent-case margin in the fixture, and it sits +inside the real-recall range, so no delta removes it without taking real answers with it. + +### Does the absolute cutoff still earn its keep? Marginally — kept + +On this fixture with e5 it is a **no-op**: the lowest right-note score is 0.791, so 0.55 rejects +nothing the margin does not already reject. It is kept for two reasons, neither glamorous. It still +does real work for the hash embedder (its own sweep shows answers dropping from 16% to 0% between +0.30 and 0.50), and it is the only thing standing between the user and a reply built from a store +where everything is far away but one row happens to be a little less far — a near-empty database, or +the stale-vector case below. Cheap insurance, no measured cost. If a later embedder makes it bite, +the sweep is one command. + +### Caveat on the numbers + +Five must-be-silent cases is a thin basis for a 4-point decision. 1/5 and 2/5 differ by one case. +The shape of the frontier is trustworthy — margins separate, absolute scores do not — but δ=0.008 +itself should be re-read off a bigger fixture (next-steps item 6) before anyone defends the third +decimal. + +## Next steps — ordered by value-to-risk; nothing here is a decision + +1. **Swap the embedder to `multilingual-e5-small` with `query:`/`passage:` prefixes.** One config + change plus a prefix in `onnxembedder.go`, re-measurable in one command. +2. **Re-run `make eval-recall`, then set the gate from the sweep** — not before. Any + `query_min_score` picked against today's embedder describes a model on its way out. +3. ~~**Replace the absolute-score gate with a margin gate**~~ — done, see the section above. + δ=0.008, false recall 5/5 → 1/5. +4. **Delete or repair the dead `memStore` branch** at `voice.go:776` — search before the gate, + gate it separately, or restrict it to facts and say so. +5. **Add a mild time decay to ranking** — the newest statement of a preference is the true one. +6. **Grow the fixture from real misses.** 30 cases can rank two embedders, not trust 4 points. +7. **Re-measure end to end.** Recall is gated twice — the utterance must first route to `query`, + which the routing eval puts at ~50%. The product is ~24%, and that is what he experiences. diff --git a/ROUTING-EVAL-31-07-2026.md b/ROUTING-EVAL-31-07-2026.md index 7c95565..1ddef82 100644 --- a/ROUTING-EVAL-31-07-2026.md +++ b/ROUTING-EVAL-31-07-2026.md @@ -40,6 +40,139 @@ model → classifier as failure floor. Never compare a hash-embedder run to an ONNX one. +## Re-measured after the prompt fix + +The table above is the **baseline at commit `46259b4`**, kept as-is. The prompt fix (query +tested before fact, plus `repeat_penalty` and a bounded grammar string) was then measured on +an otherwise idle box — no other eval sharing llama-server, so these latencies are real +rather than contention. + +| | llm-only (0.8B) | cascade+llm (0.8B) | llm-only, thinking off | +|---|---|---|---| +| **intent-only accuracy** | 48.7% → **61.8%** | 50.0% → **63.2%** | **67.1%** | +| full accuracy (intent+slots+gate) | 23.7% → **38.2%** | 32.9% → **47.4%** | **42.1%** | +| route errors | 2 → **0** | 0 → 0 | **0** | +| p50 / p95 latency | **1.08s / 1.55s** | **1.04s / 1.53s** | **0.93s / 1.41s** | + +Three things this run settles: + +1. **The prompt fix holds.** An earlier contended run reported 60.5% / 36.8% for llm-only; + the quiet run gives 61.8% / 38.2%. Close enough to call the gain real, and the earlier + run's 4-5s latency figures were contention, not the model. +2. **`query→fact` fell from ×15 to ×7**, and both unparseable replies are gone. Zero route + errors in every LLM configuration. +3. **`note→fact ×4` is real, not noise.** It shows up in the quiet run too. The agent that + wrote the prompt fix suspected its own change might have caused it by pulling assertive + `запиши что…` phrasings toward fact, and that suspicion stands — all five `ru-note-*` + cases now land on fact. Tracked as Vikunja #375. + +The `thinking off` column above read as the best configuration measured so far (Vikunja #376). +**It was wrong** — see the controlled re-run below. Ignore that column. + +Still `6 / 6` missed clarify — the router has no way to say "I don't know" (Vikunja #359). +That is unchanged by anything here. + +## Thinking off — 31-07-2026, controlled re-run (Vikunja #376) + +The "thinking off wins by 6 points" observation above **does not hold**. It was a measurement +artefact, and the earlier table's `thinking off` column should be ignored. + +The thinking-off variant was scored by a hand-rolled HTTP client living in the test file +instead of `llm.Client`. That copy did not send `repeat_penalty`, which the real router does +send (`routeRepeatPenalty = 1.15`). So the two columns differed on two axes at once, and the +one that mattered was the penalty, not the thinking mode. + +Re-measured with everything else held equal — same fixture, same prompt, same grammar, same +sampling, same idle box, the three configurations run back to back and never concurrently: + +| | llm-only, thinking on | llm-only, thinking off | cascade+llm | +|---|---|---|---| +| intent-only accuracy | 59.2% (45/76) | 59.2% (45/76) | 61.8% (47/76) | +| full accuracy (intent+slots+gate) | 38.2% (29/76) | 38.2% (29/76) | 57.9% (44/76) | +| route errors | 3 | 3 | 0 | +| grammar violations | 3 (all 3 route errors) | 3 (same 3 cases) | 0 | +| missed clarify | 5 / 6 | 5 / 6 | 5 / 6 | +| p50 latency | 836ms | 920ms | 810ms | +| p95 latency | 1.41s | 2.00s | 1.31s | + +Thinking off is not just a tie on the headline numbers — it is identical case for case, with +the same confusion matrix and the same three unparseable replies. The latency difference is +run-to-run noise on one box, and it points the wrong way here. + +The reason is simpler than any accuracy argument: **this llama-server build ignores the +request-level thinking switch for this model.** Probed directly against the running server +with `chat_template_kwargs.enable_thinking = false`, `chat_template_kwargs.thinking = false` +and top-level `reasoning_budget = 0` — all three return a byte-identical answer with the +thinking trace still in `reasoning_content`, and the server reports the prompt prefix as +cached, meaning the rendered template did not change. There was never anything being turned +off, which is also why the numbers match exactly. + +Nothing was defaulted. `internal/llm` still has no `chat_template_kwargs` field, `VoiceConfig` +has no thinking flag, and `deploy/mavend.json` is unchanged. The misleading third +configuration is removed from `internal/router/eval` so the table it produced cannot be quoted +again. + +Two caveats worth saying out loud: + +- **The fixture is 76 cases.** A 6-point difference on 76 cases is roughly 4-5 cases and would + not have been worth trusting even if it had reproduced. This one was exactly 0 cases, which + is a much easier call. +- **This is one server build and one checkpoint** (`b9351`, Qwen3.5-0.8B Q4_K_M). If the + #122 checkpoint or a newer llama.cpp does honour the switch, the question reopens — but it + reopens as an unmeasured question, not as a 6-point win. + +Phrasing was **not** measured. Whether thinking helps there is still open, and now also blocked +on the same "can we even turn it off" question. + +## Clock and calendar rule — 31-07-2026 (Vikunja #374) + +`routeSystem` never said whether "который час" or "какое число завтра" are `system` or +`query`, and `system→query ×4` showed up in every run. The rule added says: the clock and the +calendar date themselves are `system`; what is *written in* the calendar or in memory +("что у меня завтра", "какие есть напоминания") stays `query`; and a time named inside a +request ("напомни завтра…") is just a detail of the request, not a reason for `system`. + +That split is not a preference. In `cmd/mavend/voice.go` only `replySystem` owns the clock and +the date formatter, so a clock question routed to `query` falls into the embedder + note RAG +and answers "не знаю". The agenda, on the other hand, is answered by `ParseCalendarDate` + +`CalendarEvents` *inside* the `query` branch, so that side has to stay `query`. The rule sits +above the question test because every one of these utterances carries a question word and a +later rule would never be reached. + +The fixture is now 77 cases: one calendar-agenda case was added +(`ru-query-019` "что у меня стоит в календаре на послезавтра", intent `query`) specifically so +an over-broad system rule cannot pass unnoticed. The clock/date cases (`ru-sys-001/002/005`, +`en-sys-001`) already existed. + +Three runs, same box, back to back, never concurrently: + +| | baseline | first rule (too broad) | rule as committed | +|---|---|---|---| +| llm-only intent-only | 59.2% (45/76) | 54.5% (42/77) | 59.7% (46/77) | +| llm-only full | 38.2% | 35.1% | 39.0% | +| llm-only route errors | 3 | 4 | 5 | +| llm-only p50 | 1.09s | 0.91s | 0.93s | +| cascade+llm intent-only | 61.8% (47/76) | 58.4% | 62.3% (48/77) | +| cascade+llm full | 57.9% | 54.5% | 59.7% | +| cascade+llm route errors | 0 | 0 | 0 | +| cascade+llm p50 | 0.91s | 0.80s | 1.04s | + +**The targeted bug is fixed and the headline number did not move.** `system→query ×4` is gone +in both LLM configurations — the `time` and `date` tags go from 0/2 and 0/2 to 2/2 and 2/2 — +but the model then over-applies the rule, and `query→system ×5` plus `reminder→system ×2` +appear where they did not exist before. Net accuracy is a wash, inside the noise of a 77-case +fixture. + +The first attempt is shown because it is the honest history: it said "спрашивает время, дату +или день недели → system" with no scope, which swept up reminders, and it cost 3-5 points. It +was tightened once, on the reasoning that a rule capturing "напомни завтра в 7" is simply +wrong, and not tuned further. The remaining `query/reminder → system` over-trigger is a new, +separate weakness of the sub-1B model and deserves its own task rather than more prompt +kneading against a held-out fixture. + +The rule is kept. It is correct about what the daemon can answer, and the failure it replaces +was silent ("не знаю" to "который час") while the one it introduces is loud. + ## Findings ### 1. The resident model does route better — 50.0% vs 36.8% @@ -110,11 +243,11 @@ Note the grammar's `string ::= "\"" ([^"\\] | "\\" .)* "\""` is unbounded, so no ### 7. Two hypotheses tested and closed -- **Thinking mode is a non-issue.** Qwen3.5's template defaults `thinking = 1`, so - grammar-constrained JSON lands in `reasoning_content` with `content` empty — - `llm.Client`'s fallback handles it. A `thinking off` run scored *identically* (18/76, - 48.7%, same p50). `internal/llm` deliberately does **not** grow a `chat_template_kwargs` - field. +- **Thinking mode is a non-issue.** Confirmed twice now, the second time properly — see the + controlled re-run section. Grammar-constrained JSON lands in `reasoning_content` with + `content` empty and `llm.Client`'s fallback handles it; the request-level switch does + nothing on this build. `internal/llm` deliberately does **not** grow a + `chat_template_kwargs` field. - **Runaway array repetition does not reproduce.** An isolated smoke test with a stripped grammar emitted `{"intent":"reminder"}` until `MaxTokens`; under the real `routeSystem` prompt the few-shot examples anchor it to one object. 2 errors in 76, not 76. diff --git a/TALK-EVAL-31-07-2026.md b/TALK-EVAL-31-07-2026.md new file mode 100644 index 0000000..65cad81 --- /dev/null +++ b/TALK-EVAL-31-07-2026.md @@ -0,0 +1,150 @@ +# Conversational phrasing eval — 31-07-2026 + +Every score measured tonight, on the three paths the nudge eval never touched: +chat, query-with-notes, and general knowledge. + +**Short version: the plumbing got fixed and the score barely moved.** Grammar and +Russian prompts together took the composite from ~9 to ~14 of 27. Everything +still failing is the model not knowing things or not holding a constraint, and +prompting is out of levers. Settles the measurement half of Vikunja #395 / #398 / +#400. + +## How to reproduce + +```sh +# llama-server: -c 4096 -ngl 99 -t 6, model /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf +MAVEN_LLM_URL=http://127.0.0.1:18099 no_proxy=127.0.0.1,localhost \ + deps/go/go/bin/go test -count=1 -timeout 40m \ + -run TestLLMTalkBaseline ./internal/phraser/eval/ -v +``` + +Three runs per configuration, always. The fixture is 27 cases, so one reply +changing moves the composite by 3.7 points — a single run cannot tell a real +change from sampling noise. This was learned the expensive way: an earlier claim +that "one nudge case fails every run" turned out to be three different cases +across three runs. + +**Run the box otherwise idle.** See the contamination note at the bottom. + +## Composite, per configuration + +| config | overall /27 | chat /9 | query /9 | knowledge /9 | canned fallbacks | +|---|---|---|---|---|---| +| baseline, no grammar | 7, 12, 7 | 1, 1, 0 | 2, 4, 2 | 4, 7, 5 | 0, 0, 0 | +| + GBNF grammar (#398) | 14, 15, 8 | 1, 3, 0 | 5, 6, 3 | 8, 6, 5 | 0, 0, 0 | +| + Russian prompts (#400) | 11, 17, 15 | 1, 5, 3 | 5, 6, 8 | 5, 6, 4 | 0, 0, 0 | +| + truncation fix, 1000ch/768tok | 12, 13, 10 | 2, 2, 1 | 7, 7, 5 | 3, 4, 4 | 3, 3, 6 | +| + rebalanced, 600ch/1024tok | **void — contaminated** | | | | | + +"Canned fallbacks" counts replies that came back as the hardcoded `"не знаю."` +or `"поговорили."`. It is not a check, it is a health signal: those strings mean +the phraser gave up, and the eval scores them as ordinary bad replies. + +## Per-check + +| check | no grammar | + grammar | + RU prompts | + truncation fix | +|---|---|---|---|---| +| nonempty | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 | +| ellipsis | 20, 19, 23 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 | +| lang | 13, 16, 15 | 23, 26, 26 | 25, 26, 25 | 26, 27, 27 | +| feminine | — | — | 25, 24, 26 | 25, 25, 27 | +| address | — | — | 21, 22, 22 | 22, 21, 22 | +| ontopic | — | — | 17, 24, 18 | 17, 19, 14 | + +`nonempty` reading 27/27 everywhere is not good news — it was a broken check. +It tested for a non-blank string, so replies of literally `{` and `"15-16"` +passed it. Fixed on `overnight/fix-truncation`; it needs a letter now. + +## What each change actually bought + +**GBNF grammar (#398) — the biggest single win.** Qwen3.5-0.8B writes +`Thinking Process:` as plain text with no tags, `stripThink` only handles +``, 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. diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go new file mode 100644 index 0000000..512b3d3 --- /dev/null +++ b/cmd/mavend/clarify.go @@ -0,0 +1,241 @@ +package main + +import ( + "context" + "log" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +// clarifyTTL — how long a parked question stays answerable. Same 90s as the +// confirm gate, for the same reason: an answer is a same-breath gesture, and a +// stale question must not eat an unrelated later utterance. +const clarifyTTL = 90 * time.Second + +// wantedSlots — what each intent needs before she can act on it. First entry is +// the one she asks about; the rest are only used to decide act-vs-drop. +// +// Intents not listed here are never worth a question: note and query act on the +// 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. +var wantedSlots = map[router.Intent][]dialogue.Slot{ + router.IntentReminder: {dialogue.SlotTime}, + router.IntentFact: {dialogue.SlotKey}, + router.IntentAct: {dialogue.SlotFn}, +} + +// clarifyQuestions — one short question per missing slot. +// +// These are fixed templates, not model output. The resident model is a 0.8B; it +// would wander, and a question whose wording changes every time is harder to +// answer than a blunt one that always reads the same. They are infinitive +// 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.SlotKey: "Что записать?", + dialogue.SlotFn: "Что сделать?", +} + +// clarifyGaveUp — she is out of questions and still does not have the slot. She +// says so out loud: dropping the request in silence would leave him thinking it +// 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 +// passed, not "I did not understand". Feminine self-reference ("ждала", +// "отпустила"); he is addressed with a plain imperative. +const clarifyExpired = "Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново." + +// clarifyExpiredNotice returns that line when a parked question had just timed +// out, and "" when nothing was parked. Call it right after +// resolveClarifyAnswer: a live question is answered there, an expired one is +// only reported here — the words themselves still go on to be routed fresh. +func (h *reactiveHandler) clarifyExpiredNotice() string { + if h.clarifyStore == nil { + return "" + } + if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) { + return "" + } + log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh") + return clarifyExpired +} + +// withNotice glues the expiry notice in front of this turn's reply. One turn +// carries one reply on the wire, so the notice cannot be a message of its own — +// but neither the notice nor the fresh answer may be dropped. +func withNotice(notice, reply string) string { + if notice == "" { + return reply + } + if reply == "" { + return notice + } + return notice + " " + reply +} + +// missingFor returns the slots a decision still needs, most important first. +// Empty ⇒ there is nothing identifiable to ask about. +func missingFor(dec router.Decision) []dialogue.Slot { + return dialogue.StillMissing(wantedSlots[dec.Intent], toDialogueSlots(dec.Slots)) +} + +// clarifyQuestion picks the one question to ask for a clarify decision. Returns +// ("", false) when she has no idea what is missing. +// +// One question about one thing: if two slots are missing she asks about the +// first and lets the rest go. Two questions in a row is an interrogation. +func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) { + missing := missingFor(dec) + if len(missing) == 0 { + return "", "", false + } + q, ok := clarifyQuestions[missing[0]] + if !ok { + return "", "", false + } + return missing[0], q, true +} + +// askClarify parks the request and returns the question to ask instead of the +// canned "не поняла". Returns ("", false) when there is nothing to ask about, so +// the caller falls back to the canned reply. +func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) { + if h.clarifyStore == nil { + return "", false + } + slot, question, ok := clarifyQuestion(dec) + if !ok { + return "", false + } + h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{ + Intent: dialogue.Intent(dec.Intent), + Slots: toDialogueSlots(dec.Slots), + Missing: []dialogue.Slot{slot}, + Utterance: dec.Utterance, + Asked: h.now(), + TTL: clarifyTTL, + Attempts: 1, // this ask + MaxAttempts: h.clarifyMaxAttempts, + }) + log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent) + return question, true +} + +// resolveClarifyAnswer reads an utterance as the answer to a parked question. +// Returns ("", false) when no live question is parked (or it expired), so the +// caller routes the utterance normally as a fresh request. Sibling of +// resolveConfirm and checked in the same place. +// +// The answer is parsed with the same extractor the router uses, for the intent +// she parked — no second parser. If it still does not fill the gap she asks +// again, up to MaxAttempts; after that she says out loud that she did not +// understand. She never drops the request in silence. +func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) { + if h.clarifyStore == nil { + return "", false + } + q := h.clarifyStore.Get(voiceDialogueID, h.now()) + if q == nil { + return "", false + } + + intent := router.Intent(q.Intent) + answer := h.extractor.Extract(ctx, intent, text, h.now()) + merged := q.Answer(text, toDialogueSlots(answer)) + if len(dialogue.StillMissing(q.Missing, merged)) > 0 { + return h.reaskOrGiveUp(q, merged, text), true + } + h.clarifyStore.Delete(voiceDialogueID) + + // Rebuild the decision as if it had routed cleanly, then run it down the + // normal path. Clarify is deliberately false and the intent is unchanged: + // filling in an argument never grants authority, so the completed decision + // still meets the allowlist and the destructive-act confirm gate in + // applyAction exactly like any other decision. + dec := router.Decision{ + Utterance: q.Utterance, + Stage: 2, + Intent: intent, + Slots: applyDialogueSlots(answer, merged), + } + return h.finishClarified(ctx, dec), true +} + +// reaskOrGiveUp handles an answer that left the gap open: ask the same question +// again while she has attempts left, otherwise say she did not understand and +// let the request go. Never returns "" — a mute give-up reads as "done". +func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string { + question := "" + if len(q.Missing) > 0 { + question = clarifyQuestions[q.Missing[0]] + } + if question == "" || !q.CanAsk() { + h.clarifyStore.Delete(voiceDialogueID) + log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text) + return clarifyGaveUp + } + // Re-park with whatever the answer DID give, the clock restarted and one + // more question spent. + q.Slots = merged + q.Attempts++ + q.Asked = h.now() + h.clarifyStore.Put(voiceDialogueID, q) + log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts) + return question +} + +// finishClarified runs a completed decision through the same steps a freshly +// routed one takes: remember the turn, act, then phrase. +func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string { + if h.dialogueSessions != nil { + now := h.now() + prev := h.dialogueSessions.Get(voiceDialogueID, now) + dec = followUpMerge(prev, dec, now) + h.rememberTurn(prev, dec, now) + } + reply := h.applyAction(ctx, dec) + if reply == "" { + reply = h.replier.Reply(dec) + } + if reply == "" { + // Belt: an empty reply here would be a silent drop. + reply = clarifyGaveUp + } + return reply +} + +// rememberTurn stores this turn as the dialogue session the next follow-up +// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so +// one long conversation can't grow the session unboundedly. +func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decision, now time.Time) { + var history []dialogue.Turn + if prev != nil { + history = append(history, dialogue.Turn{ + Intent: prev.Intent, + Slots: prev.Slots, + Text: prev.Slots.Text, + }) + maxHist := len(prev.History) + if maxHist > 3 { + maxHist = 3 + } + history = append(history, prev.History[:maxHist]...) + } + ttl := time.Duration(0) // use the store default (2 min) + if dec.Intent == router.IntentChat { + ttl = 15 * time.Minute // conversational turns should last longer + } + h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ + Intent: dialogue.Intent(dec.Intent), + Slots: toDialogueSlots(dec.Slots), + Timestamp: now, + TTL: ttl, + History: history, + }) +} diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go new file mode 100644 index 0000000..43880b2 --- /dev/null +++ b/cmd/mavend/clarify_test.go @@ -0,0 +1,329 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" + "github.com/kami/maven/internal/tool" + "github.com/kami/maven/internal/voice" +) + +// newClarifyHandler builds a handler with the clarify path wired and no model: +// stub date parser, the real fact parser, and a matcher over whatever tools the +// test enabled. `now` is fixed so TTL behaviour is testable. +func newClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) { + t.Helper() + st := newTestStore(t) + api := ipc.NewStoreAPI(st) + now := time.Date(2026, 7, 31, 9, 0, 0, 0, time.UTC) + matcher := tool.NewMatcher(api) + h := &reactiveHandler{ + api: api, + dataStore: st, + tools: tool.NewExecutor(api, 2*time.Second), + matcher: matcher, + replier: voice.NewStubReplier(), + now: func() time.Time { return now }, + dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), + clarifyStore: dialogue.NewClarifyStore(clarifyTTL), + extractor: router.Extractor{ + Time: router.StubDateTimeParser{}, + Acts: matcher, + Facts: router.DefaultFactParser{}, + }, + } + return h, st, &now +} + +func clarifyDec(intent router.Intent, slots router.Slots, utterance string) router.Decision { + return router.Decision{Utterance: utterance, Stage: 3, Intent: intent, Slots: slots, Clarify: true} +} + +// TestClarifyQuestionForMissingSlot pins which question goes with which gap, and +// which intents get no question at all. +func TestClarifyQuestionForMissingSlot(t *testing.T) { + cases := []struct { + name string + dec router.Decision + want string + asked bool + }{ + {"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}, + {"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}, + } + for _, tc := range cases { + _, got, asked := clarifyQuestion(tc.dec) + if asked != tc.asked || got != tc.want { + t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked) + } + } +} + +// TestClarifyReminderCompletesOnAnswer is the whole point of the feature: she +// asks for the missing time and the answer creates the reminder. +func TestClarifyReminderCompletesOnAnswer(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + + question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")) + if !asked || question != "На когда напомнить?" { + t.Fatalf("expected the time question, got %q asked=%v", question, asked) + } + + reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00") + if !handled { + t.Fatal("the answer to an open question must be consumed as an answer") + } + if reply == clarifyGaveUp { + t.Fatalf("a good answer must not drop the request: %q", reply) + } + + reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) + if err != nil || len(reminders) != 1 { + t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err) + } + if !strings.Contains(reminders[0].Payload, "маме") { + t.Fatalf("the reminder lost the original request: %q", reminders[0].Payload) + } + if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { + t.Fatal("the question must be cleared once answered") + } +} + +// TestClarifyFactCompletesOnAnswer — the fact path, where the answer carries +// both the key and the value. +func TestClarifyFactCompletesOnAnswer(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + + if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked { + t.Fatal("a fact with no key should be asked about") + } + if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp { + t.Fatalf("answer should complete the fact, handled=%v reply=%q", handled, reply) + } + if fact, err := st.LatestFact(ctx, "water"); err != nil || fact.Key != "water" { + t.Fatalf("clarified fact was not written: fact=%+v err=%v", fact, err) + } +} + +// TestClarifyAnswerAfterTTLIsANewRequest — a late answer is not an answer. +func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) { + ctx := context.Background() + h, st, now := newClarifyHandler(t) + + if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { + t.Fatal("expected a question") + } + *now = now.Add(clarifyTTL + time.Second) + + if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); handled { + t.Fatalf("an answer past the TTL must fall through to normal routing, got %q", reply) + } + if reminders, err := st.DueReminders(ctx, now.Add(48*time.Hour)); err != nil || len(reminders) != 0 { + t.Fatalf("expired question must not create anything: reminders=%v err=%v", reminders, err) + } +} + +// TestClarifyAsksThreeTimesThenSaysSo — three questions are allowed, the fourth +// is not, and running out is SPOKEN. Silence would read as "handled". +func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + + if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { + t.Fatal("expected a first question") + } + // Two more unclear answers ⇒ two more questions (3 asks in total). + for i := 2; i <= 3; i++ { + reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю") + if !handled { + t.Fatalf("answer %d must be consumed as an answer", i) + } + if reply != "На когда напомнить?" { + t.Fatalf("attempt %d should ask again, got %q", i, reply) + } + if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil { + t.Fatalf("attempt %d must leave the question armed", i) + } + } + + reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю") + if !handled || reply != clarifyGaveUp { + t.Fatalf("the fourth try must give up out loud, handled=%v reply=%q", handled, reply) + } + if reply == "" || strings.Contains(reply, "?") { + t.Fatalf("giving up must be spoken and must not be another question: %q", reply) + } + if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { + t.Fatal("a given-up request must leave no armed question") + } + if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 { + t.Fatalf("a given-up request must not create anything: reminders=%v err=%v", reminders, err) + } +} + +// TestClarifyMaxAttemptsIsConfigurable — one question when the config says one. +func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) { + ctx := context.Background() + h, _, _ := newClarifyHandler(t) + h.clarifyMaxAttempts = 1 + + if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { + t.Fatal("expected a question") + } + if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp { + t.Fatalf("with max 1 she must give up at once, handled=%v reply=%q", handled, reply) + } +} + +// TestClarifyRestatedAnswerWins — «в 11:00», then «нет, в 15:00». The second +// value is the one that lands. +func TestClarifyRestatedAnswerWins(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + + if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked { + t.Fatal("expected a question") + } + // First answer parses, but re-park it by hand as if she had asked again: + // what matters here is that Answer prefers the newer value over the parked + // one, which is the case the daemon hits on a re-ask. + q := h.clarifyStore.Get(voiceDialogueID, h.now()) + if q == nil { + t.Fatal("expected an armed question") + } + first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now()) + q.Slots = q.Answer("в 11:00", toDialogueSlots(first)) + + if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp { + t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply) + } + reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) + if err != nil || len(reminders) != 1 { + t.Fatalf("expected one reminder: %v err=%v", reminders, err) + } + want := h.extractor.Extract(ctx, router.IntentReminder, "в 15:00", h.now()) + if !reminders[0].FireTs.Equal(want.Time) { + t.Fatalf("reminder at %v, want the restated %v", reminders[0].FireTs, want.Time) + } +} + +// TestClarifiedActOffAllowlistIsStillRefused — clarification fills in an +// argument, it never grants authority. +func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + marker := filepath.Join(t.TempDir(), "not-allowed-ran") + + if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked { + t.Fatal("an act with no fn should be asked about") + } + reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker) + if !handled { + t.Fatal("the answer should be consumed") + } + if strings.Contains(reply, "готово") { + t.Fatalf("an act that is not on the allowlist must not report success: %q", reply) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("a clarified act off the allowlist ran anyway: %v", err) + } + if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 0 { + t.Fatalf("clarify must not enable a tool: tools=%+v err=%v", tools, err) + } +} + +// TestClarifiedDestructiveActStillNeedsConfirm — the confirm gate survives the +// clarify path. +func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) { + ctx := context.Background() + h, st, _ := newClarifyHandler(t) + marker := filepath.Join(t.TempDir(), "destructive-ran") + if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil { + t.Fatal(err) + } + + if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked { + t.Fatal("expected a question") + } + reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups") + if !handled { + t.Fatal("the answer should be consumed") + } + if !strings.Contains(reply, "да") || h.pending == nil { + t.Fatalf("a clarified destructive act must still park a confirm: reply=%q pending=%+v", reply, h.pending) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("a clarified destructive act ran before confirmation: %v", err) + } +} + +// TestNoQuestionWhenNothingIsMissing — noise keeps the canned reply, so she +// never invents a question for nothing. +func TestNoQuestionWhenNothingIsMissing(t *testing.T) { + h, _, _ := newClarifyHandler(t) + for _, dec := range []router.Decision{ + clarifyDec(router.IntentChat, router.Slots{Text: "эм"}, "эм"), + clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"), + clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."), + } { + if question, asked := h.askClarify(dec); asked { + t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question) + } + } + if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil { + t.Fatal("noise must not park a question") + } +} + +// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the +// TTL: she must say the old request is gone AND still answer the new words. +func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) { + ctx := context.Background() + h, _, now := newClarifyHandler(t) + emb := router.NewHashEmbedder(1024) + h.embedder = emb + h.router = buildRouter(emb, h.matcher, 0.55, nil) + + if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked { + t.Fatal("expected a question") + } + *now = now.Add(clarifyTTL + time.Second) + + reply := h.handleText(ctx, "как дела") + if !strings.HasPrefix(reply, clarifyExpired) { + t.Fatalf("expired question must be announced first, got %q", reply) + } + if strings.TrimSpace(strings.TrimPrefix(reply, clarifyExpired)) == "" { + 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) { + t.Fatalf("notice repeated on a later turn: %q", reply) + } +} + +// TestNoPendingQuestionFallsThrough — with nothing parked, an utterance routes +// normally. +func TestNoPendingQuestionFallsThrough(t *testing.T) { + h, _, _ := newClarifyHandler(t) + if reply, handled := h.resolveClarifyAnswer(context.Background(), "напомни в 11:00"); handled { + t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply) + } +} diff --git a/cmd/mavend/llmrouter_flag_test.go b/cmd/mavend/llmrouter_flag_test.go new file mode 100644 index 0000000..ac81977 --- /dev/null +++ b/cmd/mavend/llmrouter_flag_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "testing" + "time" + + "github.com/kami/maven/internal/llm" +) + +func TestPickLLMRouterOff(t *testing.T) { + if r := pickLLMRouter(false, llm.New("http://127.0.0.1:1", time.Second)); r != nil { + t.Error("flag off should give no LLM router") + } +} + +// The operator can turn the flag on without an LLM phraser configured. That must +// leave the classifier running, not panic. +func TestPickLLMRouterOnWithoutClient(t *testing.T) { + if r := pickLLMRouter(true, nil); r != nil { + t.Error("no llama-server should give no LLM router") + } +} + +func TestPickLLMRouterOn(t *testing.T) { + if r := pickLLMRouter(true, llm.New("http://127.0.0.1:1", time.Second)); r == nil { + t.Error("flag on with a client should give an LLM router") + } +} diff --git a/cmd/mavend/main.go b/cmd/mavend/main.go index f02d01c..2966968 100644 --- a/cmd/mavend/main.go +++ b/cmd/mavend/main.go @@ -57,10 +57,11 @@ import ( "github.com/kami/maven/internal/delivery/ntfysink" "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" - "github.com/kami/maven/internal/loop" ) var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first") @@ -161,11 +162,14 @@ func (l *lockedAPI) EnableTool(ctx context.Context, name string, cmd []string, d return errLocked } func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked } -func (l *lockedAPI) DeleteTool(ctx context.Context, name string) error { return errLocked } +func (l *lockedAPI) DeleteTool(ctx context.Context, name string) error { return errLocked } func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRoutine, error) { return nil, errLocked } func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked } +func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id int64) error { + return errLocked +} func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) { return ipc.Tool{}, errLocked } @@ -186,7 +190,9 @@ func (l *lockedAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat func run(args []string) error { cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config") wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)") + reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap)") flag.CommandLine.Parse(args) + reembedOnStart = *reembed cfg, err := config.Load(*cfgPath) if err != nil { return err @@ -244,15 +250,15 @@ func run(args []string) error { // ----- daemon components (only wired when unlocked) ----- // Pre-declare so the unlock path can wire them later. var ( - gatherer *loop.Gatherer - rules []loop.Rule - phr phraser.Phraser - voiceW *voiceWiring - dispatcher *delivery.Dispatcher - tl *tickLoop - coreAPI ipc.CoreAPI - eco *ecosystemWiring - factWorker *factEnrichmentWorker + gatherer *loop.Gatherer + rules []loop.Rule + phr phraser.Phraser + voiceW *voiceWiring + dispatcher *delivery.Dispatcher + tl *tickLoop + coreAPI ipc.CoreAPI + eco *ecosystemWiring + factWorker *factEnrichmentWorker ) if !locked { @@ -266,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" @@ -436,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" @@ -596,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()) } } diff --git a/cmd/mavend/query_recall_test.go b/cmd/mavend/query_recall_test.go new file mode 100644 index 0000000..b97c436 --- /dev/null +++ b/cmd/mavend/query_recall_test.go @@ -0,0 +1,158 @@ +package main + +import ( + "context" + "fmt" + "math" + "testing" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/voice" +) + +// fixedEmbedder hands back a vector chosen per text, so a test can say exactly +// how close each stored memory is to the question. The real embedders make +// scores that are realistic but not controllable, and this test is about the +// gate, not about the embedder. +type fixedEmbedder struct{ vecs map[string][]float32 } + +func (f *fixedEmbedder) Dim() int { return 4 } +func (f *fixedEmbedder) Close() error { return nil } + +func (f *fixedEmbedder) Embed(_ context.Context, text string) ([]float32, error) { + v, ok := f.vecs[text] + if !ok { + return nil, fmt.Errorf("fixedEmbedder: no vector for %q", text) + } + return v, nil +} + +// scoreVec builds a unit vector whose cosine against the query vector +// (1,0,0,0) is exactly score. +func scoreVec(score float64) []float32 { + rest := math.Sqrt(1 - score*score) + return []float32{float32(score), float32(rest), 0, 0} +} + +// recordingPhraser remembers what the query path handed it to phrase, which is +// how the test can tell which pass produced the answer. +type recordingPhraser struct { + *phraser.Stub + notes []string +} + +func (r *recordingPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) { + r.notes = notes + return r.Stub.PhraseQuery(ctx, utterance, notes) +} + +// recallCase — one stored memory: its text, how close it is to the question, +// whether it is a note or a fact, and whether the notes table holds it too. +type recallCase struct { + text string + score float64 + kind string +} + +// buildRecallHandler stores the given memories and returns a handler whose +// query path can be run directly. Notes go into BOTH the notes table and the +// vector index, which is what the daemon does (voice.go's IntentNote). +func buildRecallHandler(t *testing.T, question string, mems []recallCase) (*reactiveHandler, *recordingPhraser) { + t.Helper() + ctx := context.Background() + st := newTestStore(t) + emb := &fixedEmbedder{vecs: map[string][]float32{question: {1, 0, 0, 0}}} + mem := memory.NewInMemoryStore() + now := time.Now() + + for i, m := range mems { + vec := scoreVec(m.score) + emb.vecs[m.text] = vec + id := fmt.Sprintf("%s:%d", m.kind, i) + if m.kind == "note" { + if _, err := st.WriteNote(ctx, now, m.text, vec, "tap:voice"); err != nil { + t.Fatalf("WriteNote: %v", err) + } + } + if err := mem.Insert(ctx, id, vec, map[string]string{"text": m.text, "type": m.kind}); err != nil { + t.Fatalf("memory insert: %v", err) + } + } + + phr := &recordingPhraser{Stub: phraser.NewStub()} + h := &reactiveHandler{ + api: ipc.NewStoreAPI(st), + embedder: emb, + replier: voice.NewStubReplier(), + phraser: phr, + now: func() time.Time { return now }, + memStore: mem, + dataStore: st, + queryMinScore: 0.55, + queryMinMargin: 0.008, + weatherProvider: nil, + } + return h, phr +} + +func askQuery(t *testing.T, h *reactiveHandler, question string) string { + t.Helper() + return h.applyAction(context.Background(), router.Decision{ + Intent: router.IntentQuery, + Utterance: question, + }) +} + +// TestQueryRecallNoteCanWin — the note-recall regression (Vikunja #373). Notes +// and facts share one vector index, and a note that clearly beats everything +// else must be the answer. Before the fix the memory pass only ran after the +// notes-only gate had already rejected the same note at the same score, so only +// a fact could ever come back from it. +func TestQueryRecallNoteCanWin(t *testing.T) { + const q = "где молоко" + + t.Run("a clearly best note answers", func(t *testing.T) { + h, phr := buildRecallHandler(t, q, []recallCase{ + {text: "молоко стоит в холодильнике", score: 0.90, kind: "note"}, + {text: "выучил пару аккордов", score: 0.50, kind: "note"}, + }) + reply := askQuery(t, h, q) + if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want { + t.Errorf("reply %q, want %q", reply, want) + } + // One text, the winning memory's — the answer came from the memory + // pass, not from handing the phraser every note in the table. + if len(phr.notes) != 1 || phr.notes[0] != "молоко стоит в холодильнике" { + t.Errorf("phraser got %q, want just the recalled note", phr.notes) + } + }) + + // The other half of "one gate over everything": a fact that matches better + // than the best note now answers, instead of losing to a note that only had + // to beat other notes. + t.Run("the better-matching fact answers", func(t *testing.T) { + h, _ := buildRecallHandler(t, q, []recallCase{ + {text: "молоко стоит в холодильнике", score: 0.80, kind: "note"}, + {text: "купил молоко в среду", score: 0.95, kind: "fact"}, + }) + if reply := askQuery(t, h, q); reply != "купил молоко в среду" { + t.Errorf("reply %q, want the fact read back", reply) + } + }) + + // The gate is untouched: two memories this close mean the embedder cannot + // tell them apart, and silence still beats a coin flip. + t.Run("no clear best stays silent", func(t *testing.T) { + h, _ := buildRecallHandler(t, q, []recallCase{ + {text: "молоко стоит в холодильнике", score: 0.860, kind: "note"}, + {text: "молоко закончилось", score: 0.858, kind: "note"}, + }) + if reply := askQuery(t, h, q); reply != "не знаю." { + t.Errorf("reply %q, want silence", reply) + } + }) +} diff --git a/cmd/mavend/recall.go b/cmd/mavend/recall.go index 0027adf..d06aeb3 100644 --- a/cmd/mavend/recall.go +++ b/cmd/mavend/recall.go @@ -2,24 +2,24 @@ package main import "github.com/kami/maven/internal/memory" -// bestRecall is the read side of the long-term memory store: the top hit's -// stored text when it clears the confidence gate. This recalls across BOTH -// notes and facts (facts aren't in the notes table, so this is the only path -// that can answer "when did I last …?" from a captured fact). A note hit here -// is redundant with the notes-RAG path — by design; the two indexes can diverge -// once the backend is swapped for a persistent/external store. ok=false when -// there's no hit above the threshold or the hit carries no text. -func bestRecall(results []memory.Result, min float64) (string, bool) { - if len(results) == 0 { - return "", false +// bestRecall is the read side of the long-term memory store: the top hit when +// it clears the confidence gate. The index holds BOTH notes and facts, and +// either can win — the caller looks at the returned hit's meta["type"] to see +// which. Facts aren't in the notes table, so this is the only path that can +// answer "when did I last …?" from a captured fact. +// +// The whole hit is returned, not just its text, because "which memory answered" +// decides how the answer is said: a note gets phrased in Maven's voice, a fact +// is read back as stored. +// +// ok=false when the hit fails the confidence gate (see memory.Confident: an +// absolute floor plus a margin over the runner-up) or carries no text. +func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Result, bool) { + if !memory.Confident(results, minScore, minMargin) { + return memory.Result{}, false } - top := results[0] - if top.Score < min { - return "", false + if results[0].Meta["text"] == "" { + return memory.Result{}, false } - text := top.Meta["text"] - if text == "" { - return "", false - } - return text, true + return results[0], true } diff --git a/cmd/mavend/recall_test.go b/cmd/mavend/recall_test.go index d527a50..1a70b2f 100644 --- a/cmd/mavend/recall_test.go +++ b/cmd/mavend/recall_test.go @@ -8,23 +8,24 @@ import ( func TestBestRecall(t *testing.T) { const min = 0.55 + const margin = 0.008 t.Run("empty results", func(t *testing.T) { - if _, ok := bestRecall(nil, min); ok { + if _, ok := bestRecall(nil, min, margin); ok { t.Error("empty results returned ok") } }) t.Run("top below threshold", func(t *testing.T) { res := []memory.Result{{Score: 0.4, Meta: map[string]string{"text": "выпил воды"}}} - if _, ok := bestRecall(res, min); ok { + if _, ok := bestRecall(res, min, margin); ok { t.Error("below-threshold hit returned ok") } }) t.Run("hit without text meta", func(t *testing.T) { res := []memory.Result{{Score: 0.9, Meta: map[string]string{"type": "fact"}}} - if _, ok := bestRecall(res, min); ok { + if _, ok := bestRecall(res, min, margin); ok { t.Error("textless hit returned ok") } }) @@ -34,12 +35,43 @@ func TestBestRecall(t *testing.T) { {Score: 0.82, Meta: map[string]string{"text": "выпил воды в три часа", "type": "fact"}}, {Score: 0.60, Meta: map[string]string{"text": "другое"}}, } - got, ok := bestRecall(res, min) + got, ok := bestRecall(res, min, margin) if !ok { t.Fatal("clearing hit not returned") } - if got != "выпил воды в три часа" { - t.Errorf("wrong text: %q", got) + if got.Meta["text"] != "выпил воды в три часа" { + t.Errorf("wrong text: %q", got.Meta["text"]) + } + if got.Meta["type"] != "fact" { + t.Errorf("kind lost: %q", got.Meta["type"]) + } + }) + + // The index holds notes and facts together, so a note has to be able to win + // it — for a long time it could not (Vikunja #373). + t.Run("a note can win", func(t *testing.T) { + res := []memory.Result{ + {Score: 0.86, Meta: map[string]string{"text": "молоко в холодильнике", "type": "note"}}, + {Score: 0.61, Meta: map[string]string{"text": "выпил воды", "type": "fact"}}, + } + got, ok := bestRecall(res, min, margin) + if !ok { + t.Fatal("clearly-best note not returned") + } + if got.Meta["type"] != "note" || got.Meta["text"] != "молоко в холодильнике" { + t.Errorf("got %v, want the note", got.Meta) + } + }) + + // The runner-up is almost as close, so the embedder cannot tell the two + // notes apart. Silence beats reading back a coin flip. + t.Run("runner-up too close", func(t *testing.T) { + res := []memory.Result{ + {Score: 0.860, Meta: map[string]string{"text": "выпил воды в три часа"}}, + {Score: 0.858, Meta: map[string]string{"text": "другое"}}, + } + if _, ok := bestRecall(res, min, margin); ok { + t.Error("thin-margin hit returned ok") } }) } diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index bbba111..e633aa4 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -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,19 @@ 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 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.` +const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused). +Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"} +Никогда не пиши "..." в поле response.` func (r *llmReplier) Reply(d router.Decision) string { if d.Clarify { @@ -38,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, diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go index b16dd0e..6e1f08c 100644 --- a/cmd/mavend/replier_llm_test.go +++ b/cmd/mavend/replier_llm_test.go @@ -9,12 +9,15 @@ import ( "github.com/kami/maven/internal/voice" ) -type mockCompleter struct{ out string; err error } +type mockCompleter struct { + out string + err error +} 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, "записала, кофе закончился") @@ -22,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, "записала, кофе закончился") @@ -30,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) @@ -40,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) @@ -50,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) diff --git a/cmd/mavend/system_reply_test.go b/cmd/mavend/system_reply_test.go new file mode 100644 index 0000000..cfc06d4 --- /dev/null +++ b/cmd/mavend/system_reply_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/router" +) + +// systemHandler — a handler with nothing but a fixed clock, which is all +// replySystem needs. +func systemHandler(now time.Time) *reactiveHandler { + return &reactiveHandler{now: func() time.Time { return now }} +} + +// TestReplySystemDateOffset — "какое число завтра" must answer tomorrow's +// date, not today's (Vikunja #388). +func TestReplySystemDateOffset(t *testing.T) { + // Thursday, 30 July 2026. + now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC) + h := systemHandler(now) + cases := []struct{ utterance, want string }{ + {"какое сегодня число", "сегодня четверг, 30 июля 2026 года"}, + {"какое число", "сегодня четверг, 30 июля 2026 года"}, + {"какое число завтра", "завтра пятница, 31 июля 2026 года"}, + {"какое число послезавтра", "послезавтра суббота, 1 августа 2026 года"}, + {"какое было число вчера", "вчера среда, 29 июля 2026 года"}, + } + for _, c := range cases { + got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance}) + if got != c.want { + t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want) + } + } +} + +// A day she cannot work out must not come back as today's date — that is the +// same silent wrong answer #388 was about, one step further out. +func TestReplySystemUnknownDayIsHonest(t *testing.T) { + now := time.Date(2026, 7, 30, 14, 5, 0, 0, time.UTC) + h := systemHandler(now) + for _, u := range []string{ + "какое число в пятницу", + "какое число через неделю", + "какое число в понедельник", + } { + got := h.replySystem(context.Background(), router.Decision{Utterance: u}) + if got != onlyNearDaysReply { + t.Errorf("replySystem(%q) = %q, want the honest reply", u, got) + } + } + // The days she does know must not be caught by the same guard. + if got := h.replySystem(context.Background(), router.Decision{Utterance: "какое число завтра"}); got == onlyNearDaysReply { + t.Error("завтра was treated as an unknown day") + } +} + +// TestReplySystemClockCity — the clock arm must not answer local time for a +// question about another city (Vikunja #388). She keeps one clock, so every +// named place gets the honest "local time only" answer. +func TestReplySystemClockCity(t *testing.T) { + now := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC) + h := systemHandler(now) + cases := []struct{ utterance, want string }{ + {"который час", "сейчас 12 часов ровно"}, + {"который час в киеве", onlyLocalTimeReply}, + {"сколько времени в москве", onlyLocalTimeReply}, + {"который час в лондоне", onlyLocalTimeReply}, + {"который час в бишкеке", onlyLocalTimeReply}, + } + for _, c := range cases { + got := h.replySystem(context.Background(), router.Decision{Utterance: c.utterance}) + if got != c.want { + t.Errorf("replySystem(%q) = %q, want %q", c.utterance, got, c.want) + } + } +} diff --git a/cmd/mavend/tick.go b/cmd/mavend/tick.go index 67835f5..39d1c83 100644 --- a/cmd/mavend/tick.go +++ b/cmd/mavend/tick.go @@ -186,6 +186,10 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) { // LLM-phrased — so a routine can't hallucinate. severity comes from config. t.fireRoutines(ctx, now, state) + // accepted routines: patterns the user confirmed. read straight from the + // store each tick so the schedule survives a restart. + t.fireAcceptedRoutines(ctx, now, state) + // morning routines: daily checklists (medicine/water/pets/...), nagged at // most once per day per routine, and only for items still unevidenced at // nudge time. See internal/morning for the "why not four timers" rationale. @@ -377,6 +381,64 @@ func (t *tickLoop) fireRoutines(ctx context.Context, now time.Time, state loop.S } } +// fireAcceptedRoutines nudges about the routines the user accepted, once per +// interval (Vikunja #366). Accepting used to create a single reminder, so a +// non-weekly routine fired once and went quiet forever; the schedule lives in +// the proposed_routines row now and the loop re-reads it every tick. +// +// A routine is a care-class nudge and goes through the restraint gate like any +// other: quiet hours, away presence and snooze all suppress it. Reminders bypass +// that gate; routines must not. A suppressed nudge is NOT marked fired, so it +// goes out on the next tick that the gate allows — one nudge, held, not dropped +// and not repeated. +// +// The body is literal text built from the detected action and object, not +// LLM-phrased, so a routine can't hallucinate. It nudges; it never acts. +func (t *tickLoop) fireAcceptedRoutines(ctx context.Context, now time.Time, state loop.State) { + rows, err := t.store.ListAcceptedRoutines(ctx) + if err != nil { + log.Printf("tick: list accepted routines: %v", err) + return + } + accepted := make([]routine.Accepted, 0, len(rows)) + for _, r := range rows { + if r.AcceptedTs == nil { + continue // accepted before the schedule column existed — no clock to start from. + } + accepted = append(accepted, routine.Accepted{ + ID: r.ID, + Name: r.Action + " " + r.Object, + IntervalDays: r.IntervalDays, + Accepted: *r.AcceptedTs, + LastFired: r.LastFiredTs, + }) + } + + for _, a := range routine.DueAccepted(accepted, now) { + rule := loop.Rule{Name: "routine:" + a.Name, Severity: loop.Sev1} + if !loop.Gate(state, rule) { + continue + } + body := "пора: " + a.Name + pn := delivery.PhrasedNudge{ + Candidate: loop.Candidate{Rule: rule, Severity: rule.Severity, State: state}, + Body: body, + Summary: body, + } + sent, err := t.dispatcher.DispatchNudge(ctx, pn, now) + if err != nil { + log.Printf("tick: dispatch accepted routine %d: %v", a.ID, err) + continue + } + if len(sent) == 0 { + continue // routing dropped it — leave it due. + } + if err := t.store.MarkRoutineFired(ctx, a.ID, now); err != nil { + log.Printf("tick: mark routine %d fired: %v", a.ID, err) + } + } +} + // fireMorningRoutines checks each configured checklist against today's facts // and dispatches a nag listing exactly what's still missing, at most once per // routine per calendar day. Fact reads happen here (not in loop.Gatherer) diff --git a/cmd/mavend/tick_test.go b/cmd/mavend/tick_test.go index 337ad3c..3333ce8 100644 --- a/cmd/mavend/tick_test.go +++ b/cmd/mavend/tick_test.go @@ -96,6 +96,115 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) { } } +// TestTickFiresAcceptedRoutineEveryInterval — Vikunja #366. An accepted routine +// with a 3-day interval must nudge every 3 days, not once. It also must not +// replay the occurrences it slept through: after a 30-day gap it nudges once. +func TestTickFiresAcceptedRoutineEveryInterval(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + accepted := refNow() + + id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted) + if err != nil { + t.Fatalf("CreateProposedRoutine: %v", err) + } + if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil { + t.Fatalf("AcceptProposedRoutine: %v", err) + } + + sink := &fakeSink{} + tl := newTestTickLoop(t, st, sink, nil) + const rule = "routine:полить цветы" + + // Same day as the accept: not due yet. + markPresent(t, st, ctx, accepted) + tl.tick(ctx, accepted.Add(time.Hour)) + if n := countSends(sink, rule); n != 0 { + t.Fatalf("routine fired %d times before its first interval passed, want 0", n) + } + + // Three days later: the first nudge. + first := accepted.Add(3 * 24 * time.Hour) + markPresent(t, st, ctx, first) + tl.tick(ctx, first) + if n := countSends(sink, rule); n != 1 { + t.Fatalf("first interval: sends = %d, want 1", n) + } + + // Next day: still inside the interval, silent. + sink.sends = nil + markPresent(t, st, ctx, first.Add(24*time.Hour)) + tl.tick(ctx, first.Add(24*time.Hour)) + if n := countSends(sink, rule); n != 0 { + t.Fatalf("mid-interval: sends = %d, want 0", n) + } + + // Three days after the first nudge: it fires again. This is the bug — + // a one-shot reminder would never come back. + second := first.Add(3 * 24 * time.Hour) + markPresent(t, st, ctx, second) + tl.tick(ctx, second) + if n := countSends(sink, rule); n != 1 { + t.Fatalf("second interval: sends = %d, want 1 (a routine repeats)", n) + } + + // A long silence must not turn into a backlog of missed nudges. + sink.sends = nil + late := second.Add(30 * 24 * time.Hour) + markPresent(t, st, ctx, late) + tl.tick(ctx, late) + if n := countSends(sink, rule); n != 1 { + t.Fatalf("after a 30-day gap: sends = %d, want exactly 1 (no backlog)", n) + } +} + +// TestTickAcceptedRoutineRespectsQuietHours — routines are not reminders: they +// do not inherit the reminder gate bypass. Away presence drops a care-class +// nudge, and the routine stays due so it nudges once the user is back. +func TestTickAcceptedRoutineRespectsGate(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + accepted := refNow() + + id, err := st.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, accepted) + if err != nil { + t.Fatalf("CreateProposedRoutine: %v", err) + } + if err := st.AcceptProposedRoutine(ctx, id, accepted); err != nil { + t.Fatalf("AcceptProposedRoutine: %v", err) + } + + sink := &fakeSink{} + tl := newTestTickLoop(t, st, sink, nil) + const rule = "routine:полить цветы" + + // No presence probes at all ⇒ away ⇒ the care gate blocks the nudge. + due := accepted.Add(3 * 24 * time.Hour) + tl.tick(ctx, due) + if n := countSends(sink, rule); n != 0 { + t.Fatalf("away: sends = %d, want 0 (routine must not bypass the gate)", n) + } + + // Back at the desk a minute later: the nudge that was held now goes out. + back := due.Add(time.Minute) + markPresent(t, st, ctx, back) + tl.tick(ctx, back) + if n := countSends(sink, rule); n != 1 { + t.Fatalf("present again: sends = %d, want 1", n) + } +} + +// countSends counts captured sends for one rule name. +func countSends(sink *fakeSink, rule string) int { + n := 0 + for _, s := range sink.sends { + if s.RuleName == rule { + n++ + } + } + return n +} + // refNow — fixed tick time so presence decay + since durations are deterministic. func refNow() time.Time { return time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 9bbd226..af5feb0 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -171,6 +171,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem emb = router.NewHashEmbedder(1024) } w.embedder = emb + checkStoredEmbedder(dataStore, emb) // ----- tool executor (the enabled act allowlist, store-backed) ----- // Config tools are the declarative bootstrap: seed them into the store as @@ -199,8 +200,6 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem if lp, ok := phr.(*phraser.LLMPhraser); ok { llmClient = llm.New(lp.BaseURL(), 60*time.Second) } - // LLM router disabled — the classifier handles routing reliably. - // ----- router (the cascade; floor examples seed the classifier) ----- // The act matcher's allowlist is exactly the enabled tool names — the // router only matches acts the executor can run (one source of truth). @@ -208,7 +207,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem if threshold <= 0 { threshold = config.DefaultRouterThreshold } - rtr := buildRouter(emb, matcher, threshold, nil) // LLM router disabled + // The resident model routes by default: 63.2% of held-out intents right + // against the classifier's 50.0%, at about 1s a turn instead of 30ms (see + // config.VoiceConfig.LLMRouter). The classifier always stays wired as the + // fallback, so a model error never breaks a turn. + rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)) // ----- sessions registry (shared with voicesink) ----- sessions := voice.NewSessions() @@ -225,12 +228,25 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem } // ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) ----- - dialogueSessions := dialogue.NewSessionStore(2 * time.Minute) + // Store-backed when the daemon passes a store, so a restart mid-conversation + // keeps the thread (Vikunja #363). Sessions past their TTL are dropped on + // load, never revived. Clarify's parked question stays in memory only. + var dialogueSessions *dialogue.SessionStore + if dataStore != nil { + dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore) + if err := dialogueSessions.Load(context.Background(), time.Now()); err != nil { + log.Printf("dialogue: load saved sessions: %v", err) + } + } else { + dialogueSessions = dialogue.NewSessionStore(2 * time.Minute) + } + clarifyStore := dialogue.NewClarifyStore(clarifyTTL) + timeParser := router.NewPythonDateParser() // ----- 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) ----- @@ -250,9 +266,14 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem memStore: memStore, dataStore: dataStore, dialogueSessions: dialogueSessions, - queryMinScore: cfg.Voice.QueryMinScore, - timeParser: router.NewPythonDateParser(), - ecosystem: eco, + clarifyStore: clarifyStore, + // 0 here (unset config) ⇒ the dialogue default. + clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts, + extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}}, + queryMinScore: cfg.Voice.QueryMinScore, + queryMinMargin: cfg.Voice.QueryMinMargin, + timeParser: timeParser, + ecosystem: eco, } // ----- the server (TCP listener) ----- @@ -294,6 +315,9 @@ type reactiveHandler struct { // load-bearing math (same posture as the presence thresholds). Set by // wireVoice from VoiceConfig; default 0.55. queryMinScore float64 + // queryMinMargin — the second half of that gate: how far the top hit must + // beat the runner-up. 0 ⇒ margin off. + queryMinMargin float64 // timeParser — used as a fallback for stage-0 reminder grammar matches // (where the extractor didn't run). Shared with the router's extractor. @@ -304,6 +328,18 @@ type reactiveHandler struct { // box → one session slot, keyed voiceDialogueID). nil ⇒ no carry-over. dialogueSessions *dialogue.SessionStore + // clarifyStore parks the request behind an open question she asked (see + // clarify.go). nil ⇒ she falls back to the canned "не поняла" reply. + clarifyStore *dialogue.ClarifyStore + + // clarifyMaxAttempts — questions per request before she gives up out loud. + // 0 ⇒ dialogue.DefaultMaxAttempts (3). Set from VoiceConfig. + clarifyMaxAttempts int + + // extractor parses the answer to an open question, with the same parsers + // the router's own stage-2 uses. + extractor router.Extractor + // pending destructive-act confirmation. A destructive act replies with a // "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as // the y/n answer. ponytail: single slot, single-user box — a second act @@ -378,12 +414,27 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo return h.reply(ctx, reply, nil) } + // 1b2. expired clarify — a question was parked but its TTL ran out, so the + // request behind it is gone. Say that out loud (see clarify.go) and carry + // on: these words are still routed as a fresh utterance below, with the + // notice glued in front of whatever the fresh routing answers. Checked + // BEFORE the answer path: reading a parked question drops an expired one. + expiredNotice := h.clarifyExpiredNotice() + + // 1b3. clarify answer — if she asked a live question last turn, this + // utterance is its answer, not a fresh command. After the confirm check: a + // y/n gate is armed by her own prompt and is the narrower claim on the + // utterance. + if reply, handled := h.resolveClarifyAnswer(ctx, text); handled { + return h.reply(ctx, reply, nil) + } + // 1c. quiet-hours toggle — keyword match, not classifier-dependent. // "тихий режим" / "quiet on" would route through the classifier // unreliably (it's a command, not a free-form query), so we match it // before routing. Same pattern as the confirm turn above. if reply, handled := h.resolveQuietToggle(ctx, text); handled { - return h.reply(ctx, reply, nil) + return h.reply(ctx, withNotice(expiredNotice, reply), nil) } // 2. router — classify the utterance. @@ -392,10 +443,10 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo // ErrNoIntents ⇒ classifier unseeded (cold boot). reply with a // "still warming up" rather than a wire error. if errors.Is(err, router.ErrNoIntents) { - return h.reply(ctx, "я ещё не понимаю свободную речь — скоро научусь.", nil) + return h.reply(ctx, withNotice(expiredNotice, "я ещё не понимаю свободную речь — скоро научусь."), nil) } log.Printf("voice: router error: %v", err) - return h.reply(ctx, "не получилось разобрать команду.", nil) + return h.reply(ctx, withNotice(expiredNotice, "не получилось разобрать команду."), nil) } // 2b. dialogue — fill this turn's missing slots from a prior same-intent @@ -407,34 +458,16 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo prev := h.dialogueSessions.Get(voiceDialogueID, now) dec = followUpMerge(prev, dec, now) if !dec.Clarify { - // Build history: carry over up to 4 prior turns for cross-intent - // reference. The most recent prior turn is prepended to history. - var history []dialogue.Turn - if prev != nil { - history = append(history, dialogue.Turn{ - Intent: prev.Intent, - Slots: prev.Slots, - Text: prev.Slots.Text, // the prior turn's utterance - }) - // Cap history depth so one long conversation can't grow - // the session unboundedly. - maxHist := len(prev.History) - if maxHist > 3 { - maxHist = 3 - } - history = append(history, prev.History[:maxHist]...) - } - ttl := time.Duration(0) // use default (2 min) - if dec.Intent == router.IntentChat { - ttl = 15 * time.Minute // conversational turns should last longer - } - h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ - Intent: dialogue.Intent(dec.Intent), - Slots: toDialogueSlots(dec.Slots), - Timestamp: now, - TTL: ttl, - History: history, - }) + h.rememberTurn(prev, dec, now) + } + } + + // 2c. clarify — she is not sure. If one named thing is missing, ask about it + // and park the request (clarify.go); otherwise the replier's canned reply + // stands. + if dec.Clarify { + if question, asked := h.askClarify(dec); asked { + return h.reply(ctx, withNotice(expiredNotice, question), nil) } } @@ -450,7 +483,7 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo // 5. tts — synthesise the reply text; return to the voice server which // ships it back on the conn. - return h.reply(ctx, replyText, nil) + return h.reply(ctx, withNotice(expiredNotice, replyText), nil) } // handleText — the core reactive path without stt/tts: confirm check → @@ -465,14 +498,21 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string { return reply } + // 1b2/1b3. expired clarify then clarify answer — same order and reasons as + // HandlePushToTalk. + expiredNotice := h.clarifyExpiredNotice() + if reply, handled := h.resolveClarifyAnswer(ctx, text); handled { + return reply + } + // 2. router — classify the utterance. dec, err := h.router.Route(ctx, text, h.now()) if err != nil { if errors.Is(err, router.ErrNoIntents) { - return "я ещё не понимаю свободную речь — скоро научусь." + return withNotice(expiredNotice, "я ещё не понимаю свободную речь — скоро научусь.") } log.Printf("voice: handleText router error: %v", err) - return "не получилось разобрать команду." + return withNotice(expiredNotice, "не получилось разобрать команду.") } log.Printf("voice: route result: intent=%s slots=%+v", dec.Intent, dec.Slots) @@ -482,30 +522,14 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string { prev := h.dialogueSessions.Get(voiceDialogueID, now) dec = followUpMerge(prev, dec, now) if !dec.Clarify { - var history []dialogue.Turn - if prev != nil { - history = append(history, dialogue.Turn{ - Intent: prev.Intent, - Slots: prev.Slots, - Text: prev.Slots.Text, - }) - maxHist := len(prev.History) - if maxHist > 3 { - maxHist = 3 - } - history = append(history, prev.History[:maxHist]...) - } - ttl := time.Duration(0) - if dec.Intent == router.IntentChat { - ttl = 15 * time.Minute - } - h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{ - Intent: dialogue.Intent(dec.Intent), - Slots: toDialogueSlots(dec.Slots), - Timestamp: now, - TTL: ttl, - History: history, - }) + h.rememberTurn(prev, dec, now) + } + } + + // 2c. clarify — same as HandlePushToTalk: ask about the one missing thing. + if dec.Clarify { + if question, asked := h.askClarify(dec); asked { + return withNotice(expiredNotice, question) } } @@ -517,7 +541,7 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string { if replyText == "" { replyText = h.replier.Reply(dec) } - return replyText + return withNotice(expiredNotice, replyText) } // applyAction — executes the router's Decision. Intent-by-intent: @@ -567,7 +591,7 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) // fail the fact write). Facts aren't in the notes table, so this is the // only recall path for them — "когда я пил воду?" reads back from here. if h.memStore != nil { - if vec, err := h.embedder.Embed(ctx, dec.Utterance); err != nil { + if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil { log.Printf("voice: embed fact for memory: %v", err) } else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{ "source": "voice", @@ -681,7 +705,7 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) // embed the note text with the same model the classifier uses, persist // via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not // facts — no predicate reads it (spec's two-memory split). - vec, err := h.embedder.Embed(ctx, dec.Utterance) + vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance) if err != nil { log.Printf("voice: embed note: %v", err) return "не получилось сохранить заметку." @@ -728,7 +752,9 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) } // Calendar questions: "что у меня сегодня?", "планы на завтра?" - if date, ok := router.ParseCalendarDate(dec.Utterance, time.Now()); ok { + // h.now(), not time.Now(): the handler's clock is the injected one, so + // this arm can be tested at a fixed time like the rest. + if date, ok := router.ParseCalendarDate(dec.Utterance, h.now()); ok { events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) if err != nil { log.Printf("voice: calendar events: %v", err) @@ -758,32 +784,59 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition) } - vec, err := h.embedder.Embed(ctx, dec.Utterance) + vec, err := router.EmbedQuery(ctx, h.embedder, dec.Utterance) if err != nil { log.Printf("voice: embed query: %v", err) return "не получилось найти ответ." } + // Long-term memory first: ONE search over everything Maven remembers + // (notes and facts share this index) and ONE confidence gate, so the + // memory that is clearly the best match answers — a note just as much + // as a fact. + // + // This used to run only after the notes-only gate below had already + // rejected the same note at the same score, which no note could ever + // survive a second time: the branch could only return a fact (#373). + // Order, not the gate, was the bug — the set of questions Maven answers + // is unchanged, only which memory gets to answer them. + if h.memStore != nil { + if hits, herr := h.memStore.Search(ctx, vec, 3); herr == nil { + if hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin); ok { + text := hit.Meta["text"] + // A note is phrased in Maven's voice; a fact is read back + // as it was stored. + if hit.Meta["type"] == "note" { + if reply, perr := h.phraser.PhraseQuery(ctx, dec.Utterance, []string{text}); perr == nil && reply != "" { + return reply + } + } + return text + } + } else { + log.Printf("voice: memory search: %v", herr) + } + } + notes, err := h.api.QueryNotes(ctx, vec, 5) if err != nil { log.Printf("voice: query notes: %v", err) return "не получилось найти ответ." } - // Confidence gate: below threshold, say "I don't know" rather than read - // back the least-unrelated note — a confident wrong recall is worse than - // a gap (spec's "not a guesser-of-truth"). Same instinct as the loop's - // since(key)==null → don't fire. Tuned for the ONNX embedder; the Hash - // floor scores lexically and may rarely clear it. - if len(notes) == 0 || notes[0].Score < h.queryMinScore { - // Long-term memory recall (notes + facts) before general knowledge: - // the notes table can't answer fact questions, but the memory store - // indexes both. Only runs when notes-RAG already gave up → additive. - if h.memStore != nil { - if hits, herr := h.memStore.Search(ctx, vec, 3); herr == nil { - if text, ok := bestRecall(hits, h.queryMinScore); ok { - return text - } - } - } + // Notes-only pass, for notes the vector index above does not hold (an + // older note written before it existed). Same gate, notes-only + // candidates. + // + // Confidence gate: below it, say "I don't know" rather than read back + // the least-unrelated note — a confident wrong recall is worse than a + // gap (spec's "not a guesser-of-truth"). Same instinct as the loop's + // since(key)==null → don't fire. Two parts: an absolute cosine floor, + // and a margin over the runner-up, which is the part that works with + // the e5 embedder's narrow score band. See memory.Confident. + noteScores := make([]float64, len(notes)) + for i, n := range notes { + noteScores[i] = n.Score + } + if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) { // Try general knowledge from the phraser before giving up reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, nil) if err != nil || reply == "" { @@ -885,6 +938,101 @@ var ruMonths = []string{ "июля", "августа", "сентября", "октября", "ноября", "декабря", } +// onlyLocalTimeReply — the honest answer when the user asks the time somewhere +// other than here. She only keeps one clock, and saying so is better than +// naming the wrong city's time. +// +// There used to be a city→time-zone table here. It was removed on purpose: the +// user only ever asks for local time, so the table was a second list of cities +// to keep in step with the weather one for no gain. +const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу." + +// notPlaceAfterV — words that follow "в" without naming a place, so +// mentionsUnknownPlace does not mistake them for a city. +var notPlaceAfterV = map[string]bool{ + "данный": true, "данную": true, "этот": true, "эту": true, + "котором": true, "какое": true, "какой": true, "который": true, + "общем": true, "точности": true, "курсе": true, "сутках": true, + "часах": true, "минутах": true, "секундах": true, "неделе": true, +} + +// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase +// that looks like a place we do not know ("который час в киеве"). Used only to +// pick the honest "local time only" reply instead of answering local time as +// if it were the city's. +func mentionsUnknownPlace(u string) bool { + toks := strings.Fields(u) + for i := 0; i+1 < len(toks); i++ { + if toks[i] != "в" && toks[i] != "во" { + continue + } + next := strings.Trim(toks[i+1], ".,?!") + if next == "" || notPlaceAfterV[next] { + continue + } + // A number after "в" is a clock ("в 5 часов"), not a place. + if _, err := strconv.Atoi(strings.SplitN(next, ":", 2)[0]); err == nil { + continue + } + return true + } + return false +} + +// onlyNearDaysReply — she can work out today, tomorrow, the day after and +// yesterday, and nothing further. Said out loud instead of answering today's +// date for a day she did not understand. +const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу." + +// dayWords — day references the calendar parser cannot resolve. A weekday name +// or a "через …" phrase means he asked about a specific other day. +var dayWords = []string{ + "понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен", + "через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", +} + +// mentionsUnknownDay reports whether the question names a day the calendar +// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to +// pick an honest reply over a confidently wrong one. +// +// Only called after ParseCalendarDate has already failed, so "завтра" and the +// other words it does know never reach here. +func mentionsUnknownDay(u string) bool { + for _, w := range dayWords { + if strings.Contains(u, w) { + return true + } + } + return false +} + +// ruClock renders the clock part of the time reply: "15 часов 4 минуты". +func ruClock(t time.Time) string { + h, m := t.Hour(), t.Minute() + hourWord := ruPlural(h, "час", "часа", "часов") + if m == 0 { + return fmt.Sprintf("%d %s ровно", h, hourWord) + } + return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут")) +} + +// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date +// reply opens the way a person would say it. +func dayPrefix(now, day time.Time) string { + base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + switch int(day.Sub(base).Hours() / 24) { + case -1: + return "вчера" + case 0: + return "сегодня" + case 1: + return "завтра" + case 2: + return "послезавтра" + } + return "это" +} + func ruPlural(n int, one, two, many string) string { n = n % 100 if n > 10 && n < 20 { @@ -963,18 +1111,29 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) switch { case strings.Contains(u, "час") || strings.Contains(u, "врем"): - h := now.Hour() - m := now.Minute() - hourWord := ruPlural(h, "час", "часа", "часов") - if m == 0 { - return fmt.Sprintf("сейчас %d %s ровно", h, hourWord) + // "который час в киеве" — she keeps one clock, so any named place gets + // the honest answer. Never local time dressed up as the city's. + if mentionsUnknownPlace(u) { + return onlyLocalTimeReply } - minWord := ruPlural(m, "минута", "минуты", "минут") - return fmt.Sprintf("сейчас %d %s %d %s", h, hourWord, m, minWord) + return "сейчас " + ruClock(now) case strings.Contains(u, "день") || strings.Contains(u, "числ"): - dow := ruWeekdays[now.Weekday()] - month := ruMonths[now.Month()-1] - return fmt.Sprintf("сегодня %s, %d %s %d года", dow, now.Day(), month, now.Year()) + // "какое число завтра" — answer for the day the user asked about, + // not today. Reuses the router's calendar day-word parser. + day := now + prefix := "сегодня" + if d, ok := router.ParseCalendarDate(u, now); ok { + day = d + prefix = dayPrefix(now, d) + } else if mentionsUnknownDay(u) { + // He named a day she cannot work out ("в пятницу", "через неделю"). + // Answering today's date here would be the same silent wrong answer + // this arm was fixed for, so say what she can do instead. + return onlyNearDaysReply + } + dow := ruWeekdays[day.Weekday()] + month := ruMonths[day.Month()-1] + return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year()) case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"): return "присутствие пока не подключено к голосовому запросу." case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"): @@ -1048,6 +1207,21 @@ func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (v return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audioOut}, nil } +// pickLLMRouter returns the LLM router when the operator asked for it and there +// is a llama-server to talk to, and nil otherwise. nil is safe: the cascade then +// routes with the classifier, so an unusable setting costs accuracy, not turns. +func pickLLMRouter(enabled bool, c *llm.Client) *router.LLMRouter { + if !enabled { + return nil + } + if c == nil { + log.Printf("voice: voice.llm_router is on but there is no llama-server to route with (the phraser is not an LLM phraser) — using the classifier instead") + return nil + } + log.Printf("voice: LLM router enabled") + return router.NewLLMRouter(c) +} + // buildRouter constructs the reactive-path router with the given embedder // and confidence threshold. // - stage-0 grammars from DefaultActMatcher whose fn allowlist is exactly @@ -1414,23 +1588,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri switch classifyConfirm(text) { case confirmYes: h.pendingRoutine = nil - // Create a recurring reminder at the detected interval. - // Weekly patterns get a cron expression; arbitrary intervals - // fire once and the detector re-proposes on the next cycle. - intervalDur := time.Duration(pr.interval * 24 * float64(time.Hour)) - fire := h.now().Add(intervalDur) - cron := "" - if pr.interval >= 6.5 && pr.interval <= 7.5 { - cron = fmt.Sprintf("0 %d * * %d", fire.Hour(), int(fire.Weekday())) - } - payload := fmt.Sprintf(`{"text":"%s %s"}`, pr.action, pr.object) - remID, err := h.api.CreateReminder(ctx, fire, payload, cron) - if err != nil { - log.Printf("voice: create routine reminder: %v", err) - return "не получилось поставить напоминание.", true - } - if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, remID); err != nil { + // Only record the acceptance. The tick loop reads accepted + // routines and nudges on their own interval. Building a reminder + // here made a routine fire exactly once (Vikunja #366). + if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil { log.Printf("voice: accept proposed routine: %v", err) + return "не получилось запомнить рутину.", true } return "буду напоминать.", true case confirmNo: @@ -1713,3 +1876,71 @@ func jsonStringImpl(s string) string { b = append(b, '"') return string(b) } + +// reembedOnStart is the -reembed flag (set in run()). Opt-in on purpose: see +// runReembed. +var reembedOnStart bool + +// checkStoredEmbedder compares the embedder we just loaded with the one that +// wrote the vectors already in the DB (Vikunja #378). +// +// The two models we have both make 384-dim vectors, so a size check catches +// nothing: after a swap, recall silently compares vectors from different +// spaces and the scores are noise. So we say it out loud. Recall itself is not +// changed here — the fix is `mavend -reembed`. +func checkStoredEmbedder(dataStore *store.Store, emb router.Embedder) { + if dataStore == nil { + return + } + current := router.EmbedderID(emb) + if reembedOnStart { + runReembed(dataStore, emb, current) + return + } + stored, mismatch, err := dataStore.CheckEmbedder(context.Background(), current) + if err != nil { + log.Printf("voice: embedder marker check failed: %v", err) + return + } + if mismatch { + log.Printf("voice: WARNING embedder MISMATCH — stored vectors were written by %q but the configured embedder is %q; recall scores are noise until the notes and facts are re-embedded — run `mavend -reembed` once (Vikunja #378)", stored, current) + return + } + log.Printf("voice: embedder marker ok (%s)", current) +} + +// runReembed is the one-shot backfill behind -reembed. +// +// Why a flag and not automatic on mismatch: the embedder is ONNX on the +// laptop's CPU, so a few thousand notes is minutes of work. Doing that silently +// inside a normal start would look like the daemon hanging on boot. So the user +// runs it once, deliberately, after an embedder swap; the mismatch warning +// above tells them to. It re-embeds, logs what it did, and then the daemon +// carries on serving as usual — no separate binary, no second start needed. +func runReembed(dataStore *store.Store, emb router.Embedder, current string) { + log.Printf("voice: re-embedding stored notes and facts with %s — this can take a few minutes, do not interrupt", current) + res, err := dataStore.ReembedAll(context.Background(), current, + // EmbedPassage, not EmbedQuery: these are stored texts being searched + // FOR, which is the side they were written with. + func(ctx context.Context, text string) ([]float32, error) { + return router.EmbedPassage(ctx, emb, text) + }) + if err != nil { + log.Printf("voice: re-embed FAILED, nothing was changed and no marker was written — safe to run again: %v", err) + return + } + if res.Skipped { + log.Printf("voice: re-embed skipped — the stored vectors were already written by %s", current) + return + } + log.Printf("voice: re-embed done — %d notes in the notes table, %d notes and %d facts in the memory index, took %s; stored vectors now belong to %s", + res.Notes, res.MemNotes, res.Facts, res.Took.Round(time.Second), current) + + // A row with no text cannot be re-embedded, so its vector is still the old + // model's noise while the marker now says everything is current. Both write + // paths always store the text, so this should be zero — say it loudly + // rather than bury it in the line above if it ever isn't. + if res.NoText > 0 { + log.Printf("voice: WARNING %d stored rows had no text, so their vectors could not be re-embedded and are still noise; they will never match anything useful (Vikunja #378)", res.NoText) + } +} diff --git a/cmd/mavweb/ecosystem.go b/cmd/mavweb/ecosystem.go index b5b5208..7198853 100644 --- a/cmd/mavweb/ecosystem.go +++ b/cmd/mavweb/ecosystem.go @@ -91,7 +91,10 @@ func handleEcosystem(w http.ResponseWriter, r *http.Request, urls ecoURLs) { var d ecoData var wg sync.WaitGroup wg.Add(3) - go func() { defer wg.Done(); d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows) }() + go func() { + defer wg.Done() + d.Nexus.Err = getEco(ctx, urls.nexus, "/api/v1/entities?limit=50", &d.Nexus.Rows) + }() go func() { defer wg.Done() d.Praxis.Err = getEco(ctx, urls.praxis, "/api/v1/items?limit=50", &d.Praxis.Rows) diff --git a/cmd/mavweb/handlers_test.go b/cmd/mavweb/handlers_test.go index 79cfbc2..f13b26a 100644 --- a/cmd/mavweb/handlers_test.go +++ b/cmd/mavweb/handlers_test.go @@ -918,3 +918,129 @@ func TestHandleTools_ListToolsError_502(t *testing.T) { t.Fatalf("status = %d, want 502; body=%s", rr.Code, rr.Body.String()) } } + +// --- handleRoutines --- + +// routineCore is a fakeCore that also answers the proposed-routine calls. +type routineCore struct { + fakeCore + + routines []ipc.ProposedRoutine + dismissed int64 + acceptedID int64 + remCron string +} + +func (c *routineCore) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) { + return c.routines, nil +} + +func (c *routineCore) DismissProposedRoutine(_ context.Context, id int64) error { + c.dismissed = id + return nil +} + +func (c *routineCore) AcceptProposedRoutine(_ context.Context, id int64) error { + c.acceptedID = id + return nil +} + +func (c *routineCore) CreateReminder(_ context.Context, _ time.Time, _, cron string) (int64, error) { + c.remCron = cron + return 77, nil +} + +func weeklyRoutineCore() *routineCore { + return &routineCore{routines: []ipc.ProposedRoutine{{ + ID: 3, Action: "refill", Object: "cat_water", IntervalDays: 7, + Status: "proposed", CreatedTs: time.Now().Add(-2 * time.Hour).UnixMilli(), + }}} +} + +func postRoutine(action, id string) *http.Request { + return postForm(action, url.Values{"id": {id}}) +} + +func TestHandleRoutines_GET_ShowsMavensPhrase(t *testing.T) { + rr := httptest.NewRecorder() + handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), weeklyRoutineCore(), nil, false) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "заправляешь") { + t.Fatalf("want maven's phrasing in the page, got: %s", body) + } + if !strings.Contains(body, "class=scroll") { + t.Fatal("table must be wrapped in
so it pans on a phone") + } +} + +// Accepting hands the loop a new reason to speak, so it needs step-up. +func TestHandleRoutines_Accept_RequiresStepUp(t *testing.T) { + core := weeklyRoutineCore() + rr := httptest.NewRecorder() + handleRoutines(rr, postRoutine("accept", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rr.Code) + } + if core.acceptedID != 0 { + t.Fatal("accepted without step-up") + } +} + +// Accepting only flips the status. It used to also create a one-shot reminder, +// which is why a non-weekly routine fired once and then went quiet forever +// (Vikunja #366). The tick loop owns the schedule now, so a reminder here would +// be a second, competing schedule. +func TestHandleRoutines_Accept_FlipsStatusAndMakesNoReminder(t *testing.T) { + core := weeklyRoutineCore() + rr := httptest.NewRecorder() + handleRoutines(rr, postRoutine("accept", "3"), core, stepUpSession(), false) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + if core.acceptedID != 3 { + t.Fatalf("accepted id = %d, want 3", core.acceptedID) + } + if core.remCron != "" { + t.Fatalf("accepting must not create a reminder, got cron %q", core.remCron) + } +} + +// Dismiss only ever removes a reason to speak, so it is not step-up gated. +func TestHandleRoutines_Dismiss_NoStepUpNeeded(t *testing.T) { + core := weeklyRoutineCore() + rr := httptest.NewRecorder() + handleRoutines(rr, postRoutine("dismiss", "3"), core, webauthn.NewPasskeySession(5*time.Minute), false) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rr.Code, rr.Body.String()) + } + if core.dismissed != 3 { + t.Fatalf("dismissed = %d, want 3", core.dismissed) + } +} + +func TestHandleRoutines_UnknownAction_400(t *testing.T) { + rr := httptest.NewRecorder() + handleRoutines(rr, postRoutine("frobnicate", "3"), weeklyRoutineCore(), stepUpSession(), false) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rr.Code) + } +} + +func TestHandleRoutines_BadID_400(t *testing.T) { + rr := httptest.NewRecorder() + handleRoutines(rr, postRoutine("dismiss", "nope"), weeklyRoutineCore(), stepUpSession(), false) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rr.Code) + } +} + +func TestHandleRoutines_NilCore_503(t *testing.T) { + rr := httptest.NewRecorder() + handleRoutines(rr, httptest.NewRequest(http.MethodGet, "/routines", nil), nil, nil, false) + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rr.Code) + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index a6ae3a9..0d41585 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -24,6 +24,7 @@ import ( "github.com/coder/websocket" "github.com/kami/maven/internal/audio" "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/voice" "github.com/kami/maven/internal/webauthn" ) @@ -395,9 +396,6 @@ func main() { mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) { handleReminders(w, r, core) }) - mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) { - handleRoutines(w, r, core) - }) mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) { handleMorning(w, r, core) }) @@ -450,6 +448,11 @@ func main() { mux.HandleFunc("/tools", func(w http.ResponseWriter, r *http.Request) { handleTools(w, r, core, stepUpSession, *requireStepUp) }) + // /routines — the authed accept surface. Registered here, next to /tools, + // because accepting shares the same step-up gate. + mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) { + handleRoutines(w, r, core, stepUpSession, *requireStepUp) + }) // /api/revert voids the latest fact for a key — a store mutation, so it // sits behind the same passkey step-up as tool enable (nil session ⇒ @@ -680,22 +683,24 @@ const toolsHTML = `{{template "shellTop" "tools"}} {{template "shellBottom"}}` -// routinesHTML — proposed routine review surface. Lists detected patterns -// awaiting human confirmation, with accept (→ reminder) and dismiss buttons. +// routinesHTML — proposed routine review surface. One row per thing maven +// noticed, in her words, with at most two actions: accept or dismiss. const routinesHTML = `{{template "shellTop" "routines"}}

Routines

{{if .Msg}}
{{.Msg}}
{{end}}
-

proposed {{len .Proposed}}

-{{if .Proposed}}
+

noticed {{len .Proposed}}

+{{if .Proposed}}
actionobjectevery
{{range .Proposed}} - - + + +{{end}}
maven noticedwhen
{{.Action}}{{.Object}}{{.IntervalDays}} days -
+
{{.Phrase}}{{.Noticed}} + + +
-
-
{{else}}
@@ -785,7 +790,23 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { } } -func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { +// routineRow is one line on the page: what maven noticed, in her words, and +// how long ago she noticed it. +type routineRow struct { + ID int64 + Phrase string + Noticed string +} + +// handleRoutines serves the routine review surface (GET) and answers a +// proposal (POST id + action=accept|dismiss). +// +// Accept is gated at step-up, the same tier as enabling a tool: saying yes +// hands the trigger loop a new standing reason to speak to the human, so it +// moves the boundary and only an authed surface may do it. Dismiss is not +// gated — it only ever removes a reason to speak, so the worst a weaker caller +// can do is make maven quieter. +func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) { if core == nil { http.Error(w, "routines disabled (no -core)", http.StatusServiceUnavailable) return @@ -801,6 +822,17 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { return } switch action { + case "accept": + if !stepUpOK(session, requireStepUp) { + http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden) + return + } + if err := acceptRoutine(ctx, core, rid); err != nil { + log.Printf("routines: accept %d: %v", rid, err) + http.Error(w, "accept failed: "+err.Error(), http.StatusBadGateway) + return + } + msg = "accepted routine — maven will remind you" case "dismiss": if err := core.DismissProposedRoutine(ctx, rid); err != nil { log.Printf("routines: dismiss %d: %v", rid, err) @@ -822,12 +854,57 @@ func handleRoutines(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := routinesTmpl.Execute(w, struct { Msg string - Proposed []ipc.ProposedRoutine - }{msg, proposed}); err != nil { + Proposed []routineRow + }{msg, routineRows(proposed)}); err != nil { log.Printf("routines render: %v", err) } } +// routineRows turns the wire rows into display rows. The phrase comes from +// pattern.PhraseRoutine so the page says the same thing maven's voice says. +func routineRows(rs []ipc.ProposedRoutine) []routineRow { + out := make([]routineRow, 0, len(rs)) + for _, r := range rs { + p := pattern.ProposedRoutine{Action: r.Action, Object: r.Object, IntervalDays: r.IntervalDays} + noticed := "just now" + if r.CreatedTs > 0 { + noticed = time.Since(time.UnixMilli(r.CreatedTs)).Round(time.Minute).String() + " ago" + } + out = append(out, routineRow{ID: r.ID, Phrase: pattern.PhraseRoutine(&p), Noticed: noticed}) + } + return out +} + +// acceptRoutine creates the recurring reminder for a proposal, then marks the +// proposal accepted and links the reminder to it. Weekly patterns get a cron +// expression; any other interval fires once. +// +// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go. +// When the tick loop learns to read accepted proposals directly, both callers +// should hand off to one place in core instead of each building a reminder. +func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error { + proposed, err := core.ListProposedRoutines(ctx) + if err != nil { + return err + } + var found *ipc.ProposedRoutine + for i := range proposed { + if proposed[i].ID == id { + found = &proposed[i] + break + } + } + if found == nil { + return errors.New("no such proposed routine") + } + + // No reminder is created here. Accepting only flips the status; the tick + // loop reads accepted routines and nudges on the interval (Vikunja #366). + // The old code made a one-shot reminder, so a non-weekly routine fired + // once and then went quiet forever. + return core.AcceptProposedRoutine(ctx, id) +} + func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) { if core == nil { http.Error(w, "trace disabled (no -core)", http.StatusServiceUnavailable) diff --git a/deploy/mavend.json b/deploy/mavend.json index 3a8c122..962e47d 100644 --- a/deploy/mavend.json +++ b/deploy/mavend.json @@ -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": { @@ -36,10 +37,14 @@ "stt": { "socket": "/run/maven/stt.sock", "lang": "ru" }, "tts": { "socket": "/run/maven/tts.sock", "lang": "ru" }, "embedder": { - "model_path": "/opt/maven/models/embedder/model.onnx", - "tokenizer_path": "/opt/maven/models/embedder/tokenizer.json", + "model_path": "/opt/maven/models/embedder/multilingual-e5-small/model_quantized.onnx", + "tokenizer_path": "/opt/maven/models/embedder/multilingual-e5-small/tokenizer.json", "lib_path": "/opt/maven/lib/libonnxruntime.so" }, + "llm_router": true, + "query_min_score": 0.55, + "query_min_margin": 0.008, + "clarify_max_attempts": 3, "tool_timeout": "30s", "tools": [ { "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false }, diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 45acc73..1e19edc 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -446,6 +446,9 @@ func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) { func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) { return nil, nil } +func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _ int64) error { + return nil +} func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error { return nil } diff --git a/internal/config/config.go b/internal/config/config.go index cca49e0..33f1f26 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -257,18 +257,58 @@ type VoiceConfig struct { // Default 0.35 if unset. RouterThreshold float64 `json:"router_threshold,omitempty"` + // LLMRouter — route with the resident model instead of the embedding + // classifier. On by default since Vikunja #320. + // + // Measured on the held-out fixture (ROUTING-EVAL-31-07-2026.md): 63.2% of + // intents right against the classifier's 50.0%, and no route errors. It + // costs about 1s per turn instead of 30ms. + // + // It is safe to leave on. The model can refuse — it answers "unknown" when + // it cannot route, and the turn drops to the classifier and its clarify + // gate. Any LLM error does the same, so a turn never breaks on the model. + // Slot extraction runs on LLM decisions too, so acts get their Fn and + // reminders their Time. + // + // Set it false to go back to the classifier, e.g. on a box with no + // llama-server or when 1s a turn is too slow. + // + // It is a pointer so that "missing from the file" and "explicitly false" + // are different things: missing means on, false means off. Read it with + // UseLLMRouter(), not directly. + LLMRouter *bool `json:"llm_router,omitempty"` + // QueryMinScore — the note-recall confidence gate. Top cosine below this // ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55); // the HashEmbedder floor scores lexically and may never clear it. 0.55 // default if unset. QueryMinScore float64 `json:"query_min_score,omitempty"` + // QueryMinMargin — the second half of the recall gate: the top hit must + // beat the runner-up by more than this. The absolute score above cannot do + // the job on its own, because the e5 embedder puts every cosine in one + // narrow high band, so a made-up question scores as high as a real one. + // The margin asks whether one note is clearly the best instead. + // Negative ⇒ off. 0 ⇒ the default below. + QueryMinMargin float64 `json:"query_min_margin,omitempty"` + + // ClarifyMaxAttempts — how many clarifying questions she may ask about one + // request before she gives up and says she did not understand. Default 3. + ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"` + // Persona — optional prompt prefix that tunes maven's character. Prepended // to every LLM system prompt (nudge phrasing, note queries, general // knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered // 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. @@ -329,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 @@ -390,7 +436,16 @@ const ( DefaultAutotuneInterval = 10 * time.Minute DefaultRouterThreshold = 0.55 DefaultQueryMinScore = 0.55 - DefaultToolTimeout = 30 * time.Second + // Read off the margin sweep in internal/memory/recalleval on the e5 + // embedder: 0.008 answers 68% of real questions (down from 72%) and cuts + // false recall from 5/5 to 1/5. Every larger delta costs real recall + // without removing that last one until 0.020, which drops recall to 44%. + DefaultQueryMinMargin = 0.008 + // DefaultClarifyMaxAttempts — see dialogue.DefaultMaxAttempts. + DefaultClarifyMaxAttempts = 3 + DefaultToolTimeout = 30 * time.Second + // DefaultLLMRouter — route with the resident model unless told otherwise. + DefaultLLMRouter = true DefaultFactEnrichmentInterval = 30 * time.Second ) @@ -472,9 +527,24 @@ func (c *Config) applyDefaults() { if c.Voice.QueryMinScore <= 0 { c.Voice.QueryMinScore = DefaultQueryMinScore } + // Unset ⇒ default. Negative is how you turn the margin off on purpose, + // so it is clamped to 0 rather than replaced by the default. + switch { + case c.Voice.QueryMinMargin == 0: + c.Voice.QueryMinMargin = DefaultQueryMinMargin + case c.Voice.QueryMinMargin < 0: + c.Voice.QueryMinMargin = 0 + } + if c.Voice.ClarifyMaxAttempts <= 0 { + c.Voice.ClarifyMaxAttempts = DefaultClarifyMaxAttempts + } if c.Voice.ToolTimeout <= 0 { c.Voice.ToolTimeout = Duration(DefaultToolTimeout) } + if c.Voice.LLMRouter == nil { + on := DefaultLLMRouter + c.Voice.LLMRouter = &on + } } // routines: default severity to care-class (1) — the safe floor: a @@ -493,6 +563,15 @@ func (c *Config) applyDefaults() { } } +// UseLLMRouter reports whether to route with the resident model. Unset means +// on; only an explicit false in the config turns it off. +func (v *VoiceConfig) UseLLMRouter() bool { + if v == nil || v.LLMRouter == nil { + return DefaultLLMRouter + } + return *v.LLMRouter +} + func (c *Config) validate() error { if c.Phraser != nil { if c.Phraser.ModelPath == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index be31962..dae1b6b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) @@ -171,6 +192,40 @@ func TestWeatherConfigNilOK(t *testing.T) { } } +func TestLLMRouterDefaultsOn(t *testing.T) { + p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`) + c, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !c.Voice.UseLLMRouter() { + t.Error("voice.llm_router absent should mean on") + } +} + +// Missing and explicitly false must not mean the same thing. +func TestLLMRouterExplicitFalseTurnsItOff(t *testing.T) { + p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":false}}`) + c, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if c.Voice.UseLLMRouter() { + t.Error("voice.llm_router false should turn it off") + } +} + +func TestLLMRouterRead(t *testing.T) { + p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":true}}`) + c, err := Load(p) + if err != nil { + t.Fatalf("Load: %v", err) + } + if !c.Voice.UseLLMRouter() { + t.Error("voice.llm_router true was not read") + } +} + func TestDurationRoundTrip(t *testing.T) { d := Duration(15 * time.Minute) b, err := d.MarshalJSON() diff --git a/internal/delivery/dispatcher.go b/internal/delivery/dispatcher.go index 93fc47d..085e362 100644 --- a/internal/delivery/dispatcher.go +++ b/internal/delivery/dispatcher.go @@ -137,9 +137,10 @@ func NewDispatcher(cfg Config) *Dispatcher { // picks for (severity, presence), sends via the matching sink, and records // one nudge row per successful send. returns the dispatches (one per channel). // -// a Drop channel = no send, no record (the nudge was suppressed by routing, -// not by a failure — "a missed water nudge is noise"). a nil sink = channel -// not wired, skip silently. a send error stops the dispatch and returns what +// a Drop channel = no send (the nudge was suppressed by routing, not by a +// failure — "a missed water nudge is noise"), but it does leave a 'dropped' +// outbox row so the suppression is visible. a nil sink = channel not wired, +// skip silently. a send error stops the dispatch and returns what // got through — the daemon decides whether to retry. func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now time.Time) ([]Dispatch, error) { c := pn.Candidate @@ -148,6 +149,16 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim for i := 0; i < len(channels); i++ { ch := channels[i] if ch == ChannelDrop { + // the routing table suppressed this nudge on purpose (a care nudge + // while you're away is noise). that stays — but it must not be + // invisible, or "she dropped it" and "the rule never fired" look + // the same afterwards. no nudges row: that table feeds the + // ignored_rate signal, and a nudge nobody could see must not + // count as ignored. + id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, pn.Summary, now) + d.completeOutbox(ctx, id, store.DeliveryDropped, now) + log.Printf("dispatcher: dropped %s (sev%d, presence=%s) — routing table suppressed it", + c.Rule.Name, c.Severity, c.State.Presence) continue } s := Sendable{ @@ -160,12 +171,20 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim RepeatUntilAck: ch == ChannelTelegram && c.Severity >= loop.Sev4, Ts: now, } + s = minimalForAway(s) sink := d.sinkFor(ch) if sink == nil { continue } attemptID := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, messageForChannel(s), now) - if err := sink.Send(ctx, s); err != nil { + if err := safeSend(ctx, sink, s); err != nil { + if errors.Is(err, ErrSinkPanicked) { + // one broken sink must not eat the other channels for this + // nudge (sev4 present is voice + ntfy). the attempt is closed + // as failed and we move on. + d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now) + continue + } if errors.Is(err, ErrVoiceNoSession) { // voice was assumed reachable (presence=present) but no live // session exists — the presence guess was wrong. reroute through @@ -225,12 +244,17 @@ func (d *Dispatcher) DispatchReminder(ctx context.Context, pr PhrasedReminder, n Summary: pr.Summary, Ts: now, } + s = minimalForAway(s) sink := d.sinkFor(ch) if sink == nil { continue } attemptID := d.beginOutbox(ctx, "reminder", "", rd.Reminder.ID, ch, messageForChannel(s), now) - if err := sink.Send(ctx, s); err != nil { + if err := safeSend(ctx, sink, s); err != nil { + if errors.Is(err, ErrSinkPanicked) { + d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now) + continue + } if errors.Is(err, ErrVoiceNoSession) { // presence guess was wrong — reroute reminder to the away // channel (ntfy). voice is the only present channel, so nothing @@ -315,9 +339,13 @@ func (d *Dispatcher) RepeatUnacked(ctx context.Context, keys []string, now time. RepeatUntilAck: true, Ts: now, } + s = minimalForAway(s) attemptID := d.beginOutbox(ctx, "nudge", key, 0, ChannelTelegram, messageForChannel(s), now) - if err := d.cfg.Telegram.Send(ctx, s); err != nil { + if err := safeSend(ctx, d.cfg.Telegram, s); err != nil { d.completeOutbox(ctx, attemptID, store.DeliveryFailed, now) + if errors.Is(err, ErrSinkPanicked) { + continue + } return out, fmt.Errorf("repeat send telegram %s: %w", key, err) } d.completeOutbox(ctx, attemptID, store.DeliverySent, now) @@ -329,6 +357,24 @@ func (d *Dispatcher) RepeatUnacked(ctx context.Context, keys []string, now time. return out, nil } +// ErrSinkPanicked — a sink panicked mid-send. the send did not happen, so the +// attempt is recorded failed and never silently retried as if it had. +var ErrSinkPanicked = errors.New("delivery: sink panicked mid-send") + +// safeSend calls a sink and turns a panic into an error. without this a +// panicking sink unwinds past completeOutbox and leaves the delivery_attempts +// row pending forever — reconciliation only runs at daemon startup, and core +// is long-lived, so the row would sit there for weeks. +func safeSend(ctx context.Context, sink Sink, s Sendable) (err error) { + defer func() { + if r := recover(); r != nil { + log.Printf("dispatcher: PANIC in %s sink (this is a bug, fix the sink): %v", s.Channel, r) + err = fmt.Errorf("%w: %s: %v", ErrSinkPanicked, s.Channel, r) + } + }() + return sink.Send(ctx, s) +} + func (d *Dispatcher) sinkFor(ch Channel) Sink { switch ch { case ChannelVoice: @@ -342,20 +388,51 @@ func (d *Dispatcher) sinkFor(ch Channel) Sink { } } +// GenericAwayMessage — what an away channel gets when the phraser gave us no +// summary. no gendered forms, so it stays right whoever reads it. +const GenericAwayMessage = "что-то требует внимания" + +// isAway — this channel leaves the box, so it only ever gets a minimal body. +func isAway(ch Channel) bool { + return ch == ChannelNtfy || ch == ChannelTelegram +} + // messageForChannel — away channels get the minimal summary (no shoulder-surf // exfil — "disk low on homesrv," not detail); voice gets the full body (local). -// a missing summary falls back to body — a terse full message is better than -// no message, and the phraser should have produced a summary for away-bound -// severities. this is the "minimal body" rule from the spec, enforced at the -// last mile so a phraser bug can't accidentally exfil via the relay. +// an empty summary must NOT fall back to the body: the resident model is small +// and drops fields often, and the away path crosses the "never phones home" +// boundary. so we send a fixed generic line plus the rule name instead. voice +// is local, so it keeps the full body. func messageForChannel(s Sendable) string { - switch s.Channel { - case ChannelNtfy, ChannelTelegram: - if s.Summary != "" { - return s.Summary - } - return s.Body - default: + if !isAway(s.Channel) { return s.Body } + return AwayMessage(s) +} + +// AwayMessage — the only text an off-box channel may ever carry. Exported so +// the away sinks share this one rule instead of each inventing a fallback: the +// summary if we have one, otherwise a fixed generic line. Never the body. +func AwayMessage(s Sendable) string { + if s.Summary != "" { + return s.Summary + } + if s.RuleName != "" { + return GenericAwayMessage + ": " + s.RuleName + } + return GenericAwayMessage +} + +// minimalForAway — strips detail from a Sendable bound for an away channel +// before any sink sees it. the sinks pick Summary themselves too, but this is +// where the boundary actually is: a sink added later must not be able to leak +// the full body just by reading the wrong field. +func minimalForAway(s Sendable) Sendable { + if !isAway(s.Channel) { + return s + } + msg := messageForChannel(s) + s.Body = msg + s.Summary = msg + return s } diff --git a/internal/delivery/dispatcher_test.go b/internal/delivery/dispatcher_test.go index e60faeb..bf1f776 100644 --- a/internal/delivery/dispatcher_test.go +++ b/internal/delivery/dispatcher_test.go @@ -639,11 +639,11 @@ func TestDispatchRecurringReminderReschedules(t *testing.T) { // ----------------------------- durable outbox -------------------------------- type outboxAttempt struct { - kind, rule string - reminderID int64 - channel, hash string - status string - begunAt, doneAt time.Time + kind, rule string + reminderID int64 + channel, hash string + status string + begunAt, doneAt time.Time } // fakeOutbox — an in-memory Outbox that also lets a test simulate a crash @@ -784,3 +784,217 @@ func TestDispatchNudge_OutboxBeginFailureDoesNotBlockSend(t *testing.T) { t.Fatalf("send should still happen despite outbox begin failure: sends=%v out=%v", voice.sends, out) } } + +// ----------------------- away channels carry no detail ----------------------- + +// panicSink lives in durability_test.go — a second agent wrote the same helper +// for the same #369 case, so this file just uses that one. + +// TestAwaySendsGenericLineWhenSummaryEmpty — #368. The phraser is a small +// model and drops fields often. An empty Summary must NOT put the full body +// on a channel that leaves the box; the away sendable gets a fixed generic +// line plus the rule name instead. +func TestAwaySendsGenericLineWhenSummaryEmpty(t *testing.T) { + ntfy := &fakeSink{} + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{Ntfy: ntfy, Nudges: rec}) + + body := "disk /dev/sda1 at 96%, 2.1G free, largest offender /var/lib/docker" + _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("disk-low", loop.Sev3, store.Away), + Body: body, + Summary: "", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(ntfy.sends) != 1 { + t.Fatalf("want 1 ntfy send, got %d", len(ntfy.sends)) + } + want := GenericAwayMessage + ": disk-low" + got := messageForChannel(ntfy.sends[0]) + if got != want { + t.Fatalf("away message: want %q, got %q", want, got) + } + if ntfy.sends[0].Body == body { + t.Fatal("away sendable still carries the full body") + } + if len(rec.rows) != 1 || rec.rows[0].message != want { + t.Fatalf("recorded message: want %q, got %+v", want, rec.rows) + } +} + +// TestSev4AwaySendableCarriesNoDetail — the rule is enforced at the +// dispatcher, not in each sink. A sink added later must not be able to leak +// the body just by reading the wrong field, so neither field may hold detail. +func TestSev4AwaySendableCarriesNoDetail(t *testing.T) { + tg := &fakeSink{} + d := NewDispatcher(Config{Telegram: tg, Ack: newFakeAck()}) + + body := "backup job failed: rsync exit 23 on /home/kami, see /var/log/backup.log" + _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("backup-failed", loop.Sev4, store.Away), + Body: body, + Summary: "бэкап не прошёл", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(tg.sends) != 1 { + t.Fatalf("want 1 telegram send, got %d", len(tg.sends)) + } + s := tg.sends[0] + if s.Body != "бэкап не прошёл" || s.Summary != "бэкап не прошёл" { + t.Fatalf("away sendable should hold only the summary, got body=%q summary=%q", s.Body, s.Summary) + } +} + +// TestAwayKeepsANonEmptySummary — the normal path is untouched. +func TestAwayKeepsANonEmptySummary(t *testing.T) { + ntfy := &fakeSink{} + d := NewDispatcher(Config{Ntfy: ntfy}) + + _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("cert", loop.Sev3, store.Away), + Body: "cert for maven.local expires in 3 days, issuer letsencrypt", + Summary: "сертификат истекает", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if got := messageForChannel(ntfy.sends[0]); got != "сертификат истекает" { + t.Fatalf("want the summary unchanged, got %q", got) + } +} + +// TestVoiceStillGetsTheFullBody — voice never leaves the box, so it keeps the +// full phrased message even when Summary is empty. +func TestVoiceStillGetsTheFullBody(t *testing.T) { + voice := &fakeSink{} + d := NewDispatcher(Config{Voice: voice}) + + body := "ты не пил воду четыре часа" + _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("water", loop.Sev1, store.Present), + Body: body, + Summary: "", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(voice.sends) != 1 || voice.sends[0].Body != body { + t.Fatalf("voice should get the full body, got %+v", voice.sends) + } + if got := messageForChannel(voice.sends[0]); got != body { + t.Fatalf("voice message: want %q, got %q", body, got) + } +} + +// TestReminderAwaySendsGenericLineWhenSummaryEmpty — the reminder path crosses +// the same boundary and has its own Sendable construction. +func TestReminderAwaySendsGenericLineWhenSummaryEmpty(t *testing.T) { + ntfy := &fakeSink{} + d := NewDispatcher(Config{Ntfy: ntfy}) + + _, err := d.DispatchReminder(context.Background(), PhrasedReminder{ + Decision: loop.ReminderDecision{ + Reminder: store.Reminder{ID: 5}, + State: loop.State{Now: refNow(), Presence: store.Away}, + }, + Body: "позвонить в клинику по поводу анализов", + Summary: "", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(ntfy.sends) != 1 { + t.Fatalf("want 1 ntfy send, got %d", len(ntfy.sends)) + } + if got := messageForChannel(ntfy.sends[0]); got != GenericAwayMessage { + t.Fatalf("away reminder message: want %q, got %q", GenericAwayMessage, got) + } +} + +// --------------------------- a panicking sink ------------------------------- + +// TestPanicMidSendResolvesTheAttempt — #369. A panic used to unwind past +// completeOutbox and leave the row pending forever, because reconciliation +// only runs at daemon startup and core is long-lived. +func TestPanicMidSendResolvesTheAttempt(t *testing.T) { + ob := &fakeOutbox{} + d := NewDispatcher(Config{Voice: &panicSink{}, Outbox: ob}) + + _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("water", loop.Sev1, store.Present), + Body: "body", Summary: "sum", + }, refNow()) + if err != nil { + t.Fatalf("a panicking sink must not fail the dispatch: %v", err) + } + if len(ob.attempts) != 1 { + t.Fatalf("want 1 outbox attempt, got %d", len(ob.attempts)) + } + if ob.attempts[0].status != store.DeliveryFailed { + t.Fatalf("want status failed after a panic, got %q", ob.attempts[0].status) + } +} + +// TestPanicInOneSinkStillDeliversTheOther — sev4 present is voice + ntfy. One +// broken sink must not eat the other channel for the same nudge. +func TestPanicInOneSinkStillDeliversTheOther(t *testing.T) { + bad := &panicSink{} + ntfy := &fakeSink{} + ob := &fakeOutbox{} + d := NewDispatcher(Config{Voice: bad, Ntfy: ntfy, Outbox: ob}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("backup-failed", loop.Sev4, store.Present), + Body: "long detailed body", Summary: "бэкап не прошёл", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if bad.calls != 1 { + t.Fatalf("want the voice sink called once, got %d", bad.calls) + } + if len(ntfy.sends) != 1 { + t.Fatalf("ntfy should still get the nudge, got %d sends", len(ntfy.sends)) + } + if len(out) != 1 || out[0].Sendable.Channel != ChannelNtfy { + t.Fatalf("want only the ntfy dispatch reported, got %+v", out) + } + if len(ob.attempts) != 2 { + t.Fatalf("want 2 outbox attempts, got %d", len(ob.attempts)) + } + if ob.attempts[0].status != store.DeliveryFailed || ob.attempts[1].status != store.DeliverySent { + t.Fatalf("want [failed, sent], got %q %q", ob.attempts[0].status, ob.attempts[1].status) + } +} + +// TestPanicInReminderSinkResolvesTheAttempt — the reminder path has its own +// send call, and a panic there must not leave the reminder marked fired. +func TestPanicInReminderSinkResolvesTheAttempt(t *testing.T) { + ob := &fakeOutbox{} + rc := &fakeReminderCompleter{} + d := NewDispatcher(Config{Voice: &panicSink{}, Reminders: rc, Outbox: ob}) + + out, err := d.DispatchReminder(context.Background(), PhrasedReminder{ + Decision: loop.ReminderDecision{ + Reminder: store.Reminder{ID: 9}, + State: loop.State{Now: refNow(), Presence: store.Present}, + }, + Body: "звонок", Summary: "звонок", + }, refNow()) + if err != nil { + t.Fatalf("a panicking sink must not fail the dispatch: %v", err) + } + if len(out) != 0 { + t.Fatalf("nothing was delivered, want no dispatches, got %+v", out) + } + if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryFailed { + t.Fatalf("want 1 attempt with status failed, got %+v", ob.attempts) + } + if len(rc.marked) != 0 { + t.Fatalf("reminder must stay pending after a panic, got %+v", rc.marked) + } +} diff --git a/internal/delivery/durability_test.go b/internal/delivery/durability_test.go new file mode 100644 index 0000000..4f015e7 --- /dev/null +++ b/internal/delivery/durability_test.go @@ -0,0 +1,230 @@ +package delivery + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" +) + +// panicSink — a sink that dies mid-send. Models the ugly case: the process is +// still alive, so startup reconciliation will not run, but the attempt row was +// already begun. +type panicSink struct{ calls int } + +func (p *panicSink) Send(_ context.Context, _ Sendable) error { + p.calls++ + panic("sink exploded mid-send") +} + +// ------------------------- voice fallthrough, per severity ------------------- + +// TestVoiceNoSessionFallthroughLeavesOutboxTrail — the fallthrough must be +// visible in the ledger too: the voice attempt closes as failed and the away +// attempt is a separate row, so an operator can see the reroute happened. +func TestVoiceNoSessionFallthroughLeavesOutboxTrail(t *testing.T) { + cases := []struct { + name string + sev loop.Severity + wantAt []string // channel per outbox attempt, in order + wantEnd []string // status per attempt, in order + }{ + {"sev3 falls through to ntfy", loop.Sev3, + []string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}}, + {"sev4 falls through to telegram", loop.Sev4, + []string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}}, + // care severities still don't reach an away channel; since #370 the + // drop itself is a visible row instead of nothing. + {"sev1 drops instead of falling through", loop.Sev1, + []string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}}, + {"sev2 drops instead of falling through", loop.Sev2, + []string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + voice := &fakeSink{err: ErrVoiceNoSession} + ntfy, telegram := &fakeSink{}, &fakeSink{} + ob := &fakeOutbox{} + d := NewDispatcher(Config{ + Voice: voice, Ntfy: ntfy, Telegram: telegram, + Ack: newFakeAck(), Outbox: ob, + }) + + if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("some_rule", c.sev, store.Present), + Body: "detail", Summary: "short", + }, refNow()); err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(ob.attempts) != len(c.wantAt) { + t.Fatalf("want %d outbox attempts, got %d (%+v)", len(c.wantAt), len(ob.attempts), ob.attempts) + } + for i, a := range ob.attempts { + if a.channel != c.wantAt[i] || a.status != c.wantEnd[i] { + t.Fatalf("attempt %d: want %s/%s, got %s/%s", i, c.wantAt[i], c.wantEnd[i], a.channel, a.status) + } + } + // care severities must not reach an away channel — that would + // defeat the drop rule. + if c.sev <= loop.Sev2 && (len(ntfy.sends) != 0 || len(telegram.sends) != 0) { + t.Fatalf("care nudge escaped to an away channel: ntfy=%d telegram=%d", + len(ntfy.sends), len(telegram.sends)) + } + }) + } +} + +// ------------------------- crash between Begin and Complete ------------------ + +// openTestStore — a real store on a temp file. The reconciliation promise is a +// SQL promise, so a fake would only test the fake. +func openTestStore(t *testing.T) *store.Store { + t.Helper() + st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "maven.db")) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +// attemptStatus reads one attempt row back. Returns ok=false when the row is +// gone, which would itself be a broken promise (a dropped attempt). +func attemptStatus(t *testing.T, st *store.Store, id int64) (status string, completed bool, ok bool) { + t.Helper() + tx, err := st.DB(context.Background()) + if err != nil { + t.Fatalf("read tx: %v", err) + } + defer func() { _ = tx.Rollback() }() + var completedTS *int64 + err = tx.QueryRowContext(context.Background(), + `SELECT status, completed_ts FROM delivery_attempts WHERE id = ?`, id).Scan(&status, &completedTS) + if err != nil { + return "", false, false + } + return status, completedTS != nil, true +} + +// TestCrashBetweenBeginAndCompleteBecomesUnknown — simulate the crash window: +// Begin lands, the process dies before Complete. Startup reconciliation must +// turn that row into "unknown" — neither resent nor dropped, because Maven +// cannot know whether the message left the box. +func TestCrashBetweenBeginAndCompleteBecomesUnknown(t *testing.T) { + st := openTestStore(t) + ctx := context.Background() + sink := &fakeSink{} + + // the crash: intent recorded, no completion. + id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow()) + if err != nil { + t.Fatalf("begin: %v", err) + } + if s, _, ok := attemptStatus(t, st, id); !ok || s != store.DeliveryPending { + t.Fatalf("before reconcile: want pending, got %q ok=%v", s, ok) + } + + // restart. + n, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(time.Minute)) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if n != 1 { + t.Fatalf("want 1 row reconciled, got %d", n) + } + s, completed, ok := attemptStatus(t, st, id) + if !ok { + t.Fatal("reconciliation dropped the row; the promise is it is never dropped") + } + if s != store.DeliveryUnknown { + t.Fatalf("want status unknown, got %q", s) + } + if !completed { + t.Fatal("reconciled row should carry a completed_ts") + } + // not resent: reconciliation is bookkeeping only, it must never push. + if len(sink.sends) != 0 { + t.Fatalf("reconciliation must not resend, got %d sends", len(sink.sends)) + } + + // idempotent: a second restart must not churn the row again. + n2, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow().Add(2*time.Minute)) + if err != nil { + t.Fatalf("reconcile again: %v", err) + } + if n2 != 0 { + t.Fatalf("second reconcile should find nothing, got %d", n2) + } + if s2, _, _ := attemptStatus(t, st, id); s2 != store.DeliveryUnknown { + t.Fatalf("unknown must stay unknown, got %q", s2) + } +} + +// TestUnknownIsNeverResolvedToSentOrFailed — the "never guess" half of the +// promise: nothing may quietly turn an unknown into a definite outcome. +func TestUnknownIsNeverResolvedToSentOrFailed(t *testing.T) { + st := openTestStore(t) + ctx := context.Background() + + id, err := st.BeginDeliveryAttempt(ctx, "nudge", "disk_low", 0, "telegram", "hash", refNow()) + if err != nil { + t.Fatalf("begin: %v", err) + } + if _, err := st.ReconcileStaleDeliveryAttempts(ctx, refNow()); err != nil { + t.Fatalf("reconcile: %v", err) + } + // a late Complete from the old in-flight send must not win. + if err := st.CompleteDeliveryAttempt(ctx, id, store.DeliverySent, refNow().Add(time.Minute)); err != nil { + t.Fatalf("late complete: %v", err) + } + if s, _, _ := attemptStatus(t, st, id); s != store.DeliveryUnknown { + t.Fatalf("late complete overwrote an unknown outcome: %q", s) + } +} + +// ------------------------- the boring failure modes -------------------------- + +// TestSendTimeoutResolvesTheAttempt — a send that times out is a definite +// failure from Maven's side, so the row must not be left pending. +func TestSendTimeoutResolvesTheAttempt(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // the deadline already blew + ob := &fakeOutbox{} + d := NewDispatcher(Config{Ntfy: &fakeSink{err: context.DeadlineExceeded}, Outbox: ob}) + + if _, err := d.DispatchNudge(ctx, PhrasedNudge{ + Candidate: candidate("cert_expiring", loop.Sev3, store.Away), + Body: "detail", Summary: "short", + }, refNow()); err == nil { + t.Fatal("want a timeout error to propagate") + } + if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryFailed { + t.Fatalf("timed-out send must close the attempt as failed, got %+v", ob.attempts) + } +} + +// TestCompleteFailureLeavesRowPendingForReconciliation — if Complete itself +// fails, the row stays pending on purpose. That is the correct ambiguous state +// and startup reconciliation is what resolves it. +func TestCompleteFailureLeavesRowPendingForReconciliation(t *testing.T) { + ob := &fakeOutbox{completeErr: errors.New("db busy")} + d := NewDispatcher(Config{Ntfy: &fakeSink{}, Outbox: ob}) + + if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("cert_expiring", loop.Sev3, store.Away), + Body: "detail", Summary: "short", + }, refNow()); err != nil { + t.Fatalf("a failed outbox complete must not fail the dispatch: %v", err) + } + if len(ob.attempts) != 1 || ob.attempts[0].status != store.DeliveryPending { + t.Fatalf("want the row left pending, got %+v", ob.attempts) + } +} + +// The panic gap this file used to describe as a skipped test is fixed and +// asserted for real in dispatcher_test.go:TestPanicMidSendResolvesTheAttempt. +// panicSink stays here because both files use it. diff --git a/internal/delivery/ntfysink/ntfysink.go b/internal/delivery/ntfysink/ntfysink.go index dad2f9b..9e7fa3e 100644 --- a/internal/delivery/ntfysink/ntfysink.go +++ b/internal/delivery/ntfysink/ntfysink.go @@ -2,10 +2,10 @@ // // ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard) // nudges when present (alongside voice), and reminders when away. the -// message body is the Sendable's Summary — the minimal-body rule from the +// message body is delivery.AwayMessage — the minimal-body rule from the // spec ("disk low on homesrv," not detail; no shoulder-surf exfil through -// the relay). voice gets Body; away channels get Summary, enforced at the -// sink so a phraser bug can't exfil. +// the relay). the dispatcher already strips detail off away sendables; the +// sink uses the same helper so it can't leak the body on its own either. // // ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes // with a dedicated user (write-only to maven-* topics) — the credential is a @@ -69,18 +69,14 @@ func New(cfg Config) (*Sink, error) { }, nil } -// Send publishes one notification to ntfy. the body is the Sendable's Summary -// (minimal body); Title is "maven" (consistent sender identity on the lock -// screen — the content is in the body). Priority maps from severity/kind so +// Send publishes one notification to ntfy. the body is the minimal away +// message (never the full body); Title is "maven" (consistent sender identity +// on the lock screen — the content is in the body). Priority maps from severity/kind so // the phone client can ring differently for an alarm vs a soft ops nudge. func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error { - body := d.Summary - if body == "" { - body = d.Body // terse full message beats no message - } - if body == "" { - return fmt.Errorf("ntfysink: empty message for %s", d.Channel) - } + // never fall back to d.Body: ntfy leaves the box, so an empty summary gets + // a generic line instead of the full detail. + body := delivery.AwayMessage(d) req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body)) if err != nil { diff --git a/internal/delivery/ntfysink/ntfysink_test.go b/internal/delivery/ntfysink/ntfysink_test.go index d106c42..c4edd1a 100644 --- a/internal/delivery/ntfysink/ntfysink_test.go +++ b/internal/delivery/ntfysink/ntfysink_test.go @@ -147,9 +147,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) { } } -func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { - // a terse full message is better than no message; the phraser should - // produce a summary for away-bound severities, but don't silently drop. +func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) { + // #368: this used to fall back to the full body. ntfy leaves the box, so + // an empty summary gets a fixed generic line plus the rule name instead. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() @@ -160,12 +160,15 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { t.Fatalf("Send: %v", err) } _, _, body, _, _, _ := rs.snapshot() - if body != s.Body { - t.Fatalf("fallback body: want %q, got %q", s.Body, body) + want := delivery.GenericAwayMessage + ": service_down" + if body != want { + t.Fatalf("body: want %q, got %q", want, body) } } -func TestSendRejectsEmptyMessage(t *testing.T) { +func TestSendNeverSendsAnEmptyMessage(t *testing.T) { + // with nothing at all to say we still send the generic line — an away + // channel can never carry detail, but it also never goes out blank. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() @@ -173,9 +176,13 @@ func TestSendRejectsEmptyMessage(t *testing.T) { sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"}) s := nudgeSendable(loop.Sev3, "") s.Body = "" - err := sink.Send(context.Background(), s) - if err == nil { - t.Fatal("want error for empty message") + s.RuleName = "" + if err := sink.Send(context.Background(), s); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _, _ := rs.snapshot() + if body != delivery.GenericAwayMessage { + t.Fatalf("body: want %q, got %q", delivery.GenericAwayMessage, body) } } diff --git a/internal/delivery/routing_table_test.go b/internal/delivery/routing_table_test.go new file mode 100644 index 0000000..a008880 --- /dev/null +++ b/internal/delivery/routing_table_test.go @@ -0,0 +1,226 @@ +package delivery + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" +) + +// This file walks every cell of the DESIGN.md § "Delivery / channel routing" +// table, once as the pure table and once through the dispatcher, so a change +// to either side has to break a named cell. +// +// present away +// sev1-2 (care) voice drop +// sev3 (soft) voice ntfy, once +// sev4 (hard) voice + ntfy telegram, repeat til ack + +type tableCell struct { + name string + sev loop.Severity + presence store.Bucket + want []Channel +} + +func allTableCells() []tableCell { + return []tableCell{ + {"sev1 present", loop.Sev1, store.Present, []Channel{ChannelVoice}}, + {"sev2 present", loop.Sev2, store.Present, []Channel{ChannelVoice}}, + {"sev3 present", loop.Sev3, store.Present, []Channel{ChannelVoice}}, + {"sev4 present", loop.Sev4, store.Present, []Channel{ChannelVoice, ChannelNtfy}}, + {"sev1 away", loop.Sev1, store.Away, []Channel{ChannelDrop}}, + {"sev2 away", loop.Sev2, store.Away, []Channel{ChannelDrop}}, + {"sev3 away", loop.Sev3, store.Away, []Channel{ChannelNtfy}}, + {"sev4 away", loop.Sev4, store.Away, []Channel{ChannelTelegram}}, + } +} + +func sameChannels(got, want []Channel) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + +func TestChannelsForEveryTableCell(t *testing.T) { + for _, c := range allTableCells() { + t.Run(c.name, func(t *testing.T) { + got := ChannelsFor(c.sev, c.presence) + if !sameChannels(got, c.want) { + t.Fatalf("%s: want %v, got %v", c.name, c.want, got) + } + }) + } +} + +// TestDispatchNudgeEveryTableCell — the same eight cells end to end: exactly +// the wanted channels get a send, and every other channel gets none. +func TestDispatchNudgeEveryTableCell(t *testing.T) { + for _, c := range allTableCells() { + t.Run(c.name, func(t *testing.T) { + voice, ntfy, telegram := &fakeSink{}, &fakeSink{}, &fakeSink{} + rec := &fakeNudgeRecorder{} + d := NewDispatcher(Config{ + Voice: voice, Ntfy: ntfy, Telegram: telegram, + Ack: newFakeAck(), Nudges: rec, + }) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("some_rule", c.sev, c.presence), + Body: "full detail body", + Summary: "short form", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + + sent := map[Channel]int{ + ChannelVoice: len(voice.sends), + ChannelNtfy: len(ntfy.sends), + ChannelTelegram: len(telegram.sends), + } + for ch, n := range sent { + want := 0 + for _, w := range c.want { + if w == ch { + want = 1 + } + } + if n != want { + t.Fatalf("%s: channel %s got %d sends, want %d", c.name, ch, n, want) + } + } + + // one dispatch and one nudge row per real (non-drop) channel. + wantDispatches := 0 + for _, w := range c.want { + if w != ChannelDrop { + wantDispatches++ + } + } + if len(out) != wantDispatches { + t.Fatalf("%s: want %d dispatches, got %d", c.name, wantDispatches, len(out)) + } + if len(rec.rows) != wantDispatches { + t.Fatalf("%s: want %d nudge rows, got %d", c.name, wantDispatches, len(rec.rows)) + } + }) + } +} + +// TestSev3AwayIsNtfyExactlyOnce — "ntfy, once": one send, and nothing on the +// sendable asks for a repeat, so the daemon's repeat driver has no reason to +// pick it up. +func TestSev3AwayIsNtfyExactlyOnce(t *testing.T) { + ntfy := &fakeSink{} + ack := newFakeAck() + d := NewDispatcher(Config{Ntfy: ntfy, Telegram: &fakeSink{}, Ack: ack}) + + out, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("cert_expiring", loop.Sev3, store.Away), + Body: "cert detail", Summary: "cert expiring", + }, refNow()) + if err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(ntfy.sends) != 1 { + t.Fatalf("sev3 away: want exactly 1 ntfy send, got %d", len(ntfy.sends)) + } + if out[0].Sendable.RepeatUntilAck { + t.Fatalf("sev3 away must not repeat til ack") + } + if _, ok := ack.lastSent["cert_expiring"]; ok { + t.Fatalf("sev3 away must not enter the ack/repeat tracker") + } +} + +// TestSev4AwayRepeatsUntilAcked — "telegram, repeat til ack": the initial send +// arms the ack clock, the repeat driver re-sends while un-acked, and an ack +// stops it. +func TestSev4AwayRepeatsUntilAcked(t *testing.T) { + telegram := &fakeSink{} + ack := newFakeAck() + d := NewDispatcher(Config{Telegram: telegram, Ack: ack}) + ctx := context.Background() + + if _, err := d.DispatchNudge(ctx, PhrasedNudge{ + Candidate: candidate("disk_low", loop.Sev4, store.Away), + Body: "disk detail", Summary: "disk low on homesrv", + }, refNow()); err != nil { + t.Fatalf("dispatch: %v", err) + } + + // two intervals pass, still un-acked → two more sends. + for i := 1; i <= 2; i++ { + at := refNow().Add(time.Duration(i) * 10 * time.Minute) + if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, at, 5*time.Minute, "disk detail", "disk low on homesrv"); err != nil { + t.Fatalf("repeat %d: %v", i, err) + } + } + if len(telegram.sends) != 3 { + t.Fatalf("want 1 initial + 2 repeats = 3 telegram sends, got %d", len(telegram.sends)) + } + + // acked → no further sends, however long we wait. + _ = ack.MarkAcked(ctx, "disk_low") + if _, err := d.RepeatUnacked(ctx, []string{"disk_low"}, refNow().Add(time.Hour), 5*time.Minute, "b", "s"); err != nil { + t.Fatalf("repeat after ack: %v", err) + } + if len(telegram.sends) != 3 { + t.Fatalf("ack must stop the repeat; got %d sends", len(telegram.sends)) + } +} + +// TestAwayChannelsGetMinimalBody — what leaves the box is the short form, for +// every away cell of the table. messageForChannel is the last-mile choice both +// away sinks make too. +func TestAwayChannelsGetMinimalBody(t *testing.T) { + detail := "disk /mnt/hdd1 on homesrv at 97% — 12GB free, biggest offender /var/lib/docker" + short := "disk low on homesrv" + + for _, ch := range []Channel{ChannelNtfy, ChannelTelegram} { + t.Run(string(ch), func(t *testing.T) { + msg := messageForChannel(Sendable{Channel: ch, Body: detail, Summary: short}) + if msg != short { + t.Fatalf("%s message: want %q, got %q", ch, short, msg) + } + }) + } + if got := messageForChannel(Sendable{Channel: ChannelVoice, Body: detail, Summary: short}); got != detail { + t.Fatalf("voice is local and gets the full body, got %q", got) + } +} + +// Both away-detail gaps this file used to describe as skipped tests are now +// fixed and asserted for real in dispatcher_test.go: +// TestSev4AwaySendableCarriesNoDetail and TestAwaySendsGenericLineWhenSummaryEmpty. +// An empty Summary no longer means "send the whole body" — it means a short +// generic line — so the old expectation here was wrong as well as duplicated. + +// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water +// nudge is noise, a missed backup failure isn't"), so it should be visible +// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox +// attempt, no log — nothing an operator can see afterwards. now it leaves a +// 'dropped' outbox row. +func TestCareAwayDropIsRecorded(t *testing.T) { + ob := &fakeOutbox{} + d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob}) + + if _, err := d.DispatchNudge(context.Background(), PhrasedNudge{ + Candidate: candidate("water", loop.Sev1, store.Away), + Body: "drink water", Summary: "water", + }, refNow()); err != nil { + t.Fatalf("dispatch: %v", err) + } + if len(ob.attempts) != 1 || ob.attempts[0].channel != string(ChannelDrop) { + t.Fatalf("care-away drop should leave a visible record, got %+v", ob.attempts) + } +} diff --git a/internal/delivery/telegramsink/telegramsink.go b/internal/delivery/telegramsink/telegramsink.go index 6358992..f6db2d2 100644 --- a/internal/delivery/telegramsink/telegramsink.go +++ b/internal/delivery/telegramsink/telegramsink.go @@ -2,11 +2,12 @@ // // telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm // at 2am routes to telegram, repeat til ack." the message body is the -// Sendable's Summary — the minimal-body rule from the spec ("disk low on -// homesrv," not detail; no shoulder-surf exfil through the relay). voice gets -// Body; away channels get Summary, enforced at the sink so a phraser bug can't -// exfil. additionally, protect_content=true is passed on every send so the -// message can't be forwarded out of the chat — locks the minimal body further. +// delivery.AwayMessage — the minimal-body rule from the spec ("disk low on +// homesrv," not detail; no shoulder-surf exfil through the relay). the +// dispatcher already strips detail off away sendables; the sink uses the same +// helper so it can't leak the body on its own either. additionally, +// protect_content=true is passed on every send so the message can't be +// forwarded out of the chat — locks the minimal body further. // // telegram's bot API is region-restricted for this homesrv — direct egress to // api.telegram.org is unreliable. the spec's "away channels leave the box — @@ -140,18 +141,13 @@ type telegramResp struct { } // Send publishes one message to the configured telegram chat. the body is the -// Sendable's Summary (minimal body); empty Summary falls back to Body (terse -// full message beats no message). protect_content=true so a phraser bug (Body -// leaking detail through Summary) can't be forwarded onward by the user or a -// chat observer — locks the minimal-body rule at the channel's own last mile. +// minimal away message (never the full body). protect_content=true so even +// that can't be forwarded onward by the user or a chat observer — locks the +// minimal-body rule at the channel's own last mile. func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error { - body := d.Summary - if body == "" { - body = d.Body - } - if body == "" { - return fmt.Errorf("telegramsink: empty message for %s", d.Channel) - } + // never fall back to d.Body: telegram leaves the box, so an empty summary + // gets a generic line instead of the full detail. + body := delivery.AwayMessage(d) payload := sendMessageReq{ ChatID: s.cfg.ChatID, diff --git a/internal/delivery/telegramsink/telegramsink_test.go b/internal/delivery/telegramsink/telegramsink_test.go index 5c378f4..60b66ff 100644 --- a/internal/delivery/telegramsink/telegramsink_test.go +++ b/internal/delivery/telegramsink/telegramsink_test.go @@ -173,9 +173,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) { } } -func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { - // terse full message beats none; the phraser should produce a summary for - // away-bound severities, but don't silently drop. +func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) { + // #368: this used to fall back to the full body. telegram leaves the box, + // so an empty summary gets a fixed generic line plus the rule name. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() @@ -188,12 +188,14 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) { _, _, body, _, _ := rs.snapshot() var req sendMessageReq _ = json.Unmarshal([]byte(body), &req) - if req.Text != s.Body { - t.Fatalf("fallback text: want %q, got %q", s.Body, req.Text) + want := delivery.GenericAwayMessage + ": service_down" + if req.Text != want { + t.Fatalf("text: want %q, got %q", want, req.Text) } } -func TestSendRejectsEmptyMessage(t *testing.T) { +func TestSendNeverSendsAnEmptyMessage(t *testing.T) { + // with nothing at all to say we still send the generic line. rs := newRecordingServer(t, 200, "") srv := httptest.NewServer(rs.handler()) defer srv.Close() @@ -201,9 +203,15 @@ func TestSendRejectsEmptyMessage(t *testing.T) { sink, _ := New(sinkCfg(srv.URL)) s := nudgeSendable(loop.Sev4, "") s.Body = "" - err := sink.Send(context.Background(), s) - if err == nil { - t.Fatal("want error for empty message") + s.RuleName = "" + if err := sink.Send(context.Background(), s); err != nil { + t.Fatalf("Send: %v", err) + } + _, _, body, _, _ := rs.snapshot() + var req sendMessageReq + _ = json.Unmarshal([]byte(body), &req) + if req.Text != delivery.GenericAwayMessage { + t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text) } } diff --git a/internal/dialogue/clarify.go b/internal/dialogue/clarify.go new file mode 100644 index 0000000..e3a49c3 --- /dev/null +++ b/internal/dialogue/clarify.go @@ -0,0 +1,193 @@ +package dialogue + +import ( + "sync" + "time" +) + +// Slot names one field of Slots. Named type, not a free string, so a missing +// slot cannot be misspelled — the question phrasing switches on these. +type Slot string + +const ( + SlotTime Slot = "time" // Slots.Time / HasTime + SlotKey Slot = "key" // Slots.Key / HasKey + SlotValue Slot = "value" // Slots.Value (paired with Key) + SlotFn Slot = "fn" // Slots.Fn / HasFn + SlotText Slot = "text" // Slots.Text +) + +// DefaultMaxAttempts — how many questions she may ask about one request. +// Three, because after three tries the likely problem is that she misheard the +// whole request, not one slot — so another question about that slot won't help. +// Configurable: voice.clarify_max_attempts. +const DefaultMaxAttempts = 3 + +// PendingQuestion is what Maven holds while she waits for an answer to an open +// question. Unlike the yes/no confirms in cmd/mavend/voice.go, the answer here +// is free text that fills a missing slot rather than a verdict. +type PendingQuestion struct { + Intent Intent // what the router already guessed + Slots Slots // what it already filled + Missing []Slot // what is still empty, in the order to ask about + Utterance string // the user's original raw words + Asked time.Time + TTL time.Duration + Attempts int // questions already asked + // MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts. + MaxAttempts int +} + +// maxAttempts is MaxAttempts with the default filled in. +func (q *PendingQuestion) maxAttempts() int { + if q.MaxAttempts <= 0 { + return DefaultMaxAttempts + } + return q.MaxAttempts +} + +func (q *PendingQuestion) IsExpired(now time.Time) bool { + return now.After(q.Asked.Add(q.TTL)) +} + +// CanAsk reports whether Maven may ask another question about this request. +func (q *PendingQuestion) CanAsk() bool { + return q.Attempts < q.maxAttempts() +} + +// ClarifyStore holds the parked questions. Same shape and locking as +// SessionStore: keyed by dialogue id, expired entries dropped on read. +type ClarifyStore struct { + mu sync.RWMutex + questions map[string]*PendingQuestion + defaultTTL time.Duration +} + +func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore { + if defaultTTL <= 0 { + // Short, like confirmTTL in voice.go: a clarifying question is a + // same-breath gesture, a stale one should not eat a later utterance. + defaultTTL = 90 * time.Second + } + return &ClarifyStore{ + questions: make(map[string]*PendingQuestion), + defaultTTL: defaultTTL, + } +} + +// Put parks a question. Called on a clarify decision (cmd/mavend/clarify.go). +func (s *ClarifyStore) Put(id string, q *PendingQuestion) { + if q.TTL <= 0 { + q.TTL = s.defaultTTL + } + s.mu.Lock() + s.questions[id] = q + s.mu.Unlock() +} + +// Get returns the live parked question, or nil when there is none. +func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion { + s.mu.RLock() + q, ok := s.questions[id] + s.mu.RUnlock() + if !ok { + return nil + } + if q.IsExpired(now) { + s.Delete(id) + return nil + } + return q +} + +// TakeExpired reports whether a question was parked here but its TTL ran out, +// and drops it. Get drops such a question silently, which leaves the user +// thinking his request is still alive — the caller uses this to tell him it is +// gone before treating his words as a fresh utterance. +func (s *ClarifyStore) TakeExpired(id string, now time.Time) bool { + s.mu.Lock() + defer s.mu.Unlock() + q, ok := s.questions[id] + if !ok || !q.IsExpired(now) { + return false + } + delete(s.questions, id) + return true +} + +func (s *ClarifyStore) Delete(id string) { + s.mu.Lock() + delete(s.questions, id) + s.mu.Unlock() +} + +// Answer merges the slots parsed from the user's answer into the parked ones. +// Only the slots listed in Missing are touched. Within those, a value the answer +// carries WINS over what was parked: she asked about this slot, so «нет, в пять» +// after «в три» must replace the time, not be thrown away. +// +// This is the clarify answer only. A correction in a fresh turn ("вообще-то +// перенеси на пять") is a different code path (followUpMerge) — not here. +// +// Parsing the answer text into `answer` is the caller's job; this package must +// stay free of internal/router. +func (q *PendingQuestion) Answer(text string, answer Slots) Slots { + out := q.Slots + for _, slot := range q.Missing { + switch slot { + case SlotTime: + if answer.HasTime { + out.Time = answer.Time + out.HasTime = true + } + case SlotKey: + if answer.HasKey { + out.Key = answer.Key + out.HasKey = true + } + case SlotValue: + if answer.Value != "" { + out.Value = answer.Value + } + case SlotFn: + if answer.HasFn { + out.Fn = answer.Fn + out.HasFn = true + out.Args = append([]string(nil), answer.Args...) + } + case SlotText: + if answer.Text != "" { + out.Text = answer.Text + } else if out.Text == "" { + // No parse for a text slot — the raw answer IS the text. + out.Text = text + } + } + } + return out +} + +// StillMissing lists the slots that are empty in s, out of the ones asked for. +// The caller uses it to decide between acting and dropping the request. +func StillMissing(want []Slot, s Slots) []Slot { + var out []Slot + for _, slot := range want { + empty := false + switch slot { + case SlotTime: + empty = !s.HasTime + case SlotKey: + empty = !s.HasKey + case SlotValue: + empty = s.Value == "" + case SlotFn: + empty = !s.HasFn + case SlotText: + empty = s.Text == "" + } + if empty { + out = append(out, slot) + } + } + return out +} diff --git a/internal/dialogue/clarify_test.go b/internal/dialogue/clarify_test.go new file mode 100644 index 0000000..513e2e6 --- /dev/null +++ b/internal/dialogue/clarify_test.go @@ -0,0 +1,247 @@ +package dialogue + +import ( + "reflect" + "testing" + "time" +) + +var base = time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC) + +func TestPendingQuestionIsExpired(t *testing.T) { + cases := []struct { + name string + ttl time.Duration + now time.Time + want bool + }{ + {"fresh", time.Minute, base.Add(10 * time.Second), false}, + {"exactly at ttl", time.Minute, base.Add(time.Minute), false}, + {"past ttl", time.Minute, base.Add(2 * time.Minute), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + q := &PendingQuestion{Asked: base, TTL: tc.ttl} + if got := q.IsExpired(tc.now); got != tc.want { + t.Fatalf("IsExpired = %v, want %v", got, tc.want) + } + }) + } +} + +func TestClarifyStoreGetPutDelete(t *testing.T) { + s := NewClarifyStore(time.Minute) + + if got := s.Get("voice", base); got != nil { + t.Fatalf("empty store returned %+v", got) + } + + q := &PendingQuestion{Intent: IntentReminder, Missing: []Slot{SlotTime}, Asked: base} + s.Put("voice", q) + if q.TTL != time.Minute { + t.Fatalf("Put did not apply the default TTL, got %v", q.TTL) + } + if got := s.Get("voice", base.Add(time.Second)); got != q { + t.Fatalf("Get returned %+v, want the parked question", got) + } + + // Expired questions are dropped on read, not returned. + if got := s.Get("voice", base.Add(2*time.Minute)); got != nil { + t.Fatalf("expired Get returned %+v", got) + } + if got := s.Get("voice", base); got != nil { + t.Fatalf("expired question was not deleted: %+v", got) + } + + s.Put("voice", &PendingQuestion{Asked: base, TTL: time.Hour}) + s.Delete("voice") + if got := s.Get("voice", base); got != nil { + t.Fatalf("Delete left %+v", got) + } +} + +// TestClarifyStoreTakeExpired — TakeExpired reports (and drops) only a question +// whose TTL ran out. +func TestClarifyStoreTakeExpired(t *testing.T) { + s := NewClarifyStore(time.Minute) + if s.TakeExpired("voice", base) { + t.Fatal("nothing parked ⇒ nothing expired") + } + s.Put("voice", &PendingQuestion{Missing: []Slot{SlotTime}, Asked: base, TTL: time.Minute}) + if s.TakeExpired("voice", base.Add(30*time.Second)) { + t.Fatal("a live question must not report as expired") + } + if s.Get("voice", base.Add(30*time.Second)) == nil { + t.Fatal("a live question must survive TakeExpired") + } + if !s.TakeExpired("voice", base.Add(2*time.Minute)) { + t.Fatal("a stale question must report as expired") + } + if s.TakeExpired("voice", base.Add(2*time.Minute)) { + t.Fatal("TakeExpired must drop the question, so the second call is false") + } +} + +func TestNewClarifyStoreDefaultTTL(t *testing.T) { + s := NewClarifyStore(0) + q := &PendingQuestion{Asked: base} + s.Put("voice", q) + if q.TTL != 90*time.Second { + t.Fatalf("TTL = %v, want 90s", q.TTL) + } +} + +func TestAnswerFillsOnlyMissingSlots(t *testing.T) { + answerTime := base.Add(3 * time.Hour) + other := base.Add(9 * time.Hour) + + cases := []struct { + name string + parked Slots + missing []Slot + text string + answer Slots + want Slots + }{ + { + name: "fills the missing time", + parked: Slots{Text: "напомни позвонить"}, + missing: []Slot{SlotTime}, + text: "в три", + answer: Slots{Time: answerTime, HasTime: true}, + want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true}, + }, + { + // He restated it: «нет, в пять». The new value wins. + name: "a restated time overwrites the parked one", + parked: Slots{Time: other, HasTime: true}, + missing: []Slot{SlotTime}, + text: "нет, в три", + answer: Slots{Time: answerTime, HasTime: true}, + want: Slots{Time: answerTime, HasTime: true}, + }, + { + name: "ignores slots that were not missing", + parked: Slots{Key: "water", HasKey: true}, + missing: []Slot{SlotValue}, + text: "два литра", + answer: Slots{Key: "sleep", HasKey: true, Value: "2l"}, + want: Slots{Key: "water", HasKey: true, Value: "2l"}, + }, + { + name: "fills key when empty", + parked: Slots{}, + missing: []Slot{SlotKey, SlotValue}, + text: "воды", + answer: Slots{Key: "water", HasKey: true, Value: `"drank"`}, + want: Slots{Key: "water", HasKey: true, Value: `"drank"`}, + }, + { + name: "fills fn and its args", + parked: Slots{}, + missing: []Slot{SlotFn}, + text: "перезапусти nginx", + answer: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, + want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, + }, + { + name: "a restated fn replaces the fn and its args", + parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true}, + missing: []Slot{SlotFn}, + text: "останови postgres", + answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true}, + want: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true}, + }, + { + name: "raw answer becomes the text when nothing was parsed", + parked: Slots{}, + missing: []Slot{SlotText}, + text: "купить хлеб", + answer: Slots{}, + want: Slots{Text: "купить хлеб"}, + }, + { + name: "parsed text wins over the raw answer", + parked: Slots{}, + missing: []Slot{SlotText}, + text: "запиши купить хлеб", + answer: Slots{Text: "купить хлеб"}, + want: Slots{Text: "купить хлеб"}, + }, + { + name: "empty answer leaves the slot missing", + parked: Slots{Text: "напомни"}, + missing: []Slot{SlotTime}, + text: "не знаю", + answer: Slots{}, + want: Slots{Text: "напомни"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base} + // Whole-struct compare: a new field in Slots is covered for free. + if got := q.Answer(tc.text, tc.answer); !reflect.DeepEqual(got, tc.want) { + t.Fatalf("Answer = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestCanAskAllowsThreeQuestionsByDefault(t *testing.T) { + if DefaultMaxAttempts != 3 { + t.Fatalf("DefaultMaxAttempts = %d, want 3", DefaultMaxAttempts) + } + q := &PendingQuestion{Asked: base} // MaxAttempts unset ⇒ the default + for i := 0; i < 3; i++ { + if !q.CanAsk() { + t.Fatalf("question %d should be allowed", i+1) + } + q.Attempts++ + } + if q.CanAsk() { + t.Fatal("a fourth question must not be allowed") + } +} + +func TestCanAskHonoursConfiguredMax(t *testing.T) { + q := &PendingQuestion{Asked: base, MaxAttempts: 1, Attempts: 1} + if q.CanAsk() { + t.Fatal("MaxAttempts 1 means one question only") + } +} + +func TestStillMissing(t *testing.T) { + want := []Slot{SlotTime, SlotKey, SlotValue, SlotFn, SlotText} + cases := []struct { + name string + slots Slots + want []Slot + }{ + {"all empty", Slots{}, want}, + { + name: "all filled", + slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Value: "1l", Fn: "restart", HasFn: true, Text: "t"}, + want: nil, + }, + { + name: "only value left", + slots: Slots{Time: base, HasTime: true, Key: "water", HasKey: true, Fn: "restart", HasFn: true, Text: "t"}, + want: []Slot{SlotValue}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := StillMissing(want, tc.slots) + if len(got) != len(tc.want) { + t.Fatalf("StillMissing = %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("StillMissing = %v, want %v", got, tc.want) + } + } + }) + } +} diff --git a/internal/dialogue/session.go b/internal/dialogue/session.go index 452d27f..d7203f3 100644 --- a/internal/dialogue/session.go +++ b/internal/dialogue/session.go @@ -1,8 +1,12 @@ package dialogue import ( + "context" + "encoding/json" "sync" "time" + + "github.com/kami/maven/internal/store" ) type Intent string @@ -21,6 +25,7 @@ type Slots struct { Time time.Time HasTime bool Key string + Value string // payload for a fact key, mirrors router.Slots.Value HasKey bool Text string Fn string @@ -49,10 +54,22 @@ func (s *Session) IsExpired(now time.Time) bool { return now.After(s.Timestamp.Add(s.TTL)) } +// SessionPersister — the bit of the store the session needs, as an interface +// so tests can swap it out. Data is an opaque blob: the store never looks +// inside, we encode the session as JSON here. +type SessionPersister interface { + SaveDialogueSession(ctx context.Context, id string, data []byte, ts time.Time, ttl time.Duration) error + DeleteDialogueSession(ctx context.Context, id string) error + LoadDialogueSessions(ctx context.Context, now time.Time) ([]store.DialogueSessionRow, error) +} + +// SessionStore keeps the live sessions in a map (the fast path) and mirrors +// every write to the persister, so a daemon restart can load them back. type SessionStore struct { mu sync.RWMutex sessions map[string]*Session defaultTTL time.Duration + persist SessionPersister // may be nil: memory only (tests, no-store paths) } func NewSessionStore(defaultTTL time.Duration) *SessionStore { @@ -65,6 +82,43 @@ func NewSessionStore(defaultTTL time.Duration) *SessionStore { } } +// NewPersistentSessionStore — same store, but writes also go to the DB. +// Call Load once after this to bring back sessions from a previous run. +func NewPersistentSessionStore(defaultTTL time.Duration, p SessionPersister) *SessionStore { + s := NewSessionStore(defaultTTL) + s.persist = p + return s +} + +// Load — read the saved sessions back into memory. Anything past its TTL is +// dropped (and deleted from the DB by the store), never revived. +func (s *SessionStore) Load(ctx context.Context, now time.Time) error { + if s.persist == nil { + return nil + } + rows, err := s.persist.LoadDialogueSessions(ctx, now) + if err != nil { + return err + } + s.mu.Lock() + defer s.mu.Unlock() + for _, r := range rows { + var sess Session + if err := json.Unmarshal(r.Data, &sess); err != nil { + // A blob we can't read is not worth failing a startup over. + continue + } + if sess.TTL <= 0 { + sess.TTL = r.TTL + } + if sess.IsExpired(now) { + continue + } + s.sessions[r.ID] = &sess + } + return nil +} + func (s *SessionStore) Get(id string, now time.Time) *Session { s.mu.RLock() sess, ok := s.sessions[id] @@ -86,12 +140,33 @@ func (s *SessionStore) Put(id string, sess *Session) { s.mu.Lock() s.sessions[id] = sess s.mu.Unlock() + s.save(id, sess) } func (s *SessionStore) Delete(id string) { s.mu.Lock() delete(s.sessions, id) s.mu.Unlock() + if s.persist != nil { + _ = s.persist.DeleteDialogueSession(context.Background(), id) + } +} + +// save — mirror one session to the DB. Best effort: memory already has it, so +// a write error costs us the restart safety net, not the current turn. +func (s *SessionStore) save(id string, sess *Session) { + if s.persist == nil { + return + } + data, err := json.Marshal(sess) + if err != nil { + return + } + ts := sess.Timestamp + if ts.IsZero() { + ts = time.Now() + } + _ = s.persist.SaveDialogueSession(context.Background(), id, data, ts, sess.TTL) } func InheritSlots(prev, cur Slots) Slots { @@ -104,6 +179,9 @@ func InheritSlots(prev, cur Slots) Slots { out.Key = prev.Key out.HasKey = true } + if out.Value == "" && prev.Value != "" { + out.Value = prev.Value + } if out.Text == "" && prev.Text != "" { out.Text = prev.Text } diff --git a/internal/dialogue/session_persist_test.go b/internal/dialogue/session_persist_test.go new file mode 100644 index 0000000..fc4f308 --- /dev/null +++ b/internal/dialogue/session_persist_test.go @@ -0,0 +1,118 @@ +package dialogue + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +// openStore — a store on disk, so a second handle can reopen the same file. +func openStore(t *testing.T, path string) *store.Store { + t.Helper() + s, err := store.Open(context.Background(), path) + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +// A session written before a restart comes back and still merges a follow-up. +func TestSessionSurvivesRestart(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "maven_test.db") + now := time.Now().UTC().Truncate(time.Millisecond) + + first := openStore(t, path) + before := NewPersistentSessionStore(2*time.Minute, first) + before.Put("voice", &Session{ + Intent: IntentReminder, + Slots: Slots{Text: "полить цветы", Time: now.Add(time.Hour), HasTime: true}, + Timestamp: now, + TTL: 2 * time.Minute, + }) + if err := first.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + // fresh handle, fresh in-memory map — as after a daemon restart + after := openStore(t, path) + reloaded := NewPersistentSessionStore(2*time.Minute, after) + if err := reloaded.Load(ctx, now.Add(10*time.Second)); err != nil { + t.Fatalf("Load: %v", err) + } + sess := reloaded.Get("voice", now.Add(10*time.Second)) + if sess == nil { + t.Fatal("session did not survive the restart") + } + if sess.Intent != IntentReminder { + t.Fatalf("intent = %q, want reminder", sess.Intent) + } + // the follow-up carries no text of its own; it must inherit the old one + merged := InheritSlots(sess.Slots, Slots{Time: now.Add(2 * time.Hour), HasTime: true}) + if merged.Text != "полить цветы" { + t.Fatalf("merged text = %q, want the earlier turn's text", merged.Text) + } + if !merged.Time.Equal(now.Add(2 * time.Hour)) { + t.Fatalf("merged time = %v, want the follow-up's time", merged.Time) + } +} + +// A session past its TTL is dead: a restart must not bring it back. +func TestExpiredSessionNotResurrected(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "maven_test.db") + now := time.Now().UTC().Truncate(time.Millisecond) + + first := openStore(t, path) + before := NewPersistentSessionStore(2*time.Minute, first) + before.Put("voice", &Session{ + Intent: IntentReminder, + Slots: Slots{Text: "полить цветы"}, + Timestamp: now, + TTL: time.Minute, + }) + if err := first.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + after := openStore(t, path) + reloaded := NewPersistentSessionStore(2*time.Minute, after) + later := now.Add(5 * time.Minute) // well past the 1-min TTL + if err := reloaded.Load(ctx, later); err != nil { + t.Fatalf("Load: %v", err) + } + if sess := reloaded.Get("voice", later); sess != nil { + t.Fatalf("expired session came back: %+v", sess) + } + // and it is gone from the DB too, not just from memory + rows, err := after.LoadDialogueSessions(ctx, later) + if err != nil { + t.Fatalf("LoadDialogueSessions: %v", err) + } + if len(rows) != 0 { + t.Fatalf("expired row still in the DB: %+v", rows) + } +} + +// Delete removes the row as well, so an ended conversation stays ended. +func TestDeleteRemovesPersistedSession(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "maven_test.db") + now := time.Now().UTC().Truncate(time.Millisecond) + + s := openStore(t, path) + ss := NewPersistentSessionStore(2*time.Minute, s) + ss.Put("voice", &Session{Intent: IntentChat, Timestamp: now, TTL: time.Minute}) + ss.Delete("voice") + rows, err := s.LoadDialogueSessions(ctx, now) + if err != nil { + t.Fatalf("LoadDialogueSessions: %v", err) + } + if len(rows) != 0 { + t.Fatalf("row survived Delete: %+v", rows) + } +} diff --git a/internal/dialogue/session_test.go b/internal/dialogue/session_test.go index 819efa7..735896d 100644 --- a/internal/dialogue/session_test.go +++ b/internal/dialogue/session_test.go @@ -113,4 +113,14 @@ func TestInheritSlots(t *testing.T) { if inherited6.Text != "какая погода в москве" { t.Error("should inherit text when current is empty") } + + prevValue := Slots{Key: "water", HasKey: true, Value: `"drank"`} + inherited7 := InheritSlots(prevValue, Slots{}) + if inherited7.Value != `"drank"` { + t.Error("should inherit value when current is empty") + } + kept := InheritSlots(prevValue, Slots{Value: "2l"}) + if kept.Value != "2l" { + t.Error("should keep current value") + } } diff --git a/internal/ipc/api.go b/internal/ipc/api.go index 88488a8..6f879b8 100644 --- a/internal/ipc/api.go +++ b/internal/ipc/api.go @@ -237,6 +237,10 @@ type dismissProposedRoutineReq struct { ID int64 `json:"id"` } +type acceptProposedRoutineReq struct { + ID int64 `json:"id"` +} + // CoreAPI — what core exposes to modules. One Go interface, satisfied by: // - the in-process store adapter (server.go storeAPI) — used by the daemon // for modules that live in-process for now (router, delivery) and by tests, @@ -286,6 +290,9 @@ type CoreAPI interface { ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) // DismissProposedRoutine flips a proposed routine to 'dismissed'. DismissProposedRoutine(ctx context.Context, id int64) error + // AcceptProposedRoutine flips a proposed routine to 'accepted'. The tick + // loop takes the schedule from there — no reminder is created (Vikunja #366). + AcceptProposedRoutine(ctx context.Context, id int64) error // TickTrace returns the most recent tick's rule trace. The daemon caches // this after every tick; the store adapter returns an error (trace is not diff --git a/internal/ipc/client.go b/internal/ipc/client.go index 4423484..208c250 100644 --- a/internal/ipc/client.go +++ b/internal/ipc/client.go @@ -430,6 +430,10 @@ func (c *Client) DismissProposedRoutine(ctx context.Context, id int64) error { return c.call(ctx, MethodDismissProposedRoutine, dismissProposedRoutineReq{ID: id}, nil) } +func (c *Client) AcceptProposedRoutine(ctx context.Context, id int64) error { + return c.call(ctx, MethodAcceptProposedRoutine, acceptProposedRoutineReq{ID: id}, nil) +} + func (c *Client) Chat(ctx context.Context, text string) (string, error) { var r chatResp if err := c.call(ctx, MethodChat, chatReq{Text: text}, &r); err != nil { diff --git a/internal/ipc/ipc_test.go b/internal/ipc/ipc_test.go index e793ecf..727b8ac 100644 --- a/internal/ipc/ipc_test.go +++ b/internal/ipc/ipc_test.go @@ -478,6 +478,9 @@ func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error { func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { return nil, ErrUnknownMethod } +func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id int64) error { + return nil +} func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return ErrUnknownMethod } diff --git a/internal/ipc/server.go b/internal/ipc/server.go index 775d06b..b316c9b 100644 --- a/internal/ipc/server.go +++ b/internal/ipc/server.go @@ -253,6 +253,10 @@ func (a *storeAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return mapErr(a.s.DismissProposedRoutine(ctx, id)) } +func (a *storeAPI) AcceptProposedRoutine(ctx context.Context, id int64) error { + return mapErr(a.s.AcceptProposedRoutine(ctx, id, time.Now().UTC())) +} + func toTool(t store.Tool) Tool { return Tool{ Name: t.Name, Scope: t.Scope, Cmd: t.Cmd, Destructive: t.Destructive, @@ -774,6 +778,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er } return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID) + case MethodAcceptProposedRoutine: + var p acceptProposedRoutineReq + if err := unmarshalParams(req.Params, &p); err != nil { + return nil, err + } + return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID) + case MethodRevertFact: var p struct { Key string `json:"key"` diff --git a/internal/ipc/wire.go b/internal/ipc/wire.go index 695dda4..c67cec1 100644 --- a/internal/ipc/wire.go +++ b/internal/ipc/wire.go @@ -13,38 +13,39 @@ import ( type Method string const ( - MethodWriteFact Method = "write_fact" - MethodLatestFact Method = "latest_fact" - MethodLatestFactBySource Method = "latest_fact_by_source" - MethodSince Method = "since" - MethodPresence Method = "presence" - MethodCreateReminder Method = "create_reminder" - MethodMarkReminder Method = "mark_reminder" - MethodListReminders Method = "list_reminders" - MethodRecordNudge Method = "record_nudge" - MethodResolveNudge Method = "resolve_nudge" - MethodRecentOutcomes Method = "recent_outcomes" - MethodRecentFacts Method = "recent_facts" - MethodCalendarEvents Method = "calendar_events" - MethodRecentNudges Method = "recent_nudges" - MethodWriteNote Method = "write_note" - MethodQueryNotes Method = "query_notes" - MethodRecentNotes Method = "recent_notes" - MethodProposeTool Method = "propose_tool" - MethodEnableTool Method = "enable_tool" - MethodDisableTool Method = "disable_tool" - MethodAssertStepUp Method = "assert_stepup" - MethodStoreEncryptionKey Method = "store_encryption_key" - MethodUnlock Method = "unlock" - MethodLookupTool Method = "lookup_tool" + MethodWriteFact Method = "write_fact" + MethodLatestFact Method = "latest_fact" + MethodLatestFactBySource Method = "latest_fact_by_source" + MethodSince Method = "since" + MethodPresence Method = "presence" + MethodCreateReminder Method = "create_reminder" + MethodMarkReminder Method = "mark_reminder" + MethodListReminders Method = "list_reminders" + MethodRecordNudge Method = "record_nudge" + MethodResolveNudge Method = "resolve_nudge" + MethodRecentOutcomes Method = "recent_outcomes" + MethodRecentFacts Method = "recent_facts" + MethodCalendarEvents Method = "calendar_events" + MethodRecentNudges Method = "recent_nudges" + MethodWriteNote Method = "write_note" + MethodQueryNotes Method = "query_notes" + MethodRecentNotes Method = "recent_notes" + MethodProposeTool Method = "propose_tool" + MethodEnableTool Method = "enable_tool" + MethodDisableTool Method = "disable_tool" + MethodAssertStepUp Method = "assert_stepup" + MethodStoreEncryptionKey Method = "store_encryption_key" + MethodUnlock Method = "unlock" + MethodLookupTool Method = "lookup_tool" MethodListTools Method = "list_tools" MethodDeleteTool Method = "delete_tool" MethodListProposedRoutines Method = "list_proposed_routines" MethodDismissProposedRoutine Method = "dismiss_proposed_routine" + MethodAcceptProposedRoutine Method = "accept_proposed_routine" MethodRevertFact Method = "revert_fact" - MethodTickTrace Method = "tick_trace" - MethodMorningStatus Method = "morning_status" - MethodChat Method = "chat" + MethodTickTrace Method = "tick_trace" + MethodMorningStatus Method = "morning_status" + MethodChat Method = "chat" ) // Request — one frame from module to core. Params is the JSON-encoded argument diff --git a/internal/kiwix/client.go b/internal/kiwix/client.go new file mode 100644 index 0000000..d32f264 --- /dev/null +++ b/internal/kiwix/client.go @@ -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 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), " ")) +} diff --git a/internal/kiwix/kiwix_test.go b/internal/kiwix/kiwix_test.go new file mode 100644 index 0000000..ed20be7 --- /dev/null +++ b/internal/kiwix/kiwix_test.go @@ -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 = ` + + + Search: Rayleigh scattering + 800 + + Rayleigh scattering + /content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering + Rayleigh scattering causes the blue color of the sky & yellow colors near the Sun.[1] + Wikipedia + 2,818 + + + Hyper–Rayleigh scattering + /content/wikipedia_en_all_maxi_2026-02/Hyper%E2%80%93Rayleigh_scattering + ...Rayleigh scattering" is a nonlinear optical counterpart. + Wikipedia + 914 + + +` + +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, "") { + 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") +} diff --git a/internal/kiwix/knowledge_v1.json b/internal/kiwix/knowledge_v1.json new file mode 100644 index 0000000..4d5c2de --- /dev/null +++ b/internal/kiwix/knowledge_v1.json @@ -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"] + } + ] +} diff --git a/internal/kiwix/retrieval_eval.go b/internal/kiwix/retrieval_eval.go new file mode 100644 index 0000000..923dcce --- /dev/null +++ b/internal/kiwix/retrieval_eval.go @@ -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() +} diff --git a/internal/kiwix/rewrite.go b/internal/kiwix/rewrite.go new file mode 100644 index 0000000..408d608 --- /dev/null +++ b/internal/kiwix/rewrite.go @@ -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":""} + +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') +} diff --git a/internal/kiwix/rewrite_eval.go b/internal/kiwix/rewrite_eval.go new file mode 100644 index 0000000..6a74952 --- /dev/null +++ b/internal/kiwix/rewrite_eval.go @@ -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() +} diff --git a/internal/kiwix/rewrite_eval_test.go b/internal/kiwix/rewrite_eval_test.go new file mode 100644 index 0000000..aa4ee2d --- /dev/null +++ b/internal/kiwix/rewrite_eval_test.go @@ -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()) +} diff --git a/internal/kiwix/rewrite_test.go b/internal/kiwix/rewrite_test.go new file mode 100644 index 0000000..6efdae6 --- /dev/null +++ b/internal/kiwix/rewrite_test.go @@ -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) + } +} diff --git a/internal/llm/client_test.go b/internal/llm/client_test.go index 9353442..68f174d 100644 --- a/internal/llm/client_test.go +++ b/internal/llm/client_test.go @@ -19,7 +19,7 @@ func TestComplete(t *testing.T) { t.Errorf("path = %q, want /v1/chat/completions", r.URL.Path) } var reqBody struct { - Messages []struct { + Messages []struct { Role string `json:"role"` Content string `json:"content"` } `json:"messages"` diff --git a/internal/llm/modelid.go b/internal/llm/modelid.go new file mode 100644 index 0000000..36b90da --- /dev/null +++ b/internal/llm/modelid.go @@ -0,0 +1,70 @@ +package llm + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// UnknownModel is the label to print when the server would not say what it has +// loaded. Deliberately ugly: an honest "unknown" is fine, a plausible-looking +// but wrong model name is the bug this whole file exists to prevent. +const UnknownModel = "unknown-model" + +// llama-server is local, so never send this through a proxy: this box's +// http_proxy answers 503 for loopback, which would look like "server won't say +// which model it has" when the server is right there and fine. +// A Transport with no Proxy set bypasses http_proxy entirely. +var modelHTTP = &http.Client{Timeout: 10 * time.Second, Transport: &http.Transport{}} + +// ModelID asks llama-server which model it has loaded, so a scoring run can +// label itself. Without this a bake-off between two models produces two tables +// that look identical, and the operator has to remember which server was up. +// +// Read from the server rather than passed in on purpose: a hand-typed label +// goes stale the moment someone restarts the server with a different -m. +func ModelID(ctx context.Context, base string) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", strings.TrimSuffix(base, "/")+"/v1/models", nil) + if err != nil { + return "", err + } + resp, err := modelHTTP.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("models: status %d", resp.StatusCode) + } + var out struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + if len(out.Data) == 0 { + return "", fmt.Errorf("models: empty list") + } + short := shortModelID(out.Data[0].ID) + if short == "" { + // Server answered but the id field was missing or blank. Say so + // instead of handing back an empty label that reads as a real name. + return "", fmt.Errorf("models: no id in response") + } + return short, nil +} + +// shortModelID trims the path and the .gguf suffix — llama-server reports the +// file name it was started with, which is too long for a table header. +func shortModelID(id string) string { + id = strings.TrimSpace(id) + if i := strings.LastIndexAny(id, "/\\"); i >= 0 { + id = id[i+1:] + } + return strings.TrimSpace(strings.TrimSuffix(id, ".gguf")) +} diff --git a/internal/llm/modelid_test.go b/internal/llm/modelid_test.go new file mode 100644 index 0000000..c5ce1df --- /dev/null +++ b/internal/llm/modelid_test.go @@ -0,0 +1,64 @@ +package llm + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// The point of these tests: a wrong-but-plausible model label is the bug, so +// every path that cannot learn the real name must return an error instead of a +// guess. No llama-server needed — a stub server stands in. +func TestModelID(t *testing.T) { + cases := []struct { + name string + body string + code int + want string // "" ⇒ expect an error + }{ + {"full path", `{"data":[{"id":"/mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf"}]}`, 200, "Qwen3.5-0.8B.Q4_K_M"}, + {"bare name", `{"data":[{"id":"LFM2.5-1.2B"}]}`, 200, "LFM2.5-1.2B"}, + {"empty list", `{"data":[]}`, 200, ""}, + {"id missing", `{"data":[{}]}`, 200, ""}, + {"id blank", `{"data":[{"id":" "}]}`, 200, ""}, + {"server error", `nope`, 500, ""}, + {"not json", ``, 200, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("asked for %s, want /v1/models", r.URL.Path) + } + w.WriteHeader(c.code) + _, _ = w.Write([]byte(c.body)) + })) + defer srv.Close() + + got, err := ModelID(context.Background(), srv.URL+"/") + if c.want == "" { + if err == nil { + t.Fatalf("want an error, got label %q", got) + } + return + } + if err != nil { + t.Fatalf("ModelID: %v", err) + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestModelIDUnreachable(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + url := srv.URL + srv.Close() // nothing listening now + + if got, err := ModelID(context.Background(), url); err == nil { + t.Fatalf("want an error from a dead server, got label %q", got) + } +} diff --git a/internal/loop/gate_test.go b/internal/loop/gate_test.go new file mode 100644 index 0000000..ed0cb1c --- /dev/null +++ b/internal/loop/gate_test.go @@ -0,0 +1,310 @@ +package loop + +import ( + "context" + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +// Tests for the universal restraint gate. +// +// DESIGN.md § Trigger model: "the gate is universal, applied by the loop, never +// per-rule — quiet-hours, presence, cooldown, snooze, calendar-busy all live in +// one fires()." These tests pin the CONSERVATIVE side of that: the cases where +// Maven must stay quiet. They exist so nobody loosens the gate by accident. +// +// Where the code does not yet do what DESIGN.md promises, the test is written to +// show the gap and then skipped, with the file and line to fix. Behaviour is not +// changed to make a test pass. + +// testRule — a rule at the given severity that always wants to fire, so the +// only thing under test is the gate. +func testRule(name string, sev Severity) Rule { + return Rule{ + Name: name, + Severity: sev, + Cooldown: Cooldown{Base: 30 * time.Minute, Min: time.Minute, Max: time.Hour}, + Predicate: func(State) bool { return true }, + } +} + +// ---------------------------- quiet hours ------------------------------------ + +// Quiet hours silence care and leave ops alone. A failed backup at 2am matters; +// a water nudge at 2am does not. +func TestGateQuietHoursSuppressesCareOnly(t *testing.T) { + cases := []struct { + sev Severity + want bool + }{ + {Sev1, false}, + {Sev2, false}, + {Sev3, true}, + {Sev4, true}, + } + for _, c := range cases { + s := State{Now: refTime(), Presence: store.Present, QuietHours: true} + if got := Gate(s, testRule("r", c.sev)); got != c.want { + t.Errorf("quiet hours sev%d: want fire=%v, got %v", c.sev, c.want, got) + } + } +} + +// ---------------------------- presence --------------------------------------- + +// DESIGN.md § Delivery: "sev <= 2 drops on away, sev >= 3 holds: a missed water +// nudge is noise, a missed backup failure isn't." +func TestGateAwayDropsCareHoldsOps(t *testing.T) { + cases := []struct { + sev Severity + want bool + }{ + {Sev1, false}, + {Sev2, false}, + {Sev3, true}, + {Sev4, true}, + } + for _, c := range cases { + s := State{Now: refTime(), Presence: store.Away} + if got := Gate(s, testRule("r", c.sev)); got != c.want { + t.Errorf("away sev%d: want fire=%v, got %v", c.sev, c.want, got) + } + } +} + +// Care nudges are allowed through when the user is actually there and nothing +// else is suppressing. Without this the "quiet" tests above could pass on a +// gate that simply never fires. +func TestGateAllowsCareWhenPresentAndClear(t *testing.T) { + s := State{Now: refTime(), Presence: store.Present} + if !Gate(s, testRule("r", Sev1)) { + t.Fatal("present and clear: care nudge should be allowed") + } +} + +// ---------------------------- calendar busy ---------------------------------- + +// "Don't nag mid-meeting" is an env predicate in the gate, not the LLM's call. +// Ops still gets through — a service being down mid-meeting is worth the +// interruption. +func TestGateCalendarBusySuppressesCareOnly(t *testing.T) { + care := State{Now: refTime(), Presence: store.Present, CalendarBusy: true} + if Gate(care, testRule("r", Sev2)) { + t.Error("calendar busy: care nudge should be suppressed") + } + if !Gate(care, testRule("r", Sev4)) { + t.Error("calendar busy: ops hard should still fire") + } +} + +// ---------------------------- cooldown --------------------------------------- + +// Cooldown holds for every severity — it is the anti-nag knob, so ops cannot +// buy its way past it either. +func TestGateCooldownHoldsForAllSeverities(t *testing.T) { + now := refTime() + for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} { + s := State{ + Now: now, + Presence: store.Present, + CooldownUntil: map[string]time.Time{"r": now.Add(10 * time.Minute)}, + } + if Gate(s, testRule("r", sev)) { + t.Errorf("cooldown sev%d: should be suppressed", sev) + } + } +} + +// Cooldown is per-rule: one rule cooling down must not mute another. +func TestGateCooldownIsPerRule(t *testing.T) { + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + CooldownUntil: map[string]time.Time{"water": now.Add(10 * time.Minute)}, + } + if Gate(s, testRule("water", Sev1)) { + t.Error("water is cooling down and should be suppressed") + } + if !Gate(s, testRule("meal", Sev1)) { + t.Error("meal has no cooldown and should be allowed") + } +} + +// The moment the cooldown expires the rule is free again — the gate compares +// with Before, so "until" itself is already clear. +func TestGateCooldownExpires(t *testing.T) { + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + CooldownUntil: map[string]time.Time{"r": now}, + } + if !Gate(s, testRule("r", Sev1)) { + t.Fatal("cooldown at exactly now should already be clear") + } +} + +// ---------------------------- snooze ----------------------------------------- + +// Snooze is the user saying "not about this". It beats everything, including +// ops hard. +func TestGateSnoozeHoldsForAllSeverities(t *testing.T) { + now := refTime() + for _, sev := range []Severity{Sev1, Sev2, Sev3, Sev4} { + s := State{ + Now: now, + Presence: store.Present, + SnoozeUntil: map[string]time.Time{"r": now.Add(time.Hour)}, + } + if Gate(s, testRule("r", sev)) { + t.Errorf("snooze sev%d: should be suppressed", sev) + } + } +} + +// ---------------------------- no-data backstop ------------------------------- + +// The gate enforces no-data inertness a second time, for any rule that declared +// the keys it needs. A predicate that forgets the check still cannot fire. +func TestGateNoDataBackstopBeatsAnEagerPredicate(t *testing.T) { + now := refTime() + eager := Rule{ + Name: "eager", + Severity: Sev4, // even ops hard does not get past missing data + Predicate: func(State) bool { return true }, + InertWhenNoData: []string{"water", "meal"}, + } + // one of the two keys present is not enough. + s := State{ + Now: now, + Presence: store.Present, + Facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, time.Hour)}, + } + if Gate(s, eager) { + t.Fatal("a rule missing one of its keys must stay inert") + } +} + +// ---------------------------- one nudge per tick ----------------------------- + +// All five default rules want to fire at once. The tick must still emit exactly +// one candidate, the loudest — never a dogpile. +func TestTickNeverDogpilesAndPicksLoudest(t *testing.T) { + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + Facts: map[string]store.Fact{ + "water": ago("water", "tap:water", `"250ml"`, 5*time.Hour), + "meal": ago("meal", "voice", `"lunch"`, 8*time.Hour), + "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), + "break": ago("break", "voice", `"walk"`, 3*time.Hour), + "service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute), + "netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute), + }, + } + // sanity: every rule really does want to fire, so the pick is a real choice. + for _, r := range DefaultRules() { + if !r.Predicate(s) { + t.Fatalf("setup: rule %q does not want to fire", r.Name) + } + } + got := Tick(s, DefaultRules()) + if got == nil { + t.Fatal("all rules firing: want one candidate, got nil") + } + if got.Rule.Name != "service_down" || got.Severity != Sev4 { + t.Fatalf("want the loudest (service_down/sev4), got %s/sev%d", got.Rule.Name, got.Severity) + } +} + +// Tick returns a single Candidate by type, so "one per tick" cannot be violated +// by count — what can drift is WHICH one. Equal severities tie-break by name so +// the choice is deterministic across ticks. +func TestTickTieBreaksByNameForDeterminism(t *testing.T) { + s := State{Now: refTime(), Presence: store.Present} + rules := []Rule{testRule("zebra", Sev2), testRule("apple", Sev2), testRule("mango", Sev2)} + for i := 0; i < 5; i++ { + got := Tick(s, rules) + if got == nil || got.Rule.Name != "apple" { + t.Fatalf("tie-break: want apple every time, got %+v", got) + } + } +} + +// The loudest candidate wins even when the quiet one is listed first. +func TestTickOrderOfRulesDoesNotMatter(t *testing.T) { + s := State{Now: refTime(), Presence: store.Present} + first := Tick(s, []Rule{testRule("care", Sev1), testRule("ops", Sev4)}) + second := Tick(s, []Rule{testRule("ops", Sev4), testRule("care", Sev1)}) + if first == nil || second == nil { + t.Fatal("want a candidate from both orderings") + } + if first.Rule.Name != "ops" || second.Rule.Name != "ops" { + t.Fatalf("order changed the pick: %s then %s", first.Rule.Name, second.Rule.Name) + } +} + +// ---------------------------- reminders bypass the gate ---------------------- + +// DESIGN.md § User reminders: "bypasses the restraint gate — 'wake me 7' fires +// in quiet hours; that's the point." Every suppressor set at once, and the +// reminder still comes through. +func TestRemindersBypassEverySuppressor(t *testing.T) { + now := refTime() + s := State{ + Now: now, + Presence: store.Away, + QuietHours: true, + CalendarBusy: true, + CooldownUntil: map[string]time.Time{"reminder": now.Add(time.Hour)}, + } + due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}} + got := RemindDecisions(s, due) + if len(got) != 1 || got[0].Reminder.ID != 7 { + t.Fatalf("reminder must bypass the gate, got %+v", got) + } +} + +// GAP — DESIGN.md § User reminders ends "Snooze still applies." RemindDecisions +// passes every due reminder straight through with no snooze check, so a snoozed +// reminder fires anyway. The test below is what the contract asks for. +func TestRemindersStillHonourSnooze(t *testing.T) { + + now := refTime() + s := State{ + Now: now, + Presence: store.Present, + SnoozeUntil: map[string]time.Time{"reminder:7": now.Add(time.Hour)}, + } + due := []store.Reminder{{ID: 7, Payload: `{"text":"wake me"}`}} + if got := RemindDecisions(s, due); len(got) != 0 { + t.Fatalf("snoozed reminder should not be delivered, got %+v", got) + } +} + +// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil +// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the +// unit tests above pass while nothing can ever populate the map. This asserts +// the Gatherer actually produces a snooze map. +func TestGathererPopulatesSnoozeUntil(t *testing.T) { + + ctx := context.Background() + st, err := store.Open(ctx, t.TempDir()+"/m.db") + if err != nil { + t.Fatal(err) + } + defer st.Close() + + g := NewGatherer(st, DefaultRules()) + snap, _, err := g.GatherState(ctx, refTime()) + if err != nil { + t.Fatal(err) + } + if snap.SnoozeUntil == nil { + t.Fatal("Gatherer returned a nil SnoozeUntil map") + } +} diff --git a/internal/loop/gather.go b/internal/loop/gather.go index 405aa0f..c55919f 100644 --- a/internal/loop/gather.go +++ b/internal/loop/gather.go @@ -119,6 +119,14 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto return State{}, nil, err } + // live snoozes — "leave me alone until X", per rule. The `snoozed` outcome + // on the nudges table is the whole record; the store turns it into an + // expiry. Absent rules mean "not snoozed", which is what the gate reads. + snoozeUntil, err := g.store.SnoozedUntil(ctx, now) + if err != nil { + return State{}, nil, err + } + // env flags — QuietHours / CalendarBusy as config facts. // QuietHours: presence != reachability, sleep/quiet-hours handled separately // in the gate. We read a config `quiet_hours` fact for the boolean. @@ -150,7 +158,7 @@ func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []sto PresenceScore: score, Facts: facts, LastNudge: lastNudge, - SnoozeUntil: nil, // no snooze persistence yet — daemon wires in + SnoozeUntil: snoozeUntil, CooldownUntil: cooldownUntil, QuietHours: quiet, CalendarBusy: calBusy, diff --git a/internal/loop/loop.go b/internal/loop/loop.go index bc884fe..ae572d9 100644 --- a/internal/loop/loop.go +++ b/internal/loop/loop.go @@ -1,6 +1,7 @@ package loop import ( + "fmt" "time" "github.com/kami/maven/internal/store" @@ -106,25 +107,53 @@ func Tick(s State, rules []Rule) *Candidate { // ReminderDecision — a due reminder the daemon should deliver now. // NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours; -// that's the point). Snooze still applies — represented by a separate -// snooze-until the gatherer consults; for the scaffold, fired-reminders move -// straight to MarkReminder(fired). +// that's the point). Snooze is the one part of restraint that still applies. type ReminderDecision struct { Reminder store.Reminder State State } -// RemindDecisions — returns all due reminders (without gating their delivery -// by restraint). Pure: accepts an already-filtered (due) list. The Gatherer -// produces that list from `fire_ts <= now AND pending`. +// ReminderSnoozeKey — the SnoozeUntil key that holds back every due reminder. +// Reminders have no rule name, so they share one key. A snooze aimed at a +// single reminder uses ReminderSnoozeKeyFor instead. +const ReminderSnoozeKey = "reminder" + +// ReminderSnoozeKeyFor — the SnoozeUntil key for one reminder by id. +func ReminderSnoozeKeyFor(id int64) string { + return fmt.Sprintf("%s:%d", ReminderSnoozeKey, id) +} + +// RemindDecisions — returns the due reminders the daemon should deliver. +// Pure: accepts an already-filtered (due) list. The Gatherer produces that +// list from `fire_ts <= now AND pending`. +// +// Quiet hours, presence and cooldown are deliberately NOT consulted — a +// reminder must wake you at 7 even in the middle of quiet hours. Only snooze +// holds one back. A held reminder stays pending, so it comes back once the +// snooze runs out. func RemindDecisions(s State, due []store.Reminder) []ReminderDecision { out := make([]ReminderDecision, 0, len(due)) for _, r := range due { + if reminderSnoozed(s, r) { + continue + } out = append(out, ReminderDecision{Reminder: r, State: s}) } return out } +// reminderSnoozed — true when a snooze on this reminder, or on reminders as a +// class, is still running. +func reminderSnoozed(s State, r store.Reminder) bool { + keys := []string{ReminderSnoozeKey, ReminderSnoozeKeyFor(r.ID)} + for _, k := range keys { + if until, ok := s.SnoozeUntil[k]; ok && s.Now.Before(until) { + return true + } + } + return false +} + // CooldownFor — helper for the Gatherer: given the active cooldown base // (the rule's static Base, OR the feedback tuner's persisted tuning) and the // last send ts, compute the wall-clock "cooldown-until" the gate will check. diff --git a/internal/loop/rules_test.go b/internal/loop/rules_test.go new file mode 100644 index 0000000..0da19e2 --- /dev/null +++ b/internal/loop/rules_test.go @@ -0,0 +1,394 @@ +package loop + +import ( + "testing" + "time" + + "github.com/kami/maven/internal/store" +) + +// Direct tests for the five default rule predicates. +// +// A predicate is pure — (State) -> bool, no I/O — so these need no store and no +// daemon. They test the predicate ALONE: the restraint gate is tested in +// loop_test.go and gate_test.go, never here. +// +// Every rule gets the same three questions plus its own edges: +// - does it fire when it should? +// - does it stay quiet when it should? +// - is it silent when the key it needs has no data at all? +// +// The last one is load-bearing. DESIGN.md: "since(key)==null → don't fire. +// Silence on no-data is 'shuts up when uncertain'." + +// stateWith builds a snapshot at refTime() holding just the given facts. +// Presence and the env flags are left zero — the predicate must not read them. +func stateWith(facts map[string]store.Fact) State { + return State{Now: refTime(), Facts: facts} +} + +// ago is a fact for key written `d` before refTime(). +func ago(key, source, value string, d time.Duration) store.Fact { + return factAt(key, source, value, refTime().Add(-d)) +} + +// ---------------------------- since-based care rules ------------------------- + +// The three care rules share one shape: "fire when it has been at least N since +// the last fact for key". One table drives all of them. +func TestCareRulePredicates(t *testing.T) { + cases := []struct { + name string + rule Rule + facts map[string]store.Fact + want bool + }{ + // water — threshold 3h. + { + name: "water fires at 4h", + rule: WaterRule(), + facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 4*time.Hour)}, + want: true, + }, + { + name: "water fires exactly at the 3h threshold", + rule: WaterRule(), + facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour)}, + want: true, + }, + { + name: "water quiet just under 3h", + rule: WaterRule(), + facts: map[string]store.Fact{"water": ago("water", "tap:water", `"250ml"`, 3*time.Hour-time.Minute)}, + want: false, + }, + { + name: "water quiet on no data", + rule: WaterRule(), + facts: nil, + want: false, + }, + { + name: "water quiet on a zero-timestamp fact", + rule: WaterRule(), + facts: map[string]store.Fact{"water": {Key: "water", Source: "tap:water", Value: `"250ml"`}}, + want: false, + }, + { + name: "water quiet when the only fact is for another key", + rule: WaterRule(), + facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 9*time.Hour)}, + want: false, + }, + + // meal — threshold 6h. + { + name: "meal fires at 7h", + rule: MealRule(), + facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 7*time.Hour)}, + want: true, + }, + { + name: "meal fires exactly at the 6h threshold", + rule: MealRule(), + facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour)}, + want: true, + }, + { + name: "meal quiet just under 6h", + rule: MealRule(), + facts: map[string]store.Fact{"meal": ago("meal", "voice", `"lunch"`, 6*time.Hour-time.Minute)}, + want: false, + }, + { + name: "meal quiet on no data", + rule: MealRule(), + facts: nil, + want: false, + }, + + // break — needs BOTH anchors: at the desk now, and no break for 90min. + { + name: "break fires when at desk and no break for 2h", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), + "break": ago("break", "voice", `"walk"`, 2*time.Hour), + }, + want: true, + }, + { + name: "break fires exactly at both thresholds", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 2*time.Minute), + "break": ago("break", "voice", `"walk"`, 90*time.Minute), + }, + want: true, + }, + { + name: "break quiet when the desk signal is stale (user left)", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 10*time.Minute), + "break": ago("break", "voice", `"walk"`, 2*time.Hour), + }, + want: false, + }, + { + name: "break quiet when the last break was recent", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), + "break": ago("break", "voice", `"walk"`, 20*time.Minute), + }, + want: false, + }, + { + name: "break quiet with only the desk anchor", + rule: BreakRule(), + facts: map[string]store.Fact{ + "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), + }, + want: false, + }, + { + name: "break quiet with only the break anchor", + rule: BreakRule(), + facts: map[string]store.Fact{ + "break": ago("break", "voice", `"walk"`, 2*time.Hour), + }, + want: false, + }, + { + name: "break quiet on no data", + rule: BreakRule(), + facts: nil, + want: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.rule.Predicate(stateWith(c.facts)); got != c.want { + t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got) + } + }) + } +} + +// ---------------------------- ops rules -------------------------------------- + +// The two ops rules match on a value AND on which poller wrote it. DESIGN.md: +// "a compromised poller must not be able to forge a trigger." Half of this +// table is forgery attempts; all of them must be refused. +func TestOpsRulePredicates(t *testing.T) { + cases := []struct { + name string + rule Rule + facts map[string]store.Fact + want bool + }{ + // service_down — only poll:uptimekuma may say a service is down. + { + name: "service_down fires on a kuma down fact", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute)}, + want: true, + }, + { + name: "service_down quiet when kuma says up", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `"up"`, time.Minute)}, + want: false, + }, + { + name: "service_down quiet on no data", + rule: ServiceDownRule(), + facts: nil, + want: false, + }, + { + name: "service_down quiet on a zero-timestamp fact", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": {Key: "service_down", Source: "poll:uptimekuma", Value: `"down"`}}, + want: false, + }, + // forgery attempts — right value, wrong writer. + { + name: "service_down refuses a forgery from the netdata poller", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:netdata", `"down"`, time.Minute)}, + want: false, + }, + { + name: "service_down refuses a forgery from ambient audio", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "ambient:other", `"down"`, time.Minute)}, + want: false, + }, + { + name: "service_down refuses a forgery from the user's own voice", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "voice", `"down"`, time.Minute)}, + want: false, + }, + { + name: "service_down refuses a source that only looks like kuma", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma-staging", `"down"`, time.Minute)}, + want: false, + }, + { + name: "service_down refuses an unquoted down value", + rule: ServiceDownRule(), + facts: map[string]store.Fact{"service_down": ago("service_down", "poll:uptimekuma", `down`, time.Minute)}, + want: false, + }, + + // netdata_critical — only poll:netdata may raise a critical alarm. + { + name: "netdata_critical fires on a netdata critical alarm", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"critical"`, time.Minute)}, + want: true, + }, + { + name: "netdata_critical quiet on a warning alarm", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"warning"`, time.Minute)}, + want: false, + }, + { + name: "netdata_critical quiet on a cleared alarm", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:netdata", `"clear"`, time.Minute)}, + want: false, + }, + { + name: "netdata_critical quiet on no data", + rule: NetdataCriticalRule(), + facts: nil, + want: false, + }, + { + name: "netdata_critical quiet on a zero-timestamp fact", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": {Key: "netdata_alarm", Source: "poll:netdata", Value: `"critical"`}}, + want: false, + }, + { + name: "netdata_critical refuses a forgery from the kuma poller", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "poll:uptimekuma", `"critical"`, time.Minute)}, + want: false, + }, + { + name: "netdata_critical refuses a forgery from ambient audio", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_alarm": ago("netdata_alarm", "ambient:other", `"critical"`, time.Minute)}, + want: false, + }, + { + name: "netdata_critical reads netdata_alarm, not netdata_critical", + rule: NetdataCriticalRule(), + facts: map[string]store.Fact{"netdata_critical": ago("netdata_critical", "poll:netdata", `"critical"`, time.Minute)}, + want: false, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := c.rule.Predicate(stateWith(c.facts)); got != c.want { + t.Fatalf("%s predicate: want %v, got %v", c.rule.Name, c.want, got) + } + }) + } +} + +// ---------------------------- rule metadata ---------------------------------- + +// Every default rule must declare the keys it needs. The gate uses that list as +// a second no-data backstop, so a rule that forgets it loses the safety net +// even if its predicate happens to check. +func TestDefaultRulesDeclareInertKeys(t *testing.T) { + for _, r := range DefaultRules() { + if len(r.InertWhenNoData) == 0 { + t.Errorf("rule %q declares no InertWhenNoData keys", r.Name) + } + } +} + +// A no-data snapshot must make EVERY default rule quiet, predicate alone, with +// the gate out of the picture. This is the whole-set version of the per-rule +// no-data cases above. +func TestNoDefaultRuleFiresOnEmptyState(t *testing.T) { + empty := stateWith(nil) + for _, r := range DefaultRules() { + if r.Predicate(empty) { + t.Errorf("rule %q fires on an empty snapshot", r.Name) + } + } +} + +// Severities are the delivery contract (DESIGN.md § Delivery / channel +// routing): care is sev1-2 and drops when away, ops is sev3-4 and holds. Pin +// them so a change to a rule's insistence has to be deliberate. +func TestDefaultRuleSeverities(t *testing.T) { + want := map[string]Severity{ + "water": Sev1, + "meal": Sev1, + "break": Sev2, + "service_down": Sev4, + "netdata_critical": Sev3, + } + got := map[string]Severity{} + for _, r := range DefaultRules() { + got[r.Name] = r.Severity + } + if len(got) != len(want) { + t.Fatalf("rule count changed: want %d, got %d", len(want), len(got)) + } + for name, sev := range want { + if got[name] != sev { + t.Errorf("rule %q severity: want %d, got %d", name, sev, got[name]) + } + } +} + +// Cooldown bounds keep the feedback tuner honest — DESIGN.md wants +// `cooldown in [min,max]` "so a weird week can't mutate Maven silent or +// stalker". A base outside its own envelope would make that meaningless. +func TestDefaultRuleCooldownsAreBounded(t *testing.T) { + for _, r := range DefaultRules() { + c := r.Cooldown + if c.Min <= 0 || c.Base <= 0 || c.Max <= 0 { + t.Errorf("rule %q has a non-positive cooldown: %+v", r.Name, c) + continue + } + if c.Base < c.Min || c.Base > c.Max { + t.Errorf("rule %q base %v outside envelope [%v, %v]", r.Name, c.Base, c.Min, c.Max) + } + } +} + +// A predicate must read only the snapshot it is handed. Same snapshot twice +// (and a snapshot shared between two rules) must give the same answer — no +// hidden state, no clock reads. +func TestPredicatesArePure(t *testing.T) { + s := stateWith(map[string]store.Fact{ + "water": ago("water", "tap:water", `"250ml"`, 4*time.Hour), + "meal": ago("meal", "voice", `"lunch"`, 7*time.Hour), + "desk_active": ago("desk_active", "infer:hyprland", "1", 30*time.Second), + "break": ago("break", "voice", `"walk"`, 2*time.Hour), + "service_down": ago("service_down", "poll:uptimekuma", `"down"`, time.Minute), + }) + for _, r := range DefaultRules() { + first := r.Predicate(s) + for i := 0; i < 3; i++ { + if again := r.Predicate(s); again != first { + t.Fatalf("rule %q predicate is not pure: %v then %v", r.Name, first, again) + } + } + } +} diff --git a/internal/memory/gate.go b/internal/memory/gate.go new file mode 100644 index 0000000..d503d86 --- /dev/null +++ b/internal/memory/gate.go @@ -0,0 +1,38 @@ +package memory + +// Confidence gate for a recall. Two checks, both must pass before Maven says a +// note back: +// +// - minScore — an absolute cosine floor. +// - minMargin — the top hit must beat the runner-up by more than this. +// +// The margin is the one that carries the weight. The e5 embedder packs every +// score into a narrow high band (0.79-0.89 on the recall fixture), so an +// absolute floor cannot tell a real hit from a confident-looking miss: every +// value under the band admits everything, every value above it answers nothing. +// A margin asks a different question — "is this note clearly the best one, or +// is the whole shelf equally close?" — and a made-up question has no clear best. +// +// With one hit and no runner-up there is nothing to compare, so only the floor +// applies. + +// ConfidentScores reports whether the top score clears both gates. scores must +// be sorted highest first. minMargin <= 0 turns the margin check off. +func ConfidentScores(scores []float64, minScore, minMargin float64) bool { + if len(scores) == 0 || scores[0] < minScore { + return false + } + if minMargin > 0 && len(scores) > 1 && scores[0]-scores[1] <= minMargin { + return false + } + return true +} + +// Confident is ConfidentScores for search results. +func Confident(results []Result, minScore, minMargin float64) bool { + scores := make([]float64, len(results)) + for i, r := range results { + scores[i] = r.Score + } + return ConfidentScores(scores, minScore, minMargin) +} diff --git a/internal/memory/gate_test.go b/internal/memory/gate_test.go new file mode 100644 index 0000000..2142cdf --- /dev/null +++ b/internal/memory/gate_test.go @@ -0,0 +1,45 @@ +package memory + +import "testing" + +func TestConfidentScores(t *testing.T) { + cases := []struct { + name string + scores []float64 + minScore float64 + minMargin float64 + want bool + }{ + {"no hits", nil, 0.55, 0.008, false}, + {"below the floor", []float64{0.40, 0.10}, 0.55, 0.008, false}, + {"clear winner", []float64{0.86, 0.70}, 0.55, 0.008, true}, + {"runner-up too close", []float64{0.860, 0.858}, 0.55, 0.008, false}, + // The rule is "beats the runner-up by MORE than delta". Not testing an + // exactly-equal margin: no pair of these decimals subtracts to exactly + // 0.008 in binary float, so such a test would pin rounding, not the rule. + {"margin just under delta", []float64{0.8079, 0.8}, 0.55, 0.008, false}, + {"margin just over delta", []float64{0.8081, 0.8}, 0.55, 0.008, true}, + // One hit: nothing to compare against, so only the floor applies. + {"single hit clears", []float64{0.86}, 0.55, 0.008, true}, + {"single hit below floor", []float64{0.10}, 0.55, 0.008, false}, + // Margin off — the old absolute-only behaviour. + {"margin off admits a tie", []float64{0.86, 0.86}, 0.55, 0, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := ConfidentScores(c.scores, c.minScore, c.minMargin); got != c.want { + t.Errorf("got %v, want %v", got, c.want) + } + }) + } +} + +func TestConfidentReadsResultScores(t *testing.T) { + res := []Result{{ID: "a", Score: 0.86}, {ID: "b", Score: 0.858}} + if Confident(res, 0.55, 0.008) { + t.Error("thin margin passed the gate") + } + if !Confident(res, 0.55, 0) { + t.Error("margin off should fall back to the floor alone") + } +} diff --git a/internal/memory/recalleval/recalleval.go b/internal/memory/recalleval/recalleval.go new file mode 100644 index 0000000..fe310e1 --- /dev/null +++ b/internal/memory/recalleval/recalleval.go @@ -0,0 +1,519 @@ +// Package recalleval is the held-out contract for note recall: can Maven find +// the right note again when the user asks for it weeks later? +// +// Why it sits beside internal/memory rather than inside it: the thing under +// test is a whole path, not one function — an embedder (internal/router), a +// vector store (internal/memory or internal/store) and the confidence gate the +// daemon applies on top (cmd/mavend/recall.go's bestRecall, config's +// query_min_score). A _test.go file inside internal/memory could not reach the +// persistent store without an import cycle, and testdata is not reachable from +// another package's working directory — so the fixture is embedded here and the +// scorer takes the store as a factory. Same layout and same reasons as +// internal/router/eval. +// +// The fixture is HELD OUT the same way the routing fixture is: a query never +// repeats its note's wording verbatim beyond ordinary shared vocabulary, and +// TestFixtureIsParaphrased enforces a floor on how little the two overlap. +// Scoring recall on a query that is a copy of the note measures string +// matching, not recall. +package recalleval + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/router" +) + +//go:embed ru_recall_v1.json +var fixtureJSON []byte + +// SchemaVersion — the version this package understands. The loader refuses any +// other version rather than misreading a fixture and reporting a number. +const SchemaVersion = 1 + +// StoredNote — one thing the user said once, as it lands in the semantic store. +// Kind is "note" or "fact"; both share the vector index (see +// cmd/mavend/recall.go), so a fact can legitimately win a recall. +type StoredNote struct { + ID string `json:"id"` + Text string `json:"text"` + Kind string `json:"kind"` +} + +// Case — a small set of notes, one query, and the note that must come back +// first. Want is empty exactly when the query should recall NOTHING: that lane +// measures false recall, which is the direction the spec cares about ("a +// confident wrong fact is worse than a known gap"). +type Case struct { + ID string `json:"id"` + Lang string `json:"lang"` + Notes []StoredNote `json:"notes"` + Query string `json:"query"` + Want string `json:"want"` + Tags []string `json:"tags"` + Note string `json:"note"` +} + +// Answerable reports whether the case expects a recall at all. +func (c Case) Answerable() bool { return c.Want != "" } + +// Fixture — the versioned envelope, same shape as the routing fixture. +// +// Filler is inserted into EVERY case's store on top of that case's own notes. +// Without it a case with three notes scores recall@3 = 100% by construction, +// which measures nothing. A real store holds months of unrelated notes, and the +// wanted note has to beat all of them. +type Fixture struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + Notes []string `json:"notes"` + Filler []StoredNote `json:"filler"` + Cases []Case `json:"cases"` +} + +// Load returns the embedded fixture. +func Load() (Fixture, error) { + var f Fixture + if err := json.Unmarshal(fixtureJSON, &f); err != nil { + return Fixture{}, fmt.Errorf("parse fixture: %w", err) + } + if f.SchemaVersion != SchemaVersion { + return Fixture{}, fmt.Errorf("fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion) + } + if len(f.Cases) == 0 { + return Fixture{}, fmt.Errorf("fixture has no cases") + } + return f, nil +} + +// NewStore builds an empty store for one case, plus a function to release it. +// A factory rather than a store because every case needs a clean index — notes +// from case A must not be visible to case B's query. +type NewStore func() (memory.Store, func(), error) + +// InMemory is the NewStore for memory.InMemoryStore — the fallback the daemon +// uses when there is no database (cmd/mavend/voice.go:224). +func InMemory() (memory.Store, func(), error) { + return memory.NewInMemoryStore(), func() {}, nil +} + +// Cache wraps an embedder so repeated text is embedded once. The gate sweep +// scores the same fixture at nine thresholds, and every case re-inserts the +// filler notes — without this the ONNX run spends minutes re-embedding +// identical strings. Latency numbers come from the uncached run. +func Cache(inner router.Embedder) router.Embedder { + return &cachingEmbedder{inner: inner, seen: map[string][]float32{}} +} + +type cachingEmbedder struct { + inner router.Embedder + seen map[string][]float32 +} + +var _ router.AsymmetricEmbedder = (*cachingEmbedder)(nil) + +func (c *cachingEmbedder) Dim() int { return c.inner.Dim() } +func (c *cachingEmbedder) Close() error { return nil } // the caller owns inner + +func (c *cachingEmbedder) Embed(ctx context.Context, text string) ([]float32, error) { + return c.cached(ctx, "embed:"+text, func() ([]float32, error) { + return c.inner.Embed(ctx, text) + }) +} + +// The two sides of an asymmetric embedder give different vectors for the same +// string, so the cache key has to say which side asked. +func (c *cachingEmbedder) EmbedQuery(ctx context.Context, text string) ([]float32, error) { + return c.cached(ctx, "query:"+text, func() ([]float32, error) { + return router.EmbedQuery(ctx, c.inner, text) + }) +} + +func (c *cachingEmbedder) EmbedPassage(ctx context.Context, text string) ([]float32, error) { + return c.cached(ctx, "passage:"+text, func() ([]float32, error) { + return router.EmbedPassage(ctx, c.inner, text) + }) +} + +func (c *cachingEmbedder) cached(_ context.Context, key string, embed func() ([]float32, error)) ([]float32, error) { + if v, ok := c.seen[key]; ok { + return v, nil + } + v, err := embed() + if err != nil { + return nil, err + } + c.seen[key] = v + return v, nil +} + +// Outcome — one scored case. +type Outcome struct { + Case Case + Hits []memory.Result + Err error + // Latency is the read path only: embed the query, then Search. Insert time + // is excluded because it happens once, weeks earlier. + Latency time.Duration + // Rank1/Rank3 — the wanted note came back first / in the top three, + // ignoring the confidence gate. Ranking is the store's job. + Rank1 bool + Rank3 bool + // Recalled — what the daemon would actually say back: the top hit's text + // when it clears the gate. Mirrors bestRecall in cmd/mavend/recall.go. + Recalled string + // Pass — the wanted note was recalled AND survived the gate; or, for a + // no-answer case, nothing was recalled. + Pass bool + // Tied — the wanted note is on top but shares its score with the next hit, + // so the sort decided it, not the embedder. Counted apart from a real hit. + Tied bool + TopID string + TopScor float64 + // Margin — top1 − top2. 0 when fewer than two hits came back. + Margin float64 + Reasons []string +} + +// Report — the aggregate. Rank and gate are kept apart on purpose: a note that +// ranks first but is silenced by query_min_score is a threshold problem, and a +// note that never ranks first is an embedder problem. Those are different fixes. +type Report struct { + Name string + MinScore float64 + // MinMargin — how far the top hit must beat the runner-up. 0 ⇒ off. + MinMargin float64 + Total int + Answerable int + Rank1 int + Rank3 int + // Gated — ranked first but the score was under MinScore, so the daemon + // stays silent and answers "не знаю". + Gated int + // WrongTop — a different note outranked the right one. + WrongTop int + // Tied — the right note was on top only because of sort order. Not credited + // as recall; tracked because it is a distinct failure (the embedder scored + // the query and the note the same as everything else). + Tied int + // NoAnswer / FalseRecall — the cases that must recall nothing, and how many + // of them the daemon would answer anyway. + NoAnswer int + FalseRecall int + Errors int + Passed int + Outcomes []Outcome + ByTag map[string]TagStat + ByLang map[string]TagStat + // CorrectTop / NoAnswerTop — sorted top-1 scores for the answerable cases + // where the right note ranked first, and for the no-answer cases. The gap + // between these two distributions is what a defensible query_min_score + // would have to sit inside; if they overlap, no threshold separates them. + CorrectTop []float64 + NoAnswerTop []float64 + // CorrectMargin / NoAnswerMargin — the same two groups, but top1 − top2 + // instead of top1. This is the pair the margin gate has to separate, and + // unlike the absolute scores it is what the sweep reads. + CorrectMargin []float64 + NoAnswerMargin []float64 + P50, P95, Max time.Duration +} + +// TagStat — passed/total for one slice of the fixture. +type TagStat struct{ Passed, Total int } + +// Recall1 — fraction of answerable cases whose wanted note ranked first. +func (r Report) Recall1() float64 { return ratio(r.Rank1, r.Answerable) } + +// Recall3 — same, in the top three. The daemon asks for 3 (voice.go), so this +// is the ceiling a better gate or a reranker could reach. +func (r Report) Recall3() float64 { return ratio(r.Rank3, r.Answerable) } + +// Answered — fraction of answerable cases the daemon would actually answer +// correctly, gate included. This is the number the operator experiences. +func (r Report) Answered() float64 { return ratio(r.Rank1-r.Gated, r.Answerable) } + +// FalseRecallRate — fraction of the no-answer cases the daemon answers anyway. +func (r Report) FalseRecallRate() float64 { return ratio(r.FalseRecall, r.NoAnswer) } + +func ratio(n, d int) float64 { + if d == 0 { + return 0 + } + return float64(n) / float64(d) +} + +// Score runs every case against a fresh store and aggregates. It never fails +// the run on an embed or search error: an erroring case scores as a miss and is +// counted in Errors, because "the embedder was down" and "the embedder was +// wrong" are different numbers. +func Score(ctx context.Context, name string, emb router.Embedder, newStore NewStore, minScore, minMargin float64, f Fixture) (Report, error) { + rep := Report{ + Name: name, + MinScore: minScore, + MinMargin: minMargin, + Total: len(f.Cases), + ByTag: map[string]TagStat{}, + ByLang: map[string]TagStat{}, + } + lat := make([]time.Duration, 0, len(f.Cases)) + + for _, c := range f.Cases { + if c.Answerable() { + rep.Answerable++ + } else { + rep.NoAnswer++ + } + o, err := scoreCase(ctx, emb, newStore, minScore, minMargin, c, f.Filler) + if err != nil { + return Report{}, err + } + lat = append(lat, o.Latency) + + switch { + case o.Err != nil: + rep.Errors++ + case c.Answerable(): + if o.Rank1 { + rep.Rank1++ + } + if o.Rank3 { + rep.Rank3++ + } + if o.Rank1 && o.Recalled == "" { + rep.Gated++ + } + if o.Tied { + rep.Tied++ + } else if !o.Rank1 { + rep.WrongTop++ + } + if o.Rank1 { + // Margins are collected on rank, not on the gate, so the + // distribution does not move as the sweep changes the gate. + rep.CorrectMargin = append(rep.CorrectMargin, o.Margin) + } + if o.Rank1 && o.Recalled != "" { + rep.CorrectTop = append(rep.CorrectTop, o.TopScor) + } + default: + if o.Recalled != "" { + rep.FalseRecall++ + } + rep.NoAnswerTop = append(rep.NoAnswerTop, o.TopScor) + rep.NoAnswerMargin = append(rep.NoAnswerMargin, o.Margin) + } + + if o.Pass { + rep.Passed++ + } + bump(rep.ByLang, c.Lang, o.Pass) + for _, tag := range c.Tags { + bump(rep.ByTag, tag, o.Pass) + } + rep.Outcomes = append(rep.Outcomes, o) + } + + sort.Float64s(rep.CorrectTop) + sort.Float64s(rep.NoAnswerTop) + sort.Float64s(rep.CorrectMargin) + sort.Float64s(rep.NoAnswerMargin) + 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 +} + +// scoreCase inserts the case's notes into a fresh store, then runs the read +// path the daemon runs. The returned error is fatal (the harness is broken); +// an embedder or store failure on the query lands in Outcome.Err instead. +func scoreCase(ctx context.Context, emb router.Embedder, newStore NewStore, minScore, minMargin float64, c Case, filler []StoredNote) (Outcome, error) { + st, release, err := newStore() + if err != nil { + return Outcome{}, fmt.Errorf("%s: new store: %w", c.ID, err) + } + defer release() + + all := append(append([]StoredNote(nil), c.Notes...), filler...) + for _, n := range all { + vec, err := router.EmbedPassage(ctx, emb, n.Text) + if err != nil { + return Outcome{}, fmt.Errorf("%s: embed note %s: %w", c.ID, n.ID, err) + } + meta := map[string]string{"text": n.Text, "type": n.Kind} + if err := st.Insert(ctx, n.ID, vec, meta); err != nil { + return Outcome{}, fmt.Errorf("%s: insert %s: %w", c.ID, n.ID, err) + } + } + + o := Outcome{Case: c} + start := time.Now() + qvec, err := router.EmbedQuery(ctx, emb, c.Query) + if err != nil { + o.Latency = time.Since(start) + o.Err = err + o.Reasons = []string{fmt.Sprintf("embed query: %v", err)} + return o, nil + } + hits, err := st.Search(ctx, qvec, 3) + o.Latency = time.Since(start) + if err != nil { + o.Err = err + o.Reasons = []string{fmt.Sprintf("search: %v", err)} + return o, nil + } + o.Hits = hits + + if len(hits) > 0 { + o.TopID, o.TopScor = hits[0].ID, hits[0].Score + if len(hits) > 1 { + o.Margin = hits[0].Score - hits[1].Score + } + o.Recalled = bestRecall(hits, minScore, minMargin) + } + for i, h := range hits { + if h.ID != c.Want { + continue + } + o.Rank3 = true + // A tie is not a hit. With a lexical embedder several notes score + // exactly 0 against a paraphrased query, and whichever one the sort + // happens to leave on top would otherwise be credited as recall. + if i == 0 && (len(hits) < 2 || hits[0].Score > hits[1].Score) { + o.Rank1 = true + } + if i == 0 && !o.Rank1 { + o.Tied = true + } + } + + switch { + case !c.Answerable(): + if o.Recalled != "" { + o.Reasons = append(o.Reasons, fmt.Sprintf("false recall: %q at %.3f (margin %.3f), want silence", o.TopID, o.TopScor, o.Margin)) + } + case o.Tied: + o.Reasons = append(o.Reasons, fmt.Sprintf("tie at %.3f — the right note is on top only by sort order", o.TopScor)) + case !o.Rank1: + o.Reasons = append(o.Reasons, fmt.Sprintf("top hit %q (%.3f), want %q%s", o.TopID, o.TopScor, c.Want, rankNote(o.Rank3))) + case o.Recalled == "": + o.Reasons = append(o.Reasons, fmt.Sprintf("right note ranked first at %.3f (margin %.3f) but the gate silenced it — daemon says \"не знаю\"", o.TopScor, o.Margin)) + } + o.Pass = len(o.Reasons) == 0 + return o, nil +} + +func rankNote(inTop3 bool) string { + if inTop3 { + return " (wanted note is in the top 3)" + } + return " (wanted note is not in the top 3)" +} + +// bestRecall mirrors cmd/mavend/recall.go — the gate the daemon actually +// applies to a memory hit. Duplicated rather than imported because package main +// is not importable; recalleval_test.go asserts the two agree in behaviour. +// The daemon returns the whole hit (a note and a fact are said differently); +// the harness only scores what came back, so it keeps returning the text. +func bestRecall(results []memory.Result, minScore, minMargin float64) string { + if !memory.Confident(results, minScore, minMargin) { + return "" + } + return results[0].Meta["text"] +} + +func bump(m map[string]TagStat, key string, pass bool) { + if key == "" { + return + } + s := m[key] + s.Total++ + if pass { + s.Passed++ + } + m[key] = s +} + +// percentile — nearest-rank on a pre-sorted slice. No interpolation: with ~30 +// samples an interpolated p95 invents a latency no query actually took. +func percentile(sorted []time.Duration, p float64) time.Duration { + if len(sorted) == 0 { + return 0 + } + i := int(p * float64(len(sorted))) + if i >= len(sorted) { + i = len(sorted) - 1 + } + return sorted[i] +} + +// String renders the report in the routing eval's style — headline first, then +// the slices that name where the path is weak. +func (r Report) String() string { + var b strings.Builder + fmt.Fprintf(&b, "%s: %d/%d cases pass (gate %.2f, margin %.3f)\n", r.Name, r.Passed, r.Total, r.MinScore, r.MinMargin) + fmt.Fprintf(&b, " recall@1 %.1f%% (%d/%d) recall@3 %.1f%% (%d/%d) answered after gate %.1f%% (%d/%d)\n", + 100*r.Recall1(), r.Rank1, r.Answerable, + 100*r.Recall3(), r.Rank3, r.Answerable, + 100*r.Answered(), r.Rank1-r.Gated, r.Answerable) + fmt.Fprintf(&b, " wrong note on top: %d | tie on top (sort order, not recall): %d | silenced by gate: %d | errors: %d\n", + r.WrongTop, r.Tied, r.Gated, r.Errors) + fmt.Fprintf(&b, " false recall %.1f%% (%d/%d must-be-silent cases answered anyway)\n", + 100*r.FalseRecallRate(), r.FalseRecall, r.NoAnswer) + fmt.Fprintf(&b, " top-1 score, right note first: %s\n", spread(r.CorrectTop)) + fmt.Fprintf(&b, " top-1 score, must be silent: %s\n", spread(r.NoAnswerTop)) + fmt.Fprintf(&b, " margin top1-top2, right note first: %s\n", spread(r.CorrectMargin)) + fmt.Fprintf(&b, " margin top1-top2, must be silent: %s\n", spread(r.NoAnswerMargin)) + fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max) + fmt.Fprintf(&b, " by lang: %s\n", renderStats(r.ByLang)) + fmt.Fprintf(&b, " by tag: %s\n", renderStats(r.ByTag)) + return b.String() +} + +// Failures — per-case detail, sorted by ID so two runs diff cleanly. +func (r Report) Failures() string { + var b strings.Builder + out := append([]Outcome(nil), r.Outcomes...) + sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID }) + for _, o := range out { + if o.Pass { + continue + } + fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Query, strings.Join(o.Reasons, "; ")) + } + return b.String() +} + +// spread — min / median / max of a sorted score list. Three numbers is enough +// to see whether two distributions overlap, which is the only question a +// threshold can answer. +func spread(sorted []float64) string { + if len(sorted) == 0 { + return "n/a" + } + return fmt.Sprintf("min %.3f median %.3f max %.3f (n=%d)", + sorted[0], sorted[len(sorted)/2], sorted[len(sorted)-1], len(sorted)) +} + +func renderStats(m map[string]TagStat) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + s := m[k] + parts = append(parts, fmt.Sprintf("%s %d/%d", k, s.Passed, s.Total)) + } + return strings.Join(parts, " ") +} diff --git a/internal/memory/recalleval/recalleval_test.go b/internal/memory/recalleval/recalleval_test.go new file mode 100644 index 0000000..95e7449 --- /dev/null +++ b/internal/memory/recalleval/recalleval_test.go @@ -0,0 +1,332 @@ +package recalleval + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "unicode" + + "github.com/kami/maven/internal/config" + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" +) + +// dim 1024 for the hash embedder: it is bag-of-words, so a narrower space +// collides tokens between unrelated notes and would measure the hash. +const hashDim = 1024 + +func TestLoadFixture(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(f.Cases) < 25 { + t.Errorf("%d cases, want >= 25", len(f.Cases)) + } + // Filler is what stops recall@3 being free: three case notes and a top-3 + // search would put the wanted note in the top 3 every time. + if len(f.Filler) < 10 { + t.Errorf("%d filler notes, want >= 10", len(f.Filler)) + } + seen := map[string]bool{} + silent, en := 0, 0 + for _, c := range f.Cases { + if c.ID == "" || seen[c.ID] { + t.Errorf("case %q: empty or duplicate id", c.ID) + } + seen[c.ID] = true + if c.Lang != "ru" && c.Lang != "en" { + t.Errorf("%s: lang %q, want ru|en", c.ID, c.Lang) + } + if c.Lang == "en" { + en++ + } + if strings.TrimSpace(c.Query) == "" { + t.Errorf("%s: empty query", c.ID) + } + // Fewer than three notes and a wrong answer has nowhere to come from, + // so recall@1 would be near-free. + if len(c.Notes) < 3 { + t.Errorf("%s: %d notes, want >= 3", c.ID, len(c.Notes)) + } + ids := map[string]bool{} + for _, n := range c.Notes { + if n.ID == "" || ids[n.ID] { + t.Errorf("%s: note %q empty or duplicate id", c.ID, n.ID) + } + ids[n.ID] = true + if strings.TrimSpace(n.Text) == "" { + t.Errorf("%s: note %q empty text", c.ID, n.ID) + } + if n.Kind != "note" && n.Kind != "fact" { + t.Errorf("%s: note %q kind %q, want note|fact", c.ID, n.ID, n.Kind) + } + } + if !c.Answerable() { + silent++ + continue + } + if !ids[c.Want] { + t.Errorf("%s: want %q is not one of the case's notes", c.ID, c.Want) + } + } + // Both lanes need enough cases that a rate means something. + if silent < 5 { + t.Errorf("%d must-be-silent cases, want >= 5", silent) + } + if en < 5 { + t.Errorf("%d English cases, want >= 5", en) + } +} + +// TestFixtureIsParaphrased — the fixture's claim to measuring recall at all. If +// a query repeats its note's words, cosine over a bag-of-words embedder gets it +// for free and the score says nothing about semantic recall. Half the query's +// words is the line: some shared vocabulary is natural ("nginx", "чай"), a copy +// is the failure. +func TestFixtureIsParaphrased(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + for _, c := range f.Cases { + if !c.Answerable() { + continue + } + var want string + for _, n := range c.Notes { + if n.ID == c.Want { + want = n.Text + } + } + q := words(c.Query) + if len(q) == 0 { + continue + } + inNote := map[string]bool{} + for _, w := range words(want) { + inNote[w] = true + } + shared := 0 + for _, w := range q { + if inNote[w] { + shared++ + } + } + if frac := float64(shared) / float64(len(q)); frac > 0.5 { + t.Errorf("%s: query shares %.0f%% of its words with the note — not a paraphrase\n query: %q\n note: %q", + c.ID, 100*frac, c.Query, want) + } + } +} + +// words — lowercased words of two runes or more, matching how the hash +// embedder tokenizes. +func words(s string) []string { + var out []string + for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) { + if len([]rune(w)) > 1 { + out = append(out, w) + } + } + return out +} + +// TestBestRecallMatchesDaemon — the harness duplicates bestRecall from +// cmd/mavend/recall.go (package main is not importable). This pins the copy to +// the original's three rules: no hits, below the gate, or no text ⇒ silence. +func TestBestRecallMatchesDaemon(t *testing.T) { + if got := bestRecall(nil, 0.55, 0); got != "" { + t.Errorf("no hits: got %q, want silence", got) + } + low := []memory.Result{{ID: "a", Score: 0.4, Meta: map[string]string{"text": "чай"}}} + if got := bestRecall(low, 0.55, 0); got != "" { + t.Errorf("below gate: got %q, want silence", got) + } + noText := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{}}} + if got := bestRecall(noText, 0.55, 0); got != "" { + t.Errorf("no text: got %q, want silence", got) + } + ok := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{"text": "чай"}}} + if got := bestRecall(ok, 0.55, 0); got != "чай" { + t.Errorf("above gate: got %q, want %q", got, "чай") + } + // Margin: a close runner-up means the embedder cannot tell the two apart, + // so Maven stays silent even though both clear the absolute floor. + close := []memory.Result{ + {ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}}, + {ID: "b", Score: 0.85, Meta: map[string]string{"text": "кофе"}}, + } + if got := bestRecall(close, 0.55, 0.03); got != "" { + t.Errorf("thin margin: got %q, want silence", got) + } + if got := bestRecall(close, 0.55, 0); got != "чай" { + t.Errorf("margin off: got %q, want %q", got, "чай") + } + clear := []memory.Result{ + {ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}}, + {ID: "b", Score: 0.70, Meta: map[string]string{"text": "кофе"}}, + } + if got := bestRecall(clear, 0.55, 0.03); got != "чай" { + t.Errorf("wide margin: got %q, want %q", got, "чай") + } +} + +// TestHashRecallBaseline — the CI ratchet. HashEmbedder, so it needs no model +// files and is byte-for-byte reproducible. +// +// It is a floor, not a target. The hash embedder is lexical, so most of this +// fixture is unwinnable for it by construction; the number worth moving is +// TestONNXRecall's. Never compare a hash-embedder number to an ONNX one. +func TestHashRecallBaseline(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rep, err := Score(context.Background(), "recall+hash", router.NewHashEmbedder(hashDim), InMemory, + config.DefaultQueryMinScore, config.DefaultQueryMinMargin, f) + if err != nil { + t.Fatalf("Score: %v", err) + } + t.Log("\n" + rep.String() + rep.Failures()) + t.Log("\ngate sweep:\n" + sweep(t, router.NewHashEmbedder(hashDim), f)) + + // 0.32 sits under the observed 0.370 recall@1 (was 0.360 over 25 answerable + // cases; the two mixed note+fact cases added with #373 make it 27). + const floorRecall1 = 0.32 + if rep.Recall1() < floorRecall1 { + t.Errorf("recall@1 %.3f below ratchet %.2f — note recall regressed", rep.Recall1(), floorRecall1) + } + // The dangerous direction, asserted tightly and separately: answering from + // the wrong note is worse than a gap. Observed 0 under the hash floor. + if rep.FalseRecall > 1 { + t.Errorf("%d false recalls, want <= 1:\n%s", rep.FalseRecall, rep.Failures()) + } +} + +// TestPersistentStoreScoresTheSame — the deployed store is sqlite-backed +// (store.MemoryStore via st.VectorMemory()), not the in-memory fallback. Its +// Search is a separate implementation of the same cosine scan, so it gets its +// own run: a divergence here would mean recall quality depends on whether a +// database was configured. +func TestPersistentStoreScoresTheSame(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + emb := router.NewHashEmbedder(hashDim) + inMem, err := Score(context.Background(), "recall+hash+memory", emb, InMemory, config.DefaultQueryMinScore, config.DefaultQueryMinMargin, f) + if err != nil { + t.Fatalf("Score in-memory: %v", err) + } + persistent, err := Score(context.Background(), "recall+hash+sqlite", emb, sqliteStores(t), config.DefaultQueryMinScore, config.DefaultQueryMinMargin, f) + if err != nil { + t.Fatalf("Score sqlite: %v", err) + } + t.Log("\n" + persistent.String()) + if persistent.Rank1 != inMem.Rank1 || persistent.FalseRecall != inMem.FalseRecall { + t.Errorf("sqlite recall@1 %d/%d fr %d, in-memory %d/%d fr %d — the two backends disagree", + persistent.Rank1, persistent.Answerable, persistent.FalseRecall, + inMem.Rank1, inMem.Answerable, inMem.FalseRecall) + } +} + +// sqliteStores returns a NewStore that hands each case its own plaintext +// database file, so cases stay isolated the way they are with InMemory. +func sqliteStores(t *testing.T) NewStore { + t.Helper() + dir := t.TempDir() + n := 0 + return func() (memory.Store, func(), error) { + n++ + st, err := store.Open(context.Background(), filepath.Join(dir, fmt.Sprintf("recall-%d.db", n))) + if err != nil { + return nil, nil, err + } + return st.VectorMemory(), func() { _ = st.Close() }, nil + } +} + +// TestONNXRecall — the number that matters: the multilingual embedder homesrv +// actually runs. Opt-in via MAVEN_ONNX_LIB because deps/ is gitignored, exactly +// like TestONNXBaseline in internal/router/eval. `make eval-recall` points it at +// the vendored runtime. +// +// Reports rather than asserts. The gate sweep is the point: it prints +// answered-vs-false-recall at a range of query_min_score values, so the right +// threshold is read off data instead of guessed. +func TestONNXRecall(t *testing.T) { + lib := os.Getenv("MAVEN_ONNX_LIB") + if lib == "" { + t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") + } + model := filepath.Join("../../..", "models/embedder/multilingual-e5-small/model_quantized.onnx") + tok := filepath.Join("../../..", "models/embedder/multilingual-e5-small/tokenizer.json") + for _, p := range []string{lib, model, tok} { + if _, err := os.Stat(p); err != nil { + t.Skipf("missing %s: %v", p, err) + } + } + emb, err := router.NewONNXEmbedder(model, tok, lib) + if err != nil { + t.Skipf("onnx embedder unavailable: %v", err) + } + defer emb.Close() + + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rep, err := Score(context.Background(), "recall+onnx", emb, InMemory, config.DefaultQueryMinScore, config.DefaultQueryMinMargin, f) + if err != nil { + t.Fatalf("Score: %v", err) + } + t.Log("\n" + rep.String() + rep.Failures()) + // Cached for the sweeps only: the headline run above must pay the real + // embedder cost so its latency numbers mean something. + cached := Cache(emb) + t.Log("\ngate sweep (margin off):\n" + sweep(t, cached, f)) + t.Log("\nmargin sweep (gate 0.55):\n" + marginSweep(t, cached, f)) +} + +// sweep scores the fixture at a range of gates and renders one line each. Two +// columns matter: how many real questions get answered, and how many made-up +// ones get answered anyway. A gate is only defensible if some value keeps the +// first high and the second at zero. +func sweep(t *testing.T, emb router.Embedder, f Fixture) string { + t.Helper() + var b strings.Builder + for _, gate := range []float64{0.0, 0.30, 0.40, 0.50, 0.55, 0.60, 0.70, 0.80, 0.90} { + rep, err := Score(context.Background(), "sweep", emb, InMemory, gate, 0, f) + if err != nil { + t.Fatalf("sweep at %.2f: %v", gate, err) + } + fmt.Fprintf(&b, " gate %.2f: answered %d/%d (%.0f%%) false recall %d/%d\n", + gate, rep.Rank1-rep.Gated, rep.Answerable, 100*rep.Answered(), rep.FalseRecall, rep.NoAnswer) + } + return b.String() +} + +// marginSweep is the same idea for the margin gate (top1 − top2 > delta), with +// the absolute gate held at its default. The absolute score cannot separate a +// real hit from a made-up question under e5 — every score lands in one narrow +// band — so this sweep is the one that picks a number. +func marginSweep(t *testing.T, emb router.Embedder, f Fixture) string { + t.Helper() + var b strings.Builder + for _, d := range []float64{0, 0.002, 0.005, 0.008, 0.01, 0.012, 0.015, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06} { + rep, err := Score(context.Background(), "margin sweep", emb, InMemory, config.DefaultQueryMinScore, d, f) + if err != nil { + t.Fatalf("margin sweep at %.3f: %v", d, err) + } + fmt.Fprintf(&b, " delta %.3f: answered %d/%d (%.0f%%) false recall %d/%d\n", + d, rep.Rank1-rep.Gated, rep.Answerable, 100*rep.Answered(), rep.FalseRecall, rep.NoAnswer) + } + return b.String() +} diff --git a/internal/memory/recalleval/ru_recall_v1.json b/internal/memory/recalleval/ru_recall_v1.json new file mode 100644 index 0000000..7659680 --- /dev/null +++ b/internal/memory/recalleval/ru_recall_v1.json @@ -0,0 +1,418 @@ +{ + "schema_version": 1, + "name": "ru_recall_v1", + "notes": [ + "Held-out note-recall fixture. Each case is a fresh semantic store: insert every note, embed the query, take the top 3 — the same read path cmd/mavend/voice.go runs for IntentQuery.", + "Queries paraphrase their note on purpose. A query that repeats the note's words measures string matching, not recall. TestFixtureIsParaphrased enforces a ceiling on word overlap.", + "want:\"\" means the query must recall NOTHING. Those cases measure false recall — the direction the spec calls out (a confident wrong fact is worse than a known gap).", + "The distractor tag marks cases where a second note is plausible and only one is right. The hard tag marks cases with little or no shared vocabulary.", + "Content is written for this operator: his preferences, his homelab, things he said once and would expect Maven to remember weeks later." + ], + "filler": [ + {"id": "f1", "text": "в субботу ходил в баню", "kind": "note"}, + {"id": "f2", "text": "купил новые кроссовки сорок третьего размера", "kind": "note"}, + {"id": "f3", "text": "сериал закончился на третьем сезоне", "kind": "note"}, + {"id": "f4", "text": "сосед сверху делает ремонт", "kind": "note"}, + {"id": "f5", "text": "билеты в театр брал заранее", "kind": "note"}, + {"id": "f6", "text": "выучил пару аккордов на гитаре", "kind": "note"}, + {"id": "f7", "text": "записался к стоматологу", "kind": "note"}, + {"id": "f8", "text": "поменял лампочку в коридоре", "kind": "note"}, + {"id": "f9", "text": "погулял вдоль реки", "kind": "note"}, + {"id": "f10", "text": "the balcony door sticks in winter", "kind": "note"}, + {"id": "f11", "text": "the neighbour's dog barks at cyclists", "kind": "note"}, + {"id": "f12", "text": "i finished the book about volcanoes", "kind": "note"} + ], + "cases": [ + { + "id": "ru-pref-001", + "lang": "ru", + "tags": ["preference", "paraphrase"], + "query": "какой кофе мне наливать", + "want": "n1", + "notes": [ + {"id": "n1", "text": "я пью кофе без сахара", "kind": "note"}, + {"id": "n2", "text": "по утрам бегаю в парке", "kind": "note"}, + {"id": "n3", "text": "не люблю громкую музыку", "kind": "note"} + ] + }, + { + "id": "ru-pref-002", + "lang": "ru", + "tags": ["preference", "homelab", "paraphrase", "hard"], + "query": "когда запускать резервное копирование", + "want": "n1", + "note": "The DESIGN.md preference-seam example, phrased as the operator would ask it later.", + "notes": [ + {"id": "n1", "text": "бэкапы лучше делать ночью в три часа", "kind": "note"}, + {"id": "n2", "text": "обновления ставлю по субботам", "kind": "note"}, + {"id": "n3", "text": "логи храню месяц", "kind": "note"} + ] + }, + { + "id": "ru-home-003", + "lang": "ru", + "tags": ["homelab", "paraphrase"], + "query": "что помогло от мерцания монитора", + "want": "n1", + "notes": [ + {"id": "n1", "text": "мерцание экрана прошло после обновления драйвера amdgpu", "kind": "note"}, + {"id": "n2", "text": "вентилятор шумит на полной нагрузке", "kind": "note"}, + {"id": "n3", "text": "поставил новый ssd в ноутбук", "kind": "note"} + ] + }, + { + "id": "ru-home-004", + "lang": "ru", + "tags": ["homelab", "distractor", "hard"], + "query": "адрес домашнего сервера", + "want": "n2", + "note": "Two notes carry an IP. Only one is the server.", + "notes": [ + {"id": "n1", "text": "роутер живёт на 192.168.1.1", "kind": "note"}, + {"id": "n2", "text": "домашний сервер на 192.168.1.104", "kind": "note"}, + {"id": "n3", "text": "принтер подключен по usb", "kind": "note"} + ] + }, + { + "id": "ru-home-005", + "lang": "ru", + "tags": ["homelab"], + "query": "где искать настройки nginx", + "want": "n1", + "notes": [ + {"id": "n1", "text": "конфиг nginx лежит в /etc/nginx/sites-enabled", "kind": "note"}, + {"id": "n2", "text": "сертификаты обновляет certbot по расписанию", "kind": "note"}, + {"id": "n3", "text": "порт 8080 занят вебкой", "kind": "note"} + ] + }, + { + "id": "ru-pref-006", + "lang": "ru", + "tags": ["preference", "distractor", "hard"], + "query": "что мне нельзя есть", + "want": "n1", + "note": "The Friday-meat note is a plausible second answer but it is a habit, not a restriction.", + "notes": [ + {"id": "n1", "text": "у меня аллергия на орехи", "kind": "note"}, + {"id": "n2", "text": "не ем мясо по пятницам", "kind": "note"}, + {"id": "n3", "text": "люблю острую еду", "kind": "note"} + ] + }, + { + "id": "ru-pers-007", + "lang": "ru", + "tags": ["distractor", "paraphrase"], + "query": "когда мамин праздник", + "want": "n1", + "notes": [ + {"id": "n1", "text": "день рождения мамы четырнадцатого марта", "kind": "note"}, + {"id": "n2", "text": "у брата день рождения в июле", "kind": "note"}, + {"id": "n3", "text": "годовщина в сентябре", "kind": "note"} + ] + }, + { + "id": "ru-home-008", + "lang": "ru", + "tags": ["homelab", "hard", "paraphrase"], + "query": "чем ускоряется языковая модель", + "want": "n1", + "notes": [ + {"id": "n1", "text": "модель крутится на встройке через vulkan", "kind": "note"}, + {"id": "n2", "text": "whisper работает на процессоре", "kind": "note"}, + {"id": "n3", "text": "голос у piper русский", "kind": "note"} + ] + }, + { + "id": "ru-pref-009", + "lang": "ru", + "tags": ["preference", "distractor"], + "query": "во сколько я обычно засыпаю", + "want": "n1", + "notes": [ + {"id": "n1", "text": "ложусь спать около часа ночи", "kind": "note"}, + {"id": "n2", "text": "встаю в семь утра", "kind": "note"}, + {"id": "n3", "text": "днём не сплю", "kind": "note"} + ] + }, + { + "id": "ru-home-010", + "lang": "ru", + "tags": ["homelab", "paraphrase"], + "query": "где у меня хранятся пароли", + "want": "n1", + "notes": [ + {"id": "n1", "text": "пароли держу в keepassxc", "kind": "note"}, + {"id": "n2", "text": "двухфакторку сделал через totp", "kind": "note"}, + {"id": "n3", "text": "ssh ключи лежат на юбикее", "kind": "note"} + ] + }, + { + "id": "ru-home-011", + "lang": "ru", + "tags": ["homelab", "hard", "paraphrase"], + "query": "из-за чего кончилось место", + "want": "n1", + "notes": [ + {"id": "n1", "text": "диск забился логами докера в июне", "kind": "note"}, + {"id": "n2", "text": "рейд собрал из двух дисков", "kind": "note"}, + {"id": "n3", "text": "бэкап на внешний диск раз в неделю", "kind": "note"} + ] + }, + { + "id": "ru-pref-012", + "lang": "ru", + "tags": ["preference", "distractor"], + "query": "какой чай мне нравится", + "want": "n1", + "notes": [ + {"id": "n1", "text": "чай пью только зелёный", "kind": "note"}, + {"id": "n2", "text": "кофе пью без сахара", "kind": "note"}, + {"id": "n3", "text": "воду пью из фильтра", "kind": "note"} + ] + }, + { + "id": "ru-silent-013", + "lang": "ru", + "tags": ["silent"], + "query": "какая погода будет в пятницу", + "want": "", + "notes": [ + {"id": "n1", "text": "роутер живёт на 192.168.1.1", "kind": "note"}, + {"id": "n2", "text": "бэкапы лучше делать ночью", "kind": "note"}, + {"id": "n3", "text": "у меня аллергия на орехи", "kind": "note"} + ] + }, + { + "id": "ru-silent-014", + "lang": "ru", + "tags": ["silent"], + "query": "как зовут сестру моего коллеги", + "want": "", + "notes": [ + {"id": "n1", "text": "конфиг nginx лежит в /etc/nginx/sites-enabled", "kind": "note"}, + {"id": "n2", "text": "порт 8080 занят вебкой", "kind": "note"}, + {"id": "n3", "text": "сертификаты обновляет certbot", "kind": "note"} + ] + }, + { + "id": "ru-silent-015", + "lang": "ru", + "tags": ["silent"], + "query": "сколько я заплатил за машину", + "want": "", + "notes": [ + {"id": "n1", "text": "чай пью только зелёный", "kind": "note"}, + {"id": "n2", "text": "ложусь спать около часа ночи", "kind": "note"}, + {"id": "n3", "text": "не люблю громкую музыку", "kind": "note"} + ] + }, + { + "id": "ru-home-016", + "lang": "ru", + "tags": ["homelab", "paraphrase"], + "query": "откуда берётся токен бота", + "want": "n1", + "notes": [ + {"id": "n1", "text": "токен телеграма лежит в deploy/telegram.env", "kind": "note"}, + {"id": "n2", "text": "вебхуки не использую, только long-poll", "kind": "note"}, + {"id": "n3", "text": "уведомления приходят в личку", "kind": "note"} + ] + }, + { + "id": "ru-hard-017", + "lang": "ru", + "tags": ["hard", "paraphrase", "homelab"], + "query": "как я восстановил конфиги", + "want": "n1", + "note": "No shared word between query and note beyond none at all. This is the case a lexical embedder cannot win.", + "notes": [ + {"id": "n1", "text": "после переустановки системы вернул все настройки из git", "kind": "note"}, + {"id": "n2", "text": "разделы на диске резал вручную", "kind": "note"}, + {"id": "n3", "text": "загрузчик поставил заново", "kind": "note"} + ] + }, + { + "id": "ru-dist-018", + "lang": "ru", + "tags": ["distractor"], + "query": "чем кормить кота", + "want": "n1", + "notes": [ + {"id": "n1", "text": "кот ест только сухой корм", "kind": "note"}, + {"id": "n2", "text": "собаке даю мясо", "kind": "note"}, + {"id": "n3", "text": "рыбок кормлю раз в день", "kind": "note"} + ] + }, + { + "id": "ru-home-019", + "lang": "ru", + "tags": ["homelab", "hard", "paraphrase"], + "query": "как контейнер получает доступ к видеокарте", + "want": "n1", + "notes": [ + {"id": "n1", "text": "docker compose пробрасывает /dev/dri внутрь", "kind": "note"}, + {"id": "n2", "text": "контейнеры рестартуют сами", "kind": "note"}, + {"id": "n3", "text": "образы чищу вручную", "kind": "note"} + ] + }, + { + "id": "ru-pref-020", + "lang": "ru", + "tags": ["preference", "distractor"], + "query": "когда мне нельзя звонить", + "want": "n1", + "notes": [ + {"id": "n1", "text": "не звони мне после десяти вечера", "kind": "note"}, + {"id": "n2", "text": "утром не трогай меня до кофе", "kind": "note"}, + {"id": "n3", "text": "по выходным не работаю", "kind": "note"} + ] + }, + { + "id": "en-pref-021", + "lang": "en", + "tags": ["preference", "paraphrase"], + "query": "which colour scheme do i like", + "want": "n1", + "notes": [ + {"id": "n1", "text": "i prefer dark theme everywhere", "kind": "note"}, + {"id": "n2", "text": "font size 14 is fine", "kind": "note"}, + {"id": "n3", "text": "i use vim keybindings", "kind": "note"} + ] + }, + { + "id": "en-home-022", + "lang": "en", + "tags": ["homelab", "distractor"], + "query": "where is the big disk mounted", + "want": "n1", + "notes": [ + {"id": "n1", "text": "the nas drive is mounted at /mnt/hdd1", "kind": "note"}, + {"id": "n2", "text": "models live on the ssd", "kind": "note"}, + {"id": "n3", "text": "backups go to the nas nightly", "kind": "note"} + ] + }, + { + "id": "en-silent-023", + "lang": "en", + "tags": ["silent"], + "query": "what is my bank account number", + "want": "", + "notes": [ + {"id": "n1", "text": "the nas drive is mounted at /mnt/hdd1", "kind": "note"}, + {"id": "n2", "text": "i prefer dark theme everywhere", "kind": "note"}, + {"id": "n3", "text": "the router runs openwrt", "kind": "note"} + ] + }, + { + "id": "en-hard-024", + "lang": "en", + "tags": ["hard", "paraphrase"], + "query": "what fixed the screen problem", + "want": "n1", + "notes": [ + {"id": "n1", "text": "the flicker went away once i swapped the display cable", "kind": "note"}, + {"id": "n2", "text": "the laptop fan is loud", "kind": "note"}, + {"id": "n3", "text": "the second monitor is 1440p", "kind": "note"} + ] + }, + { + "id": "en-pref-025", + "lang": "en", + "tags": ["preference", "hard"], + "query": "should i be offered wine", + "want": "n1", + "notes": [ + {"id": "n1", "text": "i do not drink alcohol", "kind": "note"}, + {"id": "n2", "text": "i skip breakfast", "kind": "note"}, + {"id": "n3", "text": "i like spicy food", "kind": "note"} + ] + }, + { + "id": "ru-home-026", + "lang": "ru", + "tags": ["homelab", "paraphrase"], + "query": "какая модель распознавания речи мне подходит", + "want": "n1", + "notes": [ + {"id": "n1", "text": "whisper модель small хватает для русского", "kind": "note"}, + {"id": "n2", "text": "голос ирина звучит лучше остальных", "kind": "note"}, + {"id": "n3", "text": "слово активации маven", "kind": "note"} + ] + }, + { + "id": "ru-fact-027", + "lang": "ru", + "tags": ["distractor", "hard", "paraphrase"], + "query": "когда я последний раз обслуживал машину", + "want": "n1", + "note": "A fact, not a note — both share the vector index, so a fact can win a recall.", + "notes": [ + {"id": "n1", "text": "последний раз менял масло в мае", "kind": "fact"}, + {"id": "n2", "text": "шины поменял осенью", "kind": "fact"}, + {"id": "n3", "text": "страховка до декабря", "kind": "fact"} + ] + }, + { + "id": "ru-pref-028", + "lang": "ru", + "tags": ["preference", "hard", "paraphrase"], + "query": "как мне присылать оповещения", + "want": "n1", + "notes": [ + {"id": "n1", "text": "терпеть не могу уведомления со звуком", "kind": "note"}, + {"id": "n2", "text": "вибрацию оставь включённой", "kind": "note"}, + {"id": "n3", "text": "письма читаю вечером", "kind": "note"} + ] + }, + { + "id": "ru-silent-029", + "lang": "ru", + "tags": ["silent"], + "query": "во сколько отходит поезд", + "want": "", + "notes": [ + {"id": "n1", "text": "кот ест только сухой корм", "kind": "note"}, + {"id": "n2", "text": "люблю острую еду", "kind": "note"}, + {"id": "n3", "text": "пароли держу в keepassxc", "kind": "note"} + ] + }, + { + "id": "en-home-030", + "lang": "en", + "tags": ["homelab", "paraphrase"], + "query": "what firmware is on the router", + "want": "n1", + "notes": [ + {"id": "n1", "text": "the router runs openwrt", "kind": "note"}, + {"id": "n2", "text": "wifi channel is 6", "kind": "note"}, + {"id": "n3", "text": "the guest network is off", "kind": "note"} + ] + }, + { + "id": "ru-mixed-031", + "lang": "ru", + "tags": ["mixed", "paraphrase", "hard"], + "note": "notes and facts in one store and the note is the answer — the daemon indexes both (Vikunja #373)", + "query": "куда я спрятал второй ключ от квартиры", + "want": "n1", + "notes": [ + {"id": "n1", "text": "запасной ключ от квартиры лежит в синей коробке на полке", "kind": "note"}, + {"id": "x1", "text": "поменял замок в двери двадцатого июня", "kind": "fact"}, + {"id": "x2", "text": "отдал ключ соседке в мае", "kind": "fact"} + ] + }, + { + "id": "ru-mixed-032", + "lang": "ru", + "tags": ["mixed", "distractor"], + "note": "the mirror of ru-mixed-031: the fact answers and the notes are the distractors", + "query": "когда я в последний раз заливал бензин", + "want": "x1", + "notes": [ + {"id": "x1", "text": "залил полный бак в четверг вечером", "kind": "fact"}, + {"id": "n1", "text": "на заправке у моста дешевле бензин", "kind": "note"}, + {"id": "n2", "text": "надо поменять зимние шины", "kind": "note"} + ] + } + ] +} diff --git a/internal/persona/persona.go b/internal/persona/persona.go new file mode 100644 index 0000000..d3928bb --- /dev/null +++ b/internal/persona/persona.go @@ -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 +} diff --git a/internal/persona/persona_test.go b/internal/persona/persona_test.go new file mode 100644 index 0000000..ccf411e --- /dev/null +++ b/internal/persona/persona_test.go @@ -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) + } +} diff --git a/internal/phraser/broken_json_test.go b/internal/phraser/broken_json_test.go new file mode 100644 index 0000000..c8772b7 --- /dev/null +++ b/internal/phraser/broken_json_test.go @@ -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)") + } +} diff --git a/internal/phraser/context_block_test.go b/internal/phraser/context_block_test.go new file mode 100644 index 0000000..30e0413 --- /dev/null +++ b/internal/phraser/context_block_test.go @@ -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") + } +} diff --git a/internal/phraser/eval/address_multi_test.go b/internal/phraser/eval/address_multi_test.go new file mode 100644 index 0000000..d4bc059 --- /dev/null +++ b/internal/phraser/eval/address_multi_test.go @@ -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) + } + } +} diff --git a/internal/phraser/eval/address_time_test.go b/internal/phraser/eval/address_time_test.go new file mode 100644 index 0000000..b20c460 --- /dev/null +++ b/internal/phraser/eval/address_time_test.go @@ -0,0 +1,42 @@ +package eval + +import "testing" + +func TestAddressTimeWordDoesNotBlind(t *testing.T) { + // A nudge that opens with a time word must still be caught. Without the + // time words in the stoplist, "сегодня" was read as the third party. + for _, s := range []string{ + "сегодня он не ел 11 дней", + "вчера он не пил воду", + "опять он забыл про таблетки", + } { + if r := checkAddress(s); r.Pass { + t.Errorf("checkAddress(%q) passed, want a third-person failure", s) + } + } + // Still must not fire when a third party really is named. + for _, s := range []string{ + "сегодня сервис упал, он не отвечает", + "ты не пил воду четыре часа", + } { + if r := checkAddress(s); !r.Pass { + t.Errorf("checkAddress(%q) failed: %s", s, r.Detail) + } + } +} + +// TestAddressVerbIsNotAnAntecedent — a nudge is mostly verbs, and a verb is +// never who "он" refers to. This exact string passed the check before. +func TestAddressVerbIsNotAnAntecedent(t *testing.T) { + s := "попробуй встать и отдохнуть — у него есть перерыв" + if r := checkAddress(s); r.Pass { + t.Errorf("checkAddress(%q) passed, want a third-person failure", s) + } + // Still missed, and this is the documented hole: "выпей воды, он не пил" has + // a real noun ("воды") before the pronoun, so the scan believes somebody + // else was named. Telling that apart needs a parser, not a suffix rule. + // A named third party still wins over the verbs around it. + if r := checkAddress("сервис упал, он не отвечает"); !r.Pass { + t.Errorf("checkAddress on a real third party failed: %s", r.Detail) + } +} diff --git a/internal/phraser/eval/checks.go b/internal/phraser/eval/checks.go new file mode 100644 index 0000000..29a198b --- /dev/null +++ b/internal/phraser/eval/checks.go @@ -0,0 +1,654 @@ +package eval + +import ( + "fmt" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +// The check names, in report order. Every check is a string or length test — no +// model grades another model here. +const ( + CheckMood = "mood" // mood is in the documented enum + CheckLang = "lang" // the operator's language, not the prompt's + CheckLength = "length" // a nudge is one sentence, not a paragraph + CheckFeminine = "feminine" // her self-reference is feminine (hard constraint) + CheckCringe = "cringe" // DESIGN.md § Non-goals, "not a relationship" + CheckOnTopic = "ontopic" // says the thing the rule is about + + // CheckHisGender — the other half of the persona rule: SHE is feminine, HE + // is male. "ты давно не отдыхала" addresses the operator as a woman. + CheckHisGender = "hisgender" + + // CheckAddress — she talks TO him, informally, one to one. Not "вы", not + // "он". See the comment block above checkAddress. + CheckAddress = "address" +) + +// CheckNames — report order. +var CheckNames = []string{CheckMood, CheckLang, CheckLength, CheckFeminine, CheckHisGender, CheckAddress, CheckCringe, CheckOnTopic} + +// Result — one check on one message. +type Result struct { + Name string + Pass bool + Detail string +} + +// Moods — the fixed enum from the LLM output contract. Not extended here; the +// contract lives in the daemon and the harness only reads it. +var Moods = map[string]bool{ + "neutral": true, "happy": true, "thinking": true, "tired": true, "confused": true, +} + +// Length ceilings. Justification: the nudge is spoken by piper at roughly 14 +// characters per second, so 120 characters is about 8 seconds of speech. The +// operator has AuDHD — past one short sentence a nudge stops being a nudge and +// becomes something to tune out, which is exactly the "not a nag" failure. The +// word ceiling catches the same thing for languages that pack more per byte. +const ( + MaxChars = 120 + MaxWords = 16 +) + +// RunChecks scores one message. Order matches CheckNames. +func RunChecks(c Case, body, mood string) []Result { + return []Result{ + checkMood(mood), + checkLang(body), + checkLength(body), + checkFeminine(body), + checkHisGender(body), + checkAddress(body), + checkCringe(body), + checkOnTopic(c, body), + } +} + +func checkMood(mood string) Result { + if Moods[mood] { + return Result{CheckMood, true, ""} + } + return Result{CheckMood, false, fmt.Sprintf("mood %q not in the enum", mood)} +} + +// checkLang — the operator is Russian-speaking and the nudge is spoken aloud by +// a Russian piper voice. An English nudge is not a tone problem, it is an +// unusable one. +func checkLang(body string) Result { + cyr, lat := 0, 0 + for _, r := range body { + switch { + case unicode.Is(unicode.Cyrillic, r): + cyr++ + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + lat++ + } + } + if cyr > lat { + return Result{CheckLang, true, ""} + } + return Result{CheckLang, false, fmt.Sprintf("not Russian (%d cyrillic vs %d latin letters)", cyr, lat)} +} + +func checkLength(body string) Result { + chars := utf8.RuneCountInString(body) + words := len(strings.Fields(body)) + if chars <= MaxChars && words <= MaxWords { + return Result{CheckLength, true, ""} + } + return Result{CheckLength, false, + fmt.Sprintf("%d chars / %d words, ceiling %d / %d", chars, words, MaxChars, MaxWords)} +} + +// --- feminine self-reference --------------------------------------------- +// +// The hard constraint (CLAUDE.md, DESIGN.md § Identity): Maven's Russian +// self-reference is feminine. The operator is male, so second-person forms +// addressed to him are MASCULINE and must not be flagged — "ты не пил воду" is +// correct, "я напомнил" is not. Both directions matter, which is why this is a +// windowed scan around "я" and not a bare search for masculine endings. + +var wordRE = regexp.MustCompile(`[\p{Cyrillic}]+|[,.;:!?…—-]`) + +// secondPerson — pronouns that end the self-reference window. Everything after +// one of these is about him, not about her. +var secondPerson = map[string]bool{ + "ты": true, "тебе": true, "тебя": true, "тобой": true, + "вы": true, "вам": true, "вас": true, + "он": true, "она": true, "оно": true, "они": true, +} + +// masculinePredicative — short adjectives with no verb ending to key off. +var masculinePredicative = map[string]bool{ + "должен": true, "готов": true, "рад": true, "уверен": true, + "обязан": true, "сам": true, "занят": true, "прав": true, +} + +// nounsEndingInL — the false positives of "ends in л ⇒ masculine past tense". +// Small on purpose: it only has to cover nouns a nudge might actually use. +var nounsEndingInL = map[string]bool{ + "стол": true, "стул": true, "пол": true, "зал": true, "гол": true, + "узел": true, "отдел": true, "файл": true, "канал": true, "угол": true, + "футбол": true, "вокзал": true, "металл": true, "интервал": true, + "уровень": true, "мускул": true, "апрель": true, "июль": true, "рубль": true, +} + +// masculinePast reports whether a word looks like a masculine past-tense verb. +// Russian past tense is gendered by suffix: -л (m), -ла (f). A 0.8B with weak +// Russian defaults to the masculine form, which is the exact drift being +// measured. +func masculinePast(w string) bool { + if len([]rune(w)) < 3 || nounsEndingInL[w] { + return false + } + return strings.HasSuffix(w, "л") || strings.HasSuffix(w, "лся") +} + +func checkFeminine(body string) Result { + words := wordRE.FindAllString(strings.ToLower(body), -1) + for i, w := range words { + if w != "я" { + continue + } + // Scan the next few words. Stop at punctuation or at a second-person + // pronoun: past that point the sentence is about him and masculine is + // correct. + for j := i + 1; j < len(words) && j <= i+3; j++ { + nw := words[j] + if len(nw) == 1 && !unicode.Is(unicode.Cyrillic, []rune(nw)[0]) { + break + } + if secondPerson[nw] { + break + } + if masculinePast(nw) || masculinePredicative[nw] { + return Result{CheckFeminine, false, + fmt.Sprintf("masculine self-reference %q after \"я\"", nw)} + } + } + } + // Second pass: self-reference with the pronoun dropped — "напомнил тебе", + // "проверил за тебя". A masculine past-tense verb whose object is HIM can + // only be her speaking about herself. + for i, w := range words { + if !masculinePast(w) || i+1 >= len(words) { + continue + } + next := words[i+1] + if next == "тебе" || next == "тебя" || next == "за" { + return Result{CheckFeminine, false, + fmt.Sprintf("masculine self-reference %q before %q", w, next)} + } + } + return Result{CheckFeminine, true, ""} +} + +// --- he is male ---------------------------------------------------------- +// +// The mirror of checkFeminine, and the failure it was written for: the model +// wrote "ты давно не отдыхала", which addresses the operator as a woman. That +// scored clean, because checkFeminine only ever looks at how SHE speaks about +// herself. +// +// How it works: Russian past tense is gendered by suffix, -л (m) / -ла (f). So +// this looks for feminine past-tense words in a sentence that also talks TO him +// ("ты", "тебя", "тебе", "твой", …). A feminine verb that belongs to her ("я +// заметила", "напомнила тебе") is skipped — that one is correct. +// +// Honest about the limits: this is a suffix rule, not a parser. +// - False positives: a feminine noun can be the subject in the same sentence +// ("зарядка была утром, ты её пропустил"). The guard below skips a verb whose +// previous word looks like a feminine noun, which helps but will not always +// be right. +// - False negatives: gender also shows up outside the past tense (short +// adjectives, "сама"), and none of that is checked here. +// +// That is acceptable for an eval check. It is a signal to read the message, not +// a grammar verdict, and every hit prints the word it tripped on so a human can +// disagree. + +// hisMarkers — words that mean the sentence is addressed to him. +var hisMarkers = map[string]bool{ + "ты": true, "тебя": true, "тебе": true, "тобой": true, "тобою": true, + "твой": true, "твоя": true, "твоё": true, "твое": true, "твои": true, "твою": true, +} + +// notFeminineVerb — ordinary words ending in "-ла" that are not verbs. Small on +// purpose: it only has to cover words a nudge might actually use. +// +// Words that are both a noun and a verb are deliberately NOT here. "села", +// "мыла" and "стекла" are nouns on paper, but in a nudge they are almost always +// verbs ("ты села", "ты мыла"), and listing them would make the check miss the +// exact thing it is for. Missing a real hit is worse than one false alarm. +var notFeminineVerb = map[string]bool{ + "школа": true, "скала": true, "игла": true, "метла": true, "смола": true, + "дела": true, "тела": true, "масла": true, "весла": true, + "зола": true, "пчела": true, "числа": true, +} + +// femininePast reports whether a word looks like a feminine past-tense verb: +// "отдыхала", "поела", "выспалась". +func femininePast(w string) bool { + if len([]rune(w)) < 3 || notFeminineVerb[w] { + return false + } + return strings.HasSuffix(w, "ла") || strings.HasSuffix(w, "лась") +} + +// looksFeminineNoun — a crude guard against "зарядка была": a word right before +// the verb that ends in "а"/"я" and is not itself a verb is probably the subject. +func looksFeminineNoun(w string) bool { + if femininePast(w) || len([]rune(w)) < 3 { + return false + } + return strings.HasSuffix(w, "а") || strings.HasSuffix(w, "я") +} + +// sentenceRE splits on sentence-ending punctuation, so a feminine verb in one +// sentence is not blamed on a "ты" in the next. +var sentenceRE = regexp.MustCompile(`[.!?;…]+`) + +func checkHisGender(body string) Result { + for _, sentence := range sentenceRE.Split(strings.ToLower(body), -1) { + words := wordRE.FindAllString(sentence, -1) + addressed := false + for _, w := range words { + if hisMarkers[w] { + addressed = true + } + } + if !addressed { + continue + } + for i, w := range words { + if !femininePast(w) || hersNotHis(words, i) { + continue + } + if i > 0 && looksFeminineNoun(prevWord(words, i)) { + continue + } + return Result{CheckHisGender, false, + fmt.Sprintf("feminine %q addressed to him — he is male", w)} + } + } + return Result{CheckHisGender, true, ""} +} + +// hersNotHis — the verb is Maven's own if "я" comes shortly before it, or if the +// thing she did was done to him ("напомнила тебе", "проверила за тебя"). +func hersNotHis(words []string, i int) bool { + for j := i - 1; j >= 0 && j >= i-3; j-- { + if words[j] == "я" { + return true + } + } + if i+1 < len(words) { + switch words[i+1] { + case "тебе", "тебя", "за", "тобой": + return true + } + } + return false +} + +// prevWord — the word before i, skipping "не" and punctuation, so "не отдыхала" +// still sees the subject. +func prevWord(words []string, i int) string { + for j := i - 1; j >= 0; j-- { + w := words[j] + if w == "не" || w == "ни" || !unicode.Is(unicode.Cyrillic, []rune(w)[0]) { + continue + } + return w + } + return "" +} + +// --- how she addresses him ------------------------------------------------ +// +// Persona hard constraint: Maven speaks TO him, informally, one to one. The +// phrasing eval produced two breaks of it, and both scored clean: +// +// - "Приходите… Жду вас" — the formal plural. Correct is ты/тебя/тебе and a +// singular imperative ("приходи", "жду тебя"). +// - "Он не ел 11 дней" — she talks ABOUT him, in the third person, as if +// reporting to somebody else. Correct is "ты не ел 11 дней". +// +// Like checkHisGender this is a keyword + suffix heuristic, NOT a parser. Every +// hit prints the word it tripped on, so a false alarm is obvious at a glance and +// can be dismissed. +// +// Part 1, formal address. Two signals: +// - the "вы" pronoun family, matched as whole words, so there is nothing to +// exclude — "вы" and "вас" are never anything else. +// - a plural verb ending: -ите/-ете/-йте/-ьте ("приходите", "выпейте", +// "не забудьте", "хотите"). Nouns in the prepositional case share those +// endings ("в интернете", "в свете"), so a word right after a preposition is +// skipped. That is the whole exclusion list, on purpose: a bigger one would +// start swallowing real imperatives. +// +// Part 2, third person. "он" is perfectly fine when the message really is about +// somebody or something else ("сервис упал, он не отвечает"). The way to tell +// them apart: a legitimate third person has an ANTECEDENT — the thing it refers +// to was named earlier in the message. So "он" is only flagged when nothing +// before it in the message could be that thing. +// +// Where this gives up, plainly: +// - it only looks BACKWARD. "Он не отвечает, сервис упал" names the subject +// after the pronoun and is flagged wrongly. +// - any noun earlier in the message counts as an antecedent, even when it is +// not one ("после обеда он не ел", "выпей воды, он не пил" — both missed). +// Verbs and time words no longer count, which covers the usual nudge, but a +// plain noun before the pronoun still blinds it. The +// common time words are stoplisted so the usual nudge opening does not +// blind it, but a message with any other noun in front still slips through. +// This is the check's real hole; widening it further would start flagging +// legitimate third-party messages, so it stops here. +// - a message that opens with "ты" and only later slips into "он" is missed, +// because "ты" itself is skipped but the words around it are not. +// - formal address outside these endings (short adjectives, "вашими" style +// forms not listed) is missed. + +// addressWordRE also takes Latin words, because "him"/"he" is the same break in +// English. +var addressWordRE = regexp.MustCompile(`[\p{Cyrillic}]+|[a-zA-Z]+|[,.;:!?…—-]`) + +// formalPronouns — the "вы" family. Whole-word match, so no false hits. +var formalPronouns = map[string]bool{ + "вы": true, "вас": true, "вам": true, "вами": true, + "ваш": true, "ваша": true, "ваше": true, "ваши": true, + "вашего": true, "вашей": true, "вашему": true, "вашим": true, + "вашими": true, "вашу": true, +} + +// prepositions — used twice: to skip prepositional-case nouns that look like +// plural verbs, and as words that cannot be what "он" refers to. +var prepositions = map[string]bool{ + "в": true, "во": true, "на": true, "о": true, "об": true, "обо": true, + "при": true, "по": true, "за": true, "из": true, "с": true, "со": true, + "к": true, "ко": true, "до": true, "от": true, "у": true, "над": true, + "под": true, "про": true, "без": true, "для": true, "через": true, +} + +// pluralVerb reports whether a word looks like a plural/formal verb form: +// "приходите", "выпейте", "забудьте", "хотите". +func pluralVerb(w string) bool { + if len([]rune(w)) < 5 { + return false + } + return strings.HasSuffix(w, "ите") || strings.HasSuffix(w, "ете") || + strings.HasSuffix(w, "йте") || strings.HasSuffix(w, "ьте") +} + +// thirdPersonHim — pronouns that would be talking about him instead of to him. +var thirdPersonHim = map[string]bool{ + "он": true, "его": true, "ему": true, "него": true, "нему": true, "ним": true, + "he": true, "him": true, "his": true, +} + +// notAnAntecedent — words that cannot be the thing "он" refers to: pronouns, +// particles, conjunctions, adverbs of time. If only these come before "он", the +// message never named a third party and "он" is him. +var notAnAntecedent = map[string]bool{ + "не": true, "ни": true, "и": true, "а": true, "но": true, "да": true, + "же": true, "бы": true, "ли": true, "вот": true, "уже": true, + "ещё": true, "еще": true, "тоже": true, "там": true, "тут": true, + "здесь": true, "это": true, "что": true, "как": true, "когда": true, + "чтобы": true, "потому": true, "сейчас": true, "потом": true, + // Time words. A nudge almost always opens with one ("сегодня он не ел"), + // and without them the very next word is read as the person being talked + // about, so the check misses the exact break it was written for. + "сегодня": true, "вчера": true, "завтра": true, "послезавтра": true, + "утром": true, "днём": true, "днем": true, "вечером": true, "ночью": true, + "опять": true, "снова": true, "весь": true, "всю": true, "целый": true, + "я": true, "мне": true, "меня": true, "мной": true, "мы": true, "нас": true, + "ты": true, "тебя": true, "тебе": true, "тобой": true, + "твой": true, "твоя": true, "твоё": true, "твое": true, "твои": true, "твою": true, +} + +// looksVerb — a verb is never the thing "он" refers to, so it must not count as +// an antecedent. Past tense keeps "сервис упал, он не отвечает" working off +// "сервис"; the infinitive and imperative endings are here because a nudge is +// mostly made of them ("попробуй встать и отдохнуть — у него есть перерыв" +// slipped through with "попробуй" taken for the person being talked about). +func looksVerb(w string) bool { + r := []rune(w) + if len(r) < 3 { + return false + } + for _, suf := range []string{ + "л", "ла", "ло", "ли", // past tense + "ть", "ться", "ти", "чь", // infinitive + "й", "йся", "йте", // imperative + } { + if strings.HasSuffix(w, suf) { + return true + } + } + return false +} + +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] { + add(fmt.Sprintf("formal %q — she says ты/тебя/тебе", w)) + } + if pluralVerb(w) && !(i > 0 && prepositions[words[i-1]]) { + add(fmt.Sprintf("plural imperative %q — she uses the singular", w)) + } + } + + for i, w := range words { + if !thirdPersonHim[w] { + continue + } + named := false + for j := 0; j < i; j++ { + p := words[j] + if !unicode.Is(unicode.Cyrillic, []rune(p)[0]) && !isLatinWord(p) { + continue // punctuation + } + // 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 { + 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, ""} +} + +func isLatinWord(w string) bool { + r := []rune(w)[0] + return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') +} + +// --- the cringe checks --------------------------------------------------- +// +// "Think Jarvis without the cringe part". DESIGN.md § Non-goals: "Not a +// relationship — mom-tone is a function that makes nudges land, not emotional +// company. Names the drift a warm small model falls into." Each pattern below +// is one shape of that drift. They are deliberately specific: a check that +// flags any warmth at all would make the nudges robotic, which is the other +// failure. + +type cringePattern struct { + // what the pattern is defending against, shown in the failure detail. + why string + pat *regexp.Regexp +} + +var cringePatterns = []cringePattern{ + { + // Endearments. "Not a relationship" — a pet name reframes a nudge as + // intimacy, and the operator asked for mother-like, not girlfriend-like. + why: "pet name / endearment", + // No \b around the Russian alternatives: Go's RE2 \b is ASCII-only and + // never matches at a Cyrillic boundary, so anchoring them would make + // this check silently always pass. + pat: regexp.MustCompile(`(?i)(милый|дорогой|солнышко|солнце моё|солнце мое|зайчик|котик|сладкий|малыш|дружок|родной|любимый|\bhoney\b|\bsweetie\b|\bdarling\b|\bbuddy\b)`), + }, + { + // Emoji. The nudge is spoken aloud; an emoji is either silence or a TTS + // artefact. Also the single loudest cringe signal in a small model. + why: "emoji", + pat: nil, // handled by hasEmoji, ranges don't fit a regexp cleanly + }, + { + // Exclamation pileup. One "!" is emphasis; two is a cheerleader. + why: "more than one exclamation mark", + pat: regexp.MustCompile(`!.*!|!!`), + }, + { + // Fake concern. She has no feelings to report, and reporting them makes + // the nudge about her instead of about the water. + why: "fake concern opener", + pat: regexp.MustCompile(`(?i)(я волну|я беспоко|беспокоюсь|переживаю|я забочусь|я тревож|мне тревожно|i'?m worried)`), + }, + { + // Apologising. The rule decided she speaks. Apologising for a greenlit + // nudge undermines the one thing that makes nudges land. + why: "apology", + pat: regexp.MustCompile(`(?i)(извини|прости|сожалею|прошу прощения|не хочу мешать|не хочу отвлекать|sorry|apolog)`), + }, + { + // Offering emotional support. The explicit "not emotional company" line. + why: "offer of emotional support", + pat: regexp.MustCompile(`(?i)(я рядом|я здесь для теб|ты не один|всё будет хорошо|все будет хорошо|не переживай|я с тобой|обнимаю|я поддерж|держись)`), + }, + { + // Asking how he feels. Turns a one-way nudge into a conversation he now + // owes an answer to — the most reliable way to make him mute it. + why: "asking how he feels", + pat: regexp.MustCompile(`(?i)(как ты\s*[?.!]|как ты себя|как самочувств|как настроение|всё ли в порядке|все ли в порядке|ты в порядке|how are you)`), + }, + { + // Praise for compliance. Rewards make the nudge a training exercise; + // "not a relationship" again, from the other side. + why: "praise / reward framing", + pat: regexp.MustCompile(`(?i)(молодец|умница|ты справ|гордюсь|горжусь|отличная работа|так держать|good job|proud of you)`), + }, +} + +// hasEmoji — the pictographic ranges plus the variation selector. Cyrillic and +// ordinary punctuation are far below all of these. +func hasEmoji(s string) bool { + for _, r := range s { + switch { + case r >= 0x1F000 && r <= 0x1FAFF, + r >= 0x2600 && r <= 0x27BF, + r >= 0x2B00 && r <= 0x2BFF, + r == 0xFE0F, r == 0x203C, r == 0x2049: + return true + } + } + return false +} + +func checkCringe(body string) Result { + for _, c := range cringePatterns { + if c.pat == nil { + if hasEmoji(body) { + return Result{CheckCringe, false, c.why} + } + continue + } + if m := c.pat.FindString(body); m != "" { + return Result{CheckCringe, false, fmt.Sprintf("%s (%q)", c.why, m)} + } + } + return Result{CheckCringe, true, ""} +} + +// 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 wantAny { + if strings.Contains(low, strings.ToLower(want)) { + return Result{CheckOnTopic, true, ""} + } + } + return Result{CheckOnTopic, false, + 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, ""} +} diff --git a/internal/phraser/eval/eval.go b/internal/phraser/eval/eval.go new file mode 100644 index 0000000..f199ebb --- /dev/null +++ b/internal/phraser/eval/eval.go @@ -0,0 +1,334 @@ +// Package eval scores nudge phrasing — the sentences the operator actually +// hears. It is the phrasing counterpart to internal/router/eval. +// +// Why a separate package from phraser: the fixture must be scorable by BOTH +// phrasing paths (the deterministic Stub and the resident model) from outside +// the phraser package, and a _test.go file inside phraser cannot be imported. +// So the fixture is embedded here and the scorer takes a Nudger interface. +// +// Why deterministic checks and not model judgement: the resident model is a +// 0.8B. It cannot grade its own tone. Every check in checks.go is a string or +// length test that a human can read and disagree with. A score here is a claim +// about measurable properties, not about whether a sentence is good. +// +// DESIGN.md § "Rules decide, LLM phrases" is why there is no send/veto signal +// anywhere in this package: the rule already decided she speaks. The phraser +// only words it, so a nudge the model refuses to write is a failure, never a +// legitimate outcome. +package eval + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/store" +) + +//go:embed nudges_v1.json +var fixtureJSON []byte + +// SchemaVersion — the version this package understands. +const SchemaVersion = 1 + +// Case — one nudge situation, as a real tick would present it. The fields are +// the (rule, severity, context) input DESIGN.md names, flattened to JSON. +// +// WantAny is the on-topic contract: at least one of these lowercased fragments +// must appear in the message. A water nudge that never mentions water is a +// failure however charming it reads. Fragments are stems ("вод") so declension +// does not defeat the check, and they list both languages because the Stub is +// still English (see the writeup). +type Case struct { + ID string `json:"id"` + Rule string `json:"rule"` + Severity int `json:"severity"` + + // SinceMinutes — age of the rule's own fact. 0 means "no such fact", which + // is the branch where the phraser has no duration to name. + SinceMinutes int `json:"since_minutes"` + + // FactKey/FactValue/FactSource — the aggregate fact behind the ops rules. + // service_down phrasing reads the key for the service name. + FactKey string `json:"fact_key,omitempty"` + FactValue string `json:"fact_value,omitempty"` + FactSource string `json:"fact_source,omitempty"` + + // QuietHours/CalendarBusy — the bad moments. The gate already let this + // nudge through (ops outranks quiet hours), so the phrasing still has to be + // short and plain rather than apologetic about the timing. + QuietHours bool `json:"quiet_hours,omitempty"` + CalendarBusy bool `json:"calendar_busy,omitempty"` + + WantAny []string `json:"want_any"` + Tags []string `json:"tags,omitempty"` + Note string `json:"note,omitempty"` +} + +// Fixture — the versioned envelope. SchemaVersion gates the loader so an older +// binary refuses a fixture it would misread instead of reporting a wrong score. +type Fixture struct { + SchemaVersion int `json:"schema_version"` + Name string `json:"name"` + ReferenceNow string `json:"reference_now"` + Notes []string `json:"notes"` + Cases []Case `json:"cases"` +} + +// Load returns the embedded fixture. +func Load() (Fixture, error) { + var f Fixture + if err := json.Unmarshal(fixtureJSON, &f); err != nil { + return Fixture{}, fmt.Errorf("parse fixture: %w", err) + } + if f.SchemaVersion != SchemaVersion { + return Fixture{}, fmt.Errorf("fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion) + } + if len(f.Cases) == 0 { + return Fixture{}, fmt.Errorf("fixture has no cases") + } + return f, nil +} + +// Now — the fixture's reference clock, so fact ages are reproducible. +func (f Fixture) Now() (time.Time, error) { + t, err := time.Parse(time.RFC3339, f.ReferenceNow) + if err != nil { + return time.Time{}, fmt.Errorf("parse reference_now %q: %w", f.ReferenceNow, err) + } + return t, nil +} + +// Candidate rebuilds the loop.Candidate a tick would hand the phraser. +func (c Case) Candidate(now time.Time) loop.Candidate { + state := loop.State{ + Now: now, + Facts: map[string]store.Fact{}, + QuietHours: c.QuietHours, + CalendarBusy: c.CalendarBusy, + } + if c.SinceMinutes > 0 { + state.Facts[c.Rule] = store.Fact{ + Key: c.Rule, + Ts: now.Add(-time.Duration(c.SinceMinutes) * time.Minute), + } + } + if c.FactKey != "" { + state.Facts[c.Rule] = store.Fact{ + Key: c.FactKey, + Value: c.FactValue, + Source: c.FactSource, + Ts: now.Add(-time.Duration(c.SinceMinutes) * time.Minute), + } + } + sev := loop.Severity(c.Severity) + return loop.Candidate{ + Rule: loop.Rule{Name: c.Rule, Severity: sev}, + Severity: sev, + State: state, + } +} + +// Nudger — the one thing a phrasing path must do to be scorable. Both +// *phraser.Stub and *phraser.LLMPhraser satisfy it. +type Nudger interface { + PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) +} + +// Outcome — one scored case. Failed lists the check names that did not pass, +// Reasons the human-readable detail. Failed is empty exactly when Pass is true. +type Outcome struct { + Case Case + Body string + Mood string + Err error + Latency time.Duration + Pass bool + Failed []string + Reasons []string +} + +// Report — the aggregate. ByCheck is the useful part: one composite percentage +// hides which property broke, and tuning a prompt needs to know. +type Report struct { + Name string + Total int + Passed int + Errors int + ByCheck map[string]int + ByRule map[string]TagStat + Outcomes []Outcome + P50 time.Duration + P95 time.Duration + Max time.Duration +} + +// TagStat — passed/total for one slice of the fixture. +type TagStat struct{ Passed, Total int } + +// Accuracy — fraction of cases that passed every check. +func (r Report) Accuracy() float64 { + if r.Total == 0 { + return 0 + } + return float64(r.Passed) / float64(r.Total) +} + +// Score runs every case through p and aggregates. A phrasing error scores as a +// miss and is counted in Errors — "the model was down" and "the model wrote +// something bad" are different numbers and a prompt change must not be able to +// hide behind the first one. +// +// Latency is wall-clock per PhraseNudge call. On the CPU/iGPU target a nudge +// the model takes a minute to word has already missed its moment. +func Score(ctx context.Context, name string, p Nudger, f Fixture) (Report, error) { + now, err := f.Now() + if err != nil { + return Report{}, err + } + rep := Report{ + Name: name, + Total: len(f.Cases), + ByCheck: map[string]int{}, + ByRule: map[string]TagStat{}, + } + for _, name := range CheckNames { + rep.ByCheck[name] = 0 + } + lat := make([]time.Duration, 0, len(f.Cases)) + + for _, c := range f.Cases { + start := time.Now() + pn, err := p.PhraseNudge(ctx, c.Candidate(now)) + o := Outcome{Case: c, Body: pn.Body, Mood: pn.Mood, 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 RunChecks(c, pn.Body, pn.Mood) { + 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.ByRule, ruleFamily(c.Rule), 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 +} + +// ruleFamily collapses "routine:зарядка" to "routine" so the per-rule table +// stays readable however many routines the operator configures. +func ruleFamily(rule string) string { + if i := strings.IndexByte(rule, ':'); i > 0 { + return rule[:i] + } + return rule +} + +func bump(m map[string]TagStat, key string, pass bool) { + if key == "" { + return + } + s := m[key] + s.Total++ + if pass { + s.Passed++ + } + m[key] = s +} + +// percentile — nearest-rank on a pre-sorted slice. No interpolation: with ~15 +// samples an interpolated p95 invents a latency no call actually took. +func percentile(sorted []time.Duration, p float64) time.Duration { + if len(sorted) == 0 { + return 0 + } + i := int(p * float64(len(sorted))) + if i >= len(sorted) { + i = len(sorted) - 1 + } + return sorted[i] +} + +// String renders the comparison table — composite score, then per-check so a +// regression names the property it broke, then latency. +func (r Report) 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 CheckNames { + 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 rule: %s\n", renderStats(r.ByRule)) + return b.String() +} + +// Failures — per-case detail, sorted by ID so two runs diff cleanly. +func (r Report) 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.Body, strings.Join(o.Reasons, "; ")) + } + return b.String() +} + +// Messages — every generated message verbatim, pass or fail. This is what a +// human reads to judge tone; the score only says which checks fired. +func (r Report) Messages() string { + var b strings.Builder + for _, o := range r.sorted() { + mark := "ok " + if !o.Pass { + mark = "FAIL" + } + fmt.Fprintf(&b, " %s %-22s [%s] %q\n", mark, o.Case.ID, o.Mood, o.Body) + } + return b.String() +} + +func (r Report) sorted() []Outcome { + out := append([]Outcome(nil), r.Outcomes...) + sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID }) + return out +} + +func renderStats(m map[string]TagStat) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s %d/%d", k, m[k].Passed, m[k].Total)) + } + return strings.Join(parts, " ") +} diff --git a/internal/phraser/eval/eval_test.go b/internal/phraser/eval/eval_test.go new file mode 100644 index 0000000..3342793 --- /dev/null +++ b/internal/phraser/eval/eval_test.go @@ -0,0 +1,223 @@ +package eval + +import ( + "context" + "strings" + "testing" + + "github.com/kami/maven/internal/loop" + "github.com/kami/maven/internal/phraser" +) + +func TestLoadFixture(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if _, err := f.Now(); err != nil { + t.Fatalf("Now: %v", err) + } + seen := map[string]bool{} + for _, c := range f.Cases { + if seen[c.ID] { + t.Errorf("duplicate case id %q", c.ID) + } + seen[c.ID] = true + if c.Rule == "" || c.Severity < 1 || c.Severity > 4 { + t.Errorf("%s: rule %q severity %d", c.ID, c.Rule, c.Severity) + } + if len(c.WantAny) == 0 { + t.Errorf("%s: no want_any, the on-topic check would always pass", c.ID) + } + } + // Coverage floor: all five loop rules plus both minted families, or the + // fixture measures a subset and the score does not mean what it says. + for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning"} { + found := false + for _, c := range f.Cases { + if ruleFamily(c.Rule) == rule { + found = true + } + } + if !found { + t.Errorf("no case for rule family %q", rule) + } + } +} + +// TestStubBaseline is the CI ratchet: the deterministic Stub, no model, no +// network. The floor is low on purpose — the Stub is English template phrasing, +// so it fails `lang` on every case by construction. The point of the ratchet is +// that the checks keep running and the Stub does not get worse, not that the +// Stub is good. +func TestStubBaseline(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + rep, err := Score(context.Background(), "stub (deterministic floor)", phraser.NewStub(), f) + if err != nil { + t.Fatalf("Score: %v", err) + } + t.Log("\n" + rep.String() + rep.Messages()) + + if rep.Errors != 0 { + t.Errorf("stub returned %d errors — the deterministic path must never fail", rep.Errors) + } + // Per-check ratchets rather than one composite: the Stub's composite is 0 + // (it never passes `lang`), so a composite floor would catch nothing. + floors := map[string]int{ + CheckMood: 15, + // 12, not 15: the Stub's `break` template genuinely runs past the + // ceiling ("you've been at your desk for 4 hours without a break — step + // away for a bit." is 76 chars but 16 words). Left failing rather than + // raising the ceiling to hide it. + CheckLength: 12, + CheckFeminine: 15, + CheckHisGender: 15, + CheckAddress: 15, + CheckCringe: 15, + CheckOnTopic: 12, + } + for name, floor := range floors { + if rep.ByCheck[name] < floor { + t.Errorf("check %s: %d/%d, below ratchet %d — phrasing regressed", + name, rep.ByCheck[name], rep.Total, floor) + } + } +} + +// TestChecksCatchWhatTheyClaim — the checks are the measurement, so they get +// their own tests. Without these, a bad regexp would silently make every +// phrasing run look clean. +func TestChecksCatchWhatTheyClaim(t *testing.T) { + water := Case{Rule: "water", WantAny: []string{"вод"}} + + cases := []struct { + name string + body string + want string // the check that must fail, "" for a clean message + }{ + {"clean", "уже четыре часа без воды — попей.", ""}, + {"long", "уже четыре часа без воды, а это довольно много, и вообще пить надо регулярно, иначе будет плохо совсем", CheckLength}, + {"english", "you haven't had water in 4 hours, drink something", CheckLang}, + {"masculine self", "я напомнил про воду.", CheckFeminine}, + {"masculine dropped pronoun", "напомнил тебе про воду.", CheckFeminine}, + {"masculine predicative", "я должен сказать: попей воды.", CheckFeminine}, + // The other direction: HE is male, so second-person masculine is right. + {"second person masculine ok", "ты не пил воду четыре часа.", ""}, + // The real observed failure: she addressed him as a woman. + {"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender}, + {"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender}, + // Her own feminine verb next to "ты" is correct and must not be flagged. + {"her feminine verb near ты", "я заметила, что ты не пил воду.", ""}, + {"her feminine verb about him", "напомнила тебе про воду.", ""}, + // A feminine noun subject in the same sentence is not him. + {"feminine noun subject ok", "зарядка была утром, ты её пропустил, попей воды.", ""}, + {"feminine self ok", "я заметила: воды не было четыре часа.", ""}, + {"pet name", "милый, попей воды.", CheckCringe}, + {"emoji", "попей воды 💧", CheckCringe}, + {"exclamations", "попей воды!!", CheckCringe}, + {"fake concern", "я беспокоюсь: воды не было четыре часа.", CheckCringe}, + {"apology", "извини, что отвлекаю — попей воды.", CheckCringe}, + {"emotional support", "я рядом, ты не один. попей воды.", CheckCringe}, + {"asks how he feels", "как ты себя чувствуешь? попей воды.", CheckCringe}, + {"praise", "молодец! теперь попей воды.", CheckCringe}, + {"off topic", "пора бы уже что-то сделать.", CheckOnTopic}, + // The two recorded persona breaks from the phrasing eval run. Pinned as + // unit tests because an eval run is sampled and may not reproduce them. + {"formal plural", "Приходите… Жду вас", CheckAddress}, + {"third person about him", "Он не ел 11 дней", CheckAddress}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var failed []string + for _, r := range RunChecks(water, tc.body, "neutral") { + if !r.Pass { + failed = append(failed, r.Name+"("+r.Detail+")") + } + } + joined := strings.Join(failed, " ") + switch { + case tc.want == "" && len(failed) > 0: + t.Errorf("clean message flagged: %s", joined) + case tc.want != "" && !strings.Contains(joined, tc.want+"("): + t.Errorf("want %s to fail, got %q", tc.want, joined) + } + }) + } +} + +// TestAddressCheck — the address check on its own, so the messages that must NOT +// trip it can be written without also having to satisfy the on-topic check. +func TestAddressCheck(t *testing.T) { + bad := []string{ + "Приходите… Жду вас", // the recorded formal-plural break + "Он не ел 11 дней", // the recorded third-person break + "Выпейте воды, пожалуйста.", // plural imperative on its own + "Ваш обед был давно.", // formal possessive + } + for _, body := range bad { + if r := checkAddress(body); r.Pass { + t.Errorf("persona break not caught: %q", body) + } else { + t.Logf("%q -> %s", body, r.Detail) + } + } + + good := []string{ + "ты не пил воду четыре часа — попей.", // correct informal address + "сервис netdata упал, он не отвечает.", // legitimately about a third party + "я заметила, что зарядка была утром.", // no address at all + "в интернете опять тихо, всё работает.", // "интернете" is a noun, not an imperative + } + for _, body := range good { + if r := checkAddress(body); !r.Pass { + t.Errorf("clean message flagged: %q -> %s", body, r.Detail) + } + } +} + +func TestMoodCheckUsesTheEnum(t *testing.T) { + if r := checkMood("cheerful"); r.Pass { + t.Error("mood outside the enum passed") + } + if r := checkMood(""); r.Pass { + t.Error("empty mood passed") + } + for m := range Moods { + if r := checkMood(m); !r.Pass { + t.Errorf("enum mood %q failed", m) + } + } +} + +// TestCandidateCarriesTheContext — the whole harness is worthless if the +// Candidate it builds does not carry the duration the prompt is supposed to +// name. +func TestCandidateCarriesTheContext(t *testing.T) { + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + now, _ := f.Now() + for _, c := range f.Cases { + cand := c.Candidate(now) + if cand.Rule.Name != c.Rule || cand.Severity != loop.Severity(c.Severity) { + t.Errorf("%s: candidate lost rule or severity", c.ID) + } + if c.SinceMinutes > 0 { + d, ok := cand.State.Since(c.Rule) + if !ok || int(d.Minutes()) != c.SinceMinutes { + t.Errorf("%s: since %v ok=%v, want %d minutes", c.ID, d, ok, c.SinceMinutes) + } + } + if c.FactKey != "" { + fact, ok := cand.State.Fact(c.Rule) + if !ok || fact.Key != c.FactKey { + t.Errorf("%s: fact key %q, want %q", c.ID, fact.Key, c.FactKey) + } + } + } +} diff --git a/internal/phraser/eval/llmphraser_test.go b/internal/phraser/eval/llmphraser_test.go new file mode 100644 index 0000000..bca5005 --- /dev/null +++ b/internal/phraser/eval/llmphraser_test.go @@ -0,0 +1,91 @@ +package eval + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/llm" + "github.com/kami/maven/internal/persona" + "github.com/kami/maven/internal/phraser" +) + +// TestLLMPhrasingBaseline — the resident model wording real nudges. Opt-in, +// same shape as internal/router/eval's MAVEN_LLM_URL gate, because CI has no +// model and a phrasing run costs minutes on the CPU target. +// +// llama-server -m /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf \ +// --host 127.0.0.1 --port 18099 -c 2048 -ngl 99 +// MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing +// +// It reports and does not assert a quality bar. The numbers are the input to +// tuning the persona prompt; an assertion here would be the test inventing the +// bar rather than measuring against it. The one thing worth failing on is a +// harness fault — every case erroring means the run measured infrastructure. +func TestLLMPhrasingBaseline(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)") + } + // A local llama-server must not go through an HTTP proxy. This box proxies + // loopback through a SOCKS bridge that answers 503, which would score every + // case as a phrasing error and read as "the model cannot phrase". + noProxyLoopback(t) + + ctx := context.Background() + f, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + + cfg := phraser.DefaultConfig("") + // 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() + + // Label the run with whatever gguf the server actually has loaded. It used + // to say "0.8B" no matter what, so two runs of two different models came + // out named the same and were easy to mix up when comparing. + model, err := llm.ModelID(ctx, base) + if err != nil { + // An unlabelled score is still a score, but say so loudly — a made-up + // name in a bake-off table is worse than no name. + t.Logf("could not read model id from %s: %v — report will say %q", base, err, llm.UnknownModel) + model = llm.UnknownModel + } + t.Logf("scoring model %s at %s", model, base) + + rep, err := Score(ctx, "llm ("+model+", built-in persona)", p, f) + if err != nil { + t.Fatalf("Score: %v", err) + } + t.Log("\n" + rep.String() + "\nmessages:\n" + rep.Messages() + "\nfailures:\n" + rep.Failures()) + + if rep.Errors == rep.Total { + t.Errorf("all %d cases errored — harness fault, not a measurement", rep.Total) + } +} + +// noProxyLoopback appends the loopback host to no_proxy before any request, so +// http.ProxyFromEnvironment (which caches the environment on first use) sees it. +func noProxyLoopback(t *testing.T) { + t.Helper() + for _, key := range []string{"no_proxy", "NO_PROXY"} { + cur := os.Getenv(key) + if strings.Contains(cur, "127.0.0.1") { + continue + } + if cur == "" { + t.Setenv(key, "127.0.0.1,localhost") + continue + } + t.Setenv(key, cur+",127.0.0.1,localhost") + } +} diff --git a/internal/phraser/eval/nudges_v1.json b/internal/phraser/eval/nudges_v1.json new file mode 100644 index 0000000..8f8e7d8 --- /dev/null +++ b/internal/phraser/eval/nudges_v1.json @@ -0,0 +1,158 @@ +{ + "schema_version": 1, + "name": "nudge phrasing v1", + "reference_now": "2026-07-31T21:40:00+03:00", + "notes": [ + "The five loop rules (water, meal, break, service_down, netdata_critical) plus one routine: and one morning: case, at the severities they actually ship with.", + "The bad-moment cases (quiet_hours, calendar_busy) are here because the gate already let them through — ops outranks quiet hours. The phrasing must stay short and plain, not apologise for the timing.", + "want_any lists stems, not whole words, so declension does not defeat the on-topic check. English stems are included because the deterministic Stub is still English.", + "There is no expected message. The checks measure properties, not similarity to a reference sentence — a fixed golden string would just freeze one arbitrary phrasing." + ], + "cases": [ + { + "id": "water-3h", + "rule": "water", + "severity": 1, + "since_minutes": 190, + "want_any": ["вод", "попей", "пить", "выпей", "напит", "water", "drink"], + "tags": ["care"], + "note": "The base case. Just over the 3h predicate." + }, + { + "id": "water-7h", + "rule": "water", + "severity": 1, + "since_minutes": 430, + "want_any": ["вод", "попей", "пить", "выпей", "напит", "water", "drink"], + "tags": ["care"], + "note": "Long overdue. Severity is unchanged, so the phrasing must not escalate into alarm." + }, + { + "id": "water-busy", + "rule": "water", + "severity": 1, + "since_minutes": 240, + "calendar_busy": true, + "want_any": ["вод", "попей", "пить", "выпей", "напит", "water", "drink"], + "tags": ["care", "bad-moment"], + "note": "Mid-meeting. A bad moment invites an apology, which is the check that should catch it." + }, + { + "id": "meal-7h", + "rule": "meal", + "severity": 1, + "since_minutes": 420, + "want_any": ["ешь", "еда", "еды", "поешь", "перекус", "обед", "ужин", "покуш", "food", "eat"], + "tags": ["care"] + }, + { + "id": "meal-11h-quiet", + "rule": "meal", + "severity": 1, + "since_minutes": 660, + "quiet_hours": true, + "want_any": ["ешь", "еда", "еды", "поешь", "перекус", "обед", "ужин", "покуш", "food", "eat"], + "tags": ["care", "bad-moment"], + "note": "Quiet hours suppresses care nudges in the gate, so this one only reaches the phraser via an explicit override. Included because it is the shape most likely to draw a hedge." + }, + { + "id": "break-90m", + "rule": "break", + "severity": 2, + "since_minutes": 95, + "want_any": ["перерыв", "разомн", "встань", "отдохн", "пауз", "отойд", "размин", "break", "step away"], + "tags": ["care"] + }, + { + "id": "break-4h", + "rule": "break", + "severity": 2, + "since_minutes": 240, + "want_any": ["перерыв", "разомн", "встань", "отдохн", "пауз", "отойд", "размин", "break", "step away"], + "tags": ["care"] + }, + { + "id": "break-busy", + "rule": "break", + "severity": 2, + "since_minutes": 150, + "calendar_busy": true, + "want_any": ["перерыв", "разомн", "встань", "отдохн", "пауз", "отойд", "размин", "break", "step away"], + "tags": ["care", "bad-moment"] + }, + { + "id": "service-down", + "rule": "service_down", + "severity": 4, + "since_minutes": 3, + "fact_key": "vaultwarden", + "fact_value": "\"down\"", + "fact_source": "poll:uptimekuma", + "want_any": ["vaultwarden", "сервис", "упал", "не отвеч", "лежит", "недоступ", "down", "service"], + "tags": ["ops"], + "note": "The service name is in the fact key, not the value. A nudge that says 'a service' without naming it is on-topic but useless — the on-topic check cannot catch that, a human reading Messages() can." + }, + { + "id": "service-down-night", + "rule": "service_down", + "severity": 4, + "since_minutes": 2, + "quiet_hours": true, + "fact_key": "nextcloud", + "fact_value": "\"down\"", + "fact_source": "poll:uptimekuma", + "want_any": ["nextcloud", "сервис", "упал", "не отвеч", "лежит", "недоступ", "down", "service"], + "tags": ["ops", "bad-moment"], + "note": "03:00-shaped. Sev4 outranks quiet hours by design, so she speaks — plainly, without softening it into a maybe." + }, + { + "id": "netdata-disk", + "rule": "netdata_critical", + "severity": 3, + "since_minutes": 5, + "fact_key": "netdata_alarm", + "fact_value": "\"critical\"", + "fact_source": "poll:netdata", + "want_any": ["netdata", "диск", "критич", "алярм", "аларм", "тревог", "место", "памят", "critical", "alarm", "disk"], + "tags": ["ops"] + }, + { + "id": "netdata-busy", + "rule": "netdata_critical", + "severity": 3, + "since_minutes": 12, + "calendar_busy": true, + "fact_key": "netdata_alarm", + "fact_value": "\"critical\"", + "fact_source": "poll:netdata", + "want_any": ["netdata", "диск", "критич", "алярм", "аларм", "тревог", "место", "памят", "critical", "alarm", "disk"], + "tags": ["ops", "bad-moment"] + }, + { + "id": "routine-pills", + "rule": "routine:таблетки", + "severity": 2, + "since_minutes": 0, + "want_any": ["таблетк", "приня", "лекарств", "pill"], + "tags": ["routine"], + "note": "A routine: rule has no fact of its own, so there is no duration to name. The rule name is the only context." + }, + { + "id": "routine-stretch", + "rule": "routine:зарядка", + "severity": 1, + "since_minutes": 0, + "want_any": ["зарядк", "размин", "упражн", "потянис", "разомн", "stretch", "exercise"], + "tags": ["routine"] + }, + { + "id": "morning-checklist", + "rule": "morning:утро", + "severity": 2, + "since_minutes": 0, + "want_any": ["утр", "чеклист", "список", "не сделан", "осталось", "morning"], + "tags": ["routine"], + "note": "In production cmd/mavend/tick.go phrases morning routines deterministically and never calls the LLM. Scored anyway: the phraser is reachable with this rule name, and a fallback that garbles it is still a bug." + } + ] +} diff --git a/internal/phraser/eval/talk.go b/internal/phraser/eval/talk.go new file mode 100644 index 0000000..2d071a7 --- /dev/null +++ b/internal/phraser/eval/talk.go @@ -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 +} diff --git a/internal/phraser/eval/talk_test.go b/internal/phraser/eval/talk_test.go new file mode 100644 index 0000000..dcff579 --- /dev/null +++ b/internal/phraser/eval/talk_test.go @@ -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) + } +} diff --git a/internal/phraser/eval/talk_v1.json b/internal/phraser/eval/talk_v1.json new file mode 100644 index 0000000..64b428b --- /dev/null +++ b/internal/phraser/eval/talk_v1.json @@ -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"] + } + ] +} diff --git a/internal/phraser/eval/templates_test.go b/internal/phraser/eval/templates_test.go new file mode 100644 index 0000000..523ee65 --- /dev/null +++ b/internal/phraser/eval/templates_test.go @@ -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()) + } + } +} diff --git a/internal/phraser/grammar_test.go b/internal/phraser/grammar_test.go new file mode 100644 index 0000000..2117c98 --- /dev/null +++ b/internal/phraser/grammar_test.go @@ -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) + } + } + } +} diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index 90879ab..1bf1eb9 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -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() @@ -67,6 +97,35 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) { return p, nil } +// NewLLMPhraserAt wires a phraser to a llama-server that someone else started +// and owns. It spawns nothing, so Close does not kill anything. +// +// This exists for the phrasing scorer (internal/phraser/eval), which must +// measure the phrasing against a shared llama-server without taking the model +// load hit per run or killing a server another process depends on. The daemon +// still uses NewLLMPhraser and still owns its own child process. +func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser { + return &LLMPhraser{ + cfg: cfg, + 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, @@ -157,18 +216,30 @@ 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) } if body == "" { - body = fmt.Sprintf("%s — %s", c.Rule.Name, sevLabel(c.Severity)) + // The model said nothing usable. Say it in Russian anyway — this text + // goes straight to a Russian piper voice, so the old "water — care" + // fallback was unspeakable. + body = fallbackNudge(c) } if mood == "" { mood = "neutral" @@ -183,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 @@ -199,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 @@ -219,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}, } @@ -232,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 @@ -248,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 @@ -269,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 { @@ -320,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) @@ -348,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": "", "mood": ""}. +// +// 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 { @@ -372,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 { @@ -412,40 +541,157 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma return stripThink(content), nil } +// nudgeSystem — the phrasing contract for nudges. +// +// Written as filled-in examples, not as a schema with "..." in it. A 0.8B +// copies whatever sits in the response slot, so a literal placeholder there +// teaches it to answer with the placeholder. Measured: 7/15 nudges came back +// as "..." before this. See PHRASING-EVAL-31-07-2026.md. +// +// 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. +const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл"). +Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём. + +Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу. + +Запрещено: обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов. + +Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood". +"response" — сам текст напоминания. +"mood" — ровно одно из: neutral, happy, thinking, tired, confused. + +Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет: +{"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"} +{"response": "Ноутбук на трёх процентах. Я поставила его на зарядку.", "mood": "confused"} + +Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.` + func (p *LLMPhraser) systemPrompt() string { - base := `You are maven, a self-hosted personal assistant. Generate brief, natural nudge messages in the user's language (Russian or English). Respond ONLY with valid JSON: {"response": "full voice message", "mood": "neutral"}. "response" is what the user hears; "mood" reflects maven's tone (neutral/happy/thinking/tired/confused).` - 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 + // 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 +// English identifiers; a 0.8B asked to nudge about "netdata_critical" writes +// about nothing. The daemon knows what its own rules mean, so it says so. +var ruleTopics = map[string]string{ + "water": "он давно не пил воду", + "meal": "он давно не ел", + "break": "он давно без перерыва, пора встать и размяться", + "service_down": "сервис не отвечает, лежит", + "netdata_critical": "критический алярм в netdata, проблема с диском или местом", +} + +// ruleKeywords — the word the message must contain. The 0.8B drifts to +// whatever topic it saw last unless the required word is named outright. +var ruleKeywords = map[string]string{ + "water": "воду", + "meal": "поешь", + "break": "перерыв", + "service_down": "сервис", + "netdata_critical": "диск", +} + +// ruleTopic turns a rule name into a Russian description of the situation. +// "routine:зарядка" and "morning:утро" carry their own Russian suffix. +func ruleTopic(rule string) string { + if t, ok := ruleTopics[rule]; ok { + return t } - return base + if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) { + switch rule[:i] { + case "morning": + return "утро, пора начать день: " + rule[i+1:] + default: + return "пора сделать по распорядку: " + rule[i+1:] + } + } + return rule +} + +// ruleKeyword — the word the nudge must contain, or "" when the rule name's +// own Russian suffix already is that word. +func ruleKeyword(rule string) string { + if k, ok := ruleKeywords[rule]; ok { + return k + } + if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) { + return rule[i+1:] + } + return "" +} + +// ruDur — duration in Russian. humanDur is English and its output was landing +// verbatim in the message. +func ruDur(d time.Duration) string { + if d < 0 { + d = 0 + } + h, m := int(d.Hours()), int(d.Minutes())%60 + switch { + case h >= 2: + return fmt.Sprintf("%d ч", h) + case h == 1 && m >= 30: + return "полтора часа" + case h == 1: + return "час" + default: + return fmt.Sprintf("%d мин", m) + } +} + +// fallbackNudge — plain Russian for when the model returns nothing parseable. +var fallbackNudges = map[string]string{ + "water": "Ты давно не пил воду.", + "meal": "Ты давно не ел, поешь.", + "break": "Пора сделать перерыв.", + "service_down": "Сервис не отвечает.", + "netdata_critical": "Критический алярм: проверь диск.", +} + +func fallbackNudge(c loop.Candidate) string { + if s, ok := fallbackNudges[c.Rule.Name]; ok { + return s + } + if kw := ruleKeyword(c.Rule.Name); kw != "" { + return "Напоминаю: " + kw + "." + } + return "Напоминаю о деле." } func buildNudgePrompt(c loop.Candidate) string { var ctxParts []string - ctxParts = append(ctxParts, fmt.Sprintf("Rule: %s", c.Rule.Name)) - ctxParts = append(ctxParts, fmt.Sprintf("Severity: %s", sevLabel(c.Severity))) - + ctxParts = append(ctxParts, "Ситуация: "+ruleTopic(c.Rule.Name)) + if f, ok := c.State.Facts[c.Rule.Name]; ok && f.Key != "" && f.Key != c.Rule.Name { + ctxParts = append(ctxParts, "Что именно: "+f.Key) + } if d, ok := c.State.Since(c.Rule.Name); ok { - ctxParts = append(ctxParts, fmt.Sprintf("Duration since last event: %s", humanDur(d))) + ctxParts = append(ctxParts, "Прошло: "+ruDur(d)) + } + switch sevLabel(c.Severity) { + case "alarm": + ctxParts = append(ctxParts, "Срочно, скажи прямо.") + case "ops": + ctxParts = append(ctxParts, "Это про сервер, не про здоровье.") + } + tail := "Напиши напоминание про эту ситуацию. Одно предложение, по-русски, в JSON." + if kw := ruleKeyword(c.Rule.Name); kw != "" { + // Last line on purpose: a 0.8B weights the end of the prompt hardest, + // and without the required word it drifts back to the examples. + tail += " Ответ ДОЛЖЕН содержать слово «" + kw + "»." } - return fmt.Sprintf( - `Generate a nudge message. Context: -%s - -Respond as JSON: {"response": "...", "mood": "..."}`, - strings.Join(ctxParts, "\n"), - ) + return strings.Join(ctxParts, "\n") + "\n\n" + tail } type responseMood struct { @@ -453,21 +699,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) { diff --git a/internal/phraser/nudge_templates.go b/internal/phraser/nudge_templates.go new file mode 100644 index 0000000..bb9027f --- /dev/null +++ b/internal/phraser/nudge_templates.go @@ -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) +} diff --git a/internal/phraser/nudge_templates_test.go b/internal/phraser/nudge_templates_test.go new file mode 100644 index 0000000..c33bd6a --- /dev/null +++ b/internal/phraser/nudge_templates_test.go @@ -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) + } +} diff --git a/internal/phraser/nudges_ru_v1.json b/internal/phraser/nudges_ru_v1.json new file mode 100644 index 0000000..cb2201b --- /dev/null +++ b/internal/phraser/nudges_ru_v1.json @@ -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": [ + "Напоминаю: есть дело.", + "Пора вернуться к отложенному делу.", + "Одно дело ждёт тебя.", + "Напоминаю про дело из списка.", + "В списке осталось дело.", + "Дело всё ещё не сделано." + ] + } + } +} diff --git a/internal/phraser/phraser.go b/internal/phraser/phraser.go index cd25abe..09db1f8 100644 --- a/internal/phraser/phraser.go +++ b/internal/phraser/phraser.go @@ -92,7 +92,11 @@ func (s *Stub) Close() error { return nil } // the predicate fire (the same State the predicate saw). func (s *Stub) PhraseNudge(_ context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) { body, summary := phraseNudge(c) - return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: summary}, nil + // "neutral" rather than empty: Mood is part of the documented output + // contract and the Stub is a production fallback, so it must satisfy the + // contract too. Template phrasing has no tone to report, and neutral is the + // enum's own default. + return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: summary, Mood: "neutral"}, nil } // PhraseReminder — extracts the user's text from the reminder payload (raw diff --git a/internal/router/dateparser_test.go b/internal/router/dateparser_test.go index cb98073..b5f8141 100644 --- a/internal/router/dateparser_test.go +++ b/internal/router/dateparser_test.go @@ -26,10 +26,10 @@ func TestPythonDateParser(t *testing.T) { ctx := context.Background() tests := []struct { - name string - text string - wantOK bool - checkT func(t *testing.T, got, now time.Time) + name string + text string + wantOK bool + checkT func(t *testing.T, got, now time.Time) }{ { name: "ru relative — через час", diff --git a/internal/router/embedder.go b/internal/router/embedder.go index e9c0de1..24e3eca 100644 --- a/internal/router/embedder.go +++ b/internal/router/embedder.go @@ -2,6 +2,7 @@ package router import ( "context" + "fmt" "math" "unicode" ) @@ -19,6 +20,55 @@ type Embedder interface { Close() error } +// IdentifiedEmbedder — an embedder that can name itself. The name goes into +// the DB next to the vectors it wrote, so a later model swap is caught instead +// of silently returning nonsense scores (Vikunja #378). +type IdentifiedEmbedder interface { + Embedder + ID() string +} + +// EmbedderID is the stable string stored alongside the vectors. It comes from +// the embedder itself — nobody hand-types a model name twice — and changes +// whenever the model or its dimension changes. +func EmbedderID(e Embedder) string { + if i, ok := e.(IdentifiedEmbedder); ok { + return i.ID() + } + return fmt.Sprintf("unknown@%d", e.Dim()) +} + +// AsymmetricEmbedder — an embedder that wants to know whether a text is a +// search query or a stored passage. Recall is asymmetric: a short question +// goes in, a longer note comes out. The e5 family is trained for exactly that +// and needs the side written into the text ("query: " / "passage: "). +// +// Optional on purpose: HashEmbedder has no such notion, so callers go through +// EmbedQuery and EmbedPassage below, which fall back to plain Embed. +type AsymmetricEmbedder interface { + Embedder + EmbedQuery(ctx context.Context, text string) ([]float32, error) + EmbedPassage(ctx context.Context, text string) ([]float32, error) +} + +// EmbedQuery embeds text that is being searched WITH — a question. +func EmbedQuery(ctx context.Context, e Embedder, text string) ([]float32, error) { + if a, ok := e.(AsymmetricEmbedder); ok { + return a.EmbedQuery(ctx, text) + } + return e.Embed(ctx, text) +} + +// EmbedPassage embeds text that is being searched FOR — a note or a fact on +// its way into the store. Store and lookup must use these two calls, not one +// of them twice, or the asymmetry buys nothing. +func EmbedPassage(ctx context.Context, e Embedder, text string) ([]float32, error) { + if a, ok := e.(AsymmetricEmbedder); ok { + return a.EmbedPassage(ctx, text) + } + return e.Embed(ctx, text) +} + // HashEmbedder — a deterministic bag-of-words embedder used for tests and as a // non-zero default floor. NOT semantically meaningful across languages; the // real classifier swaps in the multilingual ONNX model wholesale. @@ -39,6 +89,10 @@ func NewHashEmbedder(dim int) *HashEmbedder { func (h *HashEmbedder) Dim() int { return h.dim } +// ID names this embedder for the DB marker. The dimension is part of it +// because a HashEmbedder of another width is a different vector space. +func (h *HashEmbedder) ID() string { return fmt.Sprintf("hash@%d", h.dim) } + func (h *HashEmbedder) Close() error { return nil } func (h *HashEmbedder) Embed(_ context.Context, text string) ([]float32, error) { diff --git a/internal/router/embedder_test.go b/internal/router/embedder_test.go index 5f815b1..f4ef243 100644 --- a/internal/router/embedder_test.go +++ b/internal/router/embedder_test.go @@ -37,3 +37,60 @@ func TestHashEmbedderCyrillic(t *testing.T) { t.Fatalf("cosine(shared)=%.3f not > cosine(disjoint)=%.3f", cosine(a, b), cosine(a, c)) } } + +// recordingEmbedder — an asymmetric embedder that only remembers which side +// was asked for. Enough to pin the dispatch; real vectors need the model. +type recordingEmbedder struct{ calls []string } + +func (r *recordingEmbedder) Dim() int { return 2 } +func (r *recordingEmbedder) Close() error { return nil } + +func (r *recordingEmbedder) Embed(_ context.Context, _ string) ([]float32, error) { + r.calls = append(r.calls, "embed") + return []float32{1, 0}, nil +} + +func (r *recordingEmbedder) EmbedQuery(_ context.Context, _ string) ([]float32, error) { + r.calls = append(r.calls, "query") + return []float32{1, 0}, nil +} + +func (r *recordingEmbedder) EmbedPassage(_ context.Context, _ string) ([]float32, error) { + r.calls = append(r.calls, "passage") + return []float32{0, 1}, nil +} + +// TestEmbedQueryAndPassageSplit — a question and a stored note must not take +// the same path. If both ended up on the same call the asymmetric model buys +// nothing, which is the whole reason for the swap. +func TestEmbedQueryAndPassageSplit(t *testing.T) { + rec := &recordingEmbedder{} + if _, err := EmbedQuery(context.Background(), rec, "где логи?"); err != nil { + t.Fatalf("EmbedQuery: %v", err) + } + if _, err := EmbedPassage(context.Background(), rec, "логи в /var/log"); err != nil { + t.Fatalf("EmbedPassage: %v", err) + } + if len(rec.calls) != 2 || rec.calls[0] != "query" || rec.calls[1] != "passage" { + t.Errorf("calls %v, want [query passage]", rec.calls) + } +} + +// TestEmbedFallsBackToPlainEmbed — HashEmbedder has no sides, so both helpers +// must still work and give the same vector. +func TestEmbedFallsBackToPlainEmbed(t *testing.T) { + h := NewHashEmbedder(64) + q, err := EmbedQuery(context.Background(), h, "text") + if err != nil { + t.Fatalf("EmbedQuery: %v", err) + } + p, err := EmbedPassage(context.Background(), h, "text") + if err != nil { + t.Fatalf("EmbedPassage: %v", err) + } + for i := range q { + if q[i] != p[i] { + t.Fatalf("hash embedder gave two different vectors for the same text") + } + } +} diff --git a/internal/router/embedderid_test.go b/internal/router/embedderid_test.go new file mode 100644 index 0000000..03f1e16 --- /dev/null +++ b/internal/router/embedderid_test.go @@ -0,0 +1,24 @@ +package router + +import "testing" + +func TestEmbedderIDFromModelPath(t *testing.T) { + got := modelIDFromPath("/opt/maven/models/embedder/multilingual-e5-small.onnx") + if got != "multilingual-e5-small@384" { + t.Fatalf("modelIDFromPath = %q", got) + } + // A different model file must produce a different id, even at 384 dim. + old := modelIDFromPath("/opt/maven/models/embedder/paraphrase-multilingual-MiniLM-L12-v2.onnx") + if old == got { + t.Fatal("two different models share one id") + } +} + +func TestEmbedderIDIncludesDim(t *testing.T) { + if id := EmbedderID(NewHashEmbedder(1024)); id != "hash@1024" { + t.Fatalf("EmbedderID = %q", id) + } + if EmbedderID(NewHashEmbedder(1024)) == EmbedderID(NewHashEmbedder(384)) { + t.Fatal("dimension not part of the id") + } +} diff --git a/internal/router/eval/eval_test.go b/internal/router/eval/eval_test.go index a92b537..6f0a1a6 100644 --- a/internal/router/eval/eval_test.go +++ b/internal/router/eval/eval_test.go @@ -185,8 +185,8 @@ func TestONNXBaseline(t *testing.T) { if lib == "" { t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing") } - model := filepath.Join("../../..", "models/embedder/model.onnx") - tok := filepath.Join("../../..", "models/embedder/tokenizer.json") + model := filepath.Join("../../..", "models/embedder/multilingual-e5-small/model_quantized.onnx") + tok := filepath.Join("../../..", "models/embedder/multilingual-e5-small/tokenizer.json") for _, p := range []string{lib, model, tok} { if _, err := os.Stat(p); err != nil { t.Skipf("missing %s: %v", p, err) diff --git a/internal/router/eval/llmrouter_test.go b/internal/router/eval/llmrouter_test.go index b560d8c..12a8e76 100644 --- a/internal/router/eval/llmrouter_test.go +++ b/internal/router/eval/llmrouter_test.go @@ -1,11 +1,8 @@ package eval import ( - "bytes" "context" - "encoding/json" "fmt" - "net/http" "os" "strings" "testing" @@ -23,15 +20,26 @@ import ( // // llama-server -m /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf \ // --host 127.0.0.1 --port 18099 -c 2048 -ngl 99 -// MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-router +// MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-models // -// Three configurations, because "the LLM router" is ambiguous and the three -// numbers answer different questions: +// Every report name carries the model llama-server reports over /v1/models, so +// a bake-off across checkpoints (#278, #250) produces tables you can tell +// apart. Point the variable at one server at a time. // -// llm-only — the model alone. Measures the prompt + grammar contract. -// cascade+llm — what #320 would actually ship: stage-0 grammar, then the -// model, then the classifier as the failure floor. -// llm-no-thinking — diagnostic only, not a shippable path (see below). +// Two configurations, because "the LLM router" is ambiguous and the two numbers +// answer different questions: +// +// llm-only — the model alone. Measures the prompt + grammar contract. +// cascade+llm — what #320 would actually ship: stage-0 grammar, then the +// model, then the classifier as the failure floor. +// +// There used to be a third, "thinking off", which looked 6 points better. It is +// gone: it was measured with a hand-rolled HTTP client that quietly dropped +// repeat_penalty, so the gap was the missing penalty and not the thinking mode. +// Re-measured with everything else held equal, thinking off scores exactly the +// same, case for case — and a direct probe shows this llama-server build ignores +// enable_thinking / reasoning_budget for this model anyway, so there was nothing +// to turn off. Full write-up in ROUTING-EVAL-31-07-2026.md (Vikunja #376). func TestLLMRouterBaseline(t *testing.T) { base := os.Getenv("MAVEN_LLM_URL") if base == "" { @@ -53,6 +61,14 @@ func TestLLMRouterBaseline(t *testing.T) { } ctx := context.Background() + model, err := llm.ModelID(ctx, base) + if err != nil { + // Not fatal: an unlabelled score is still a score. But say so loudly, + // because an unlabelled row in a bake-off table is worthless. + t.Logf("could not read model id from %s: %v — reports will say %q", base, err, llm.UnknownModel) + model = llm.UnknownModel + } + t.Logf("scoring model %s at %s", model, base) lr := router.NewLLMRouter(client) // llm-only: the LLM stage in isolation. Route returns (Decision, ok, err); @@ -68,7 +84,7 @@ func TestLLMRouterBaseline(t *testing.T) { } return d, nil }) - repLLM, err := Score(ctx, "llm-only (0.8B, as deployed)", llmOnly, f) + repLLM, err := Score(ctx, "llm-only ("+model+", as deployed)", llmOnly, f) if err != nil { t.Fatalf("Score llm-only: %v", err) } @@ -77,111 +93,25 @@ func TestLLMRouterBaseline(t *testing.T) { // cascade+llm: stage-0 grammar → LLM → classifier fallback, the wiring #320 // proposes. Hash embedder for the fallback so the classifier contribution is // the deterministic floor and any lift is attributable to the model. - repCascade, err := Score(ctx, "cascade+llm (0.8B) + hash fallback", + repCascade, err := Score(ctx, "cascade+llm ("+model+") + hash fallback", newBaselineRouter(t, router.NewHashEmbedder(1024), lr), f) if err != nil { t.Fatalf("Score cascade: %v", err) } t.Log("\n" + repCascade.String() + repCascade.Failures()) - // llm-no-thinking: same prompt and grammar with the chat template's - // thinking mode off. Qwen3.5's template defaults thinking=1, so under a - // grammar the constrained JSON lands in reasoning_content with content - // empty — llm.Client's ReasoningContent fallback is what makes the router - // work at all today, by accident rather than design. - // - // MEASURED 2026-07-31: this variant scores identically to as-deployed - // (18/76, 48.7% intent-only, 2 errors, same p50). Thinking mode is a - // non-issue under a grammar — llama.cpp constrains the same token stream - // either way. Kept so the question stays answered instead of being - // re-asked, and so internal/llm does NOT grow a chat_template_kwargs field - // for a problem that does not exist. - repNoThink, err := Score(ctx, "llm-only (0.8B, thinking off) [diagnostic]", - RouterFunc(func(ctx context.Context, u string, now time.Time) (router.Decision, error) { - d, ok, err := router.NewLLMRouter(&noThinkCompleter{base: base, http: &http.Client{Timeout: 60 * time.Second}}).Route(ctx, u, now) - if err != nil { - return d, err - } - if !ok { - return d, fmt.Errorf("llm router declined without an error") - } - return d, nil - }), f) - if err != nil { - t.Fatalf("Score no-thinking: %v", err) - } - t.Log("\n" + repNoThink.String() + repNoThink.Failures()) - // Reports rather than asserts — the numbers are inputs to the #320 // decision, and an assertion here would be this test inventing the bar. // The one thing worth failing on is a harness fault: if every single case // errors, the run measured infrastructure, not routing, and the report // must not be mistaken for a score. - for _, rep := range []Report{repLLM, repCascade, repNoThink} { + for _, rep := range []Report{repLLM, repCascade} { if rep.Errors == rep.Total { t.Errorf("%s: all %d cases errored — harness fault, not a measurement", rep.Name, rep.Total) } } } -// noThinkCompleter — llm.Client with chat_template_kwargs.enable_thinking -// false. A test-local copy rather than a change to internal/llm: whether the -// daemon should send it is the open question, and answering it here by adding -// the field would prejudge #320. -type noThinkCompleter struct { - base string - http *http.Client -} - -func (c *noThinkCompleter) Complete(ctx context.Context, r llm.Req) (string, error) { - payload := map[string]any{ - "messages": []map[string]string{ - {"role": "system", "content": r.System}, - {"role": "user", "content": r.User}, - }, - "max_tokens": r.MaxTokens, - "temperature": 0, - "grammar": r.Grammar, - "chat_template_kwargs": map[string]any{"enable_thinking": false}, - } - b, err := json.Marshal(payload) - if err != nil { - return "", err - } - req, err := http.NewRequestWithContext(ctx, "POST", c.base+"/v1/chat/completions", bytes.NewReader(b)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - resp, err := c.http.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return "", fmt.Errorf("status %d", resp.StatusCode) - } - var out struct { - Choices []struct { - Message struct { - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content"` - } `json:"message"` - } `json:"choices"` - } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return "", err - } - if len(out.Choices) == 0 { - return "", fmt.Errorf("no choices") - } - m := out.Choices[0].Message - if m.Content != "" { - return m.Content, nil - } - return m.ReasoningContent, nil -} - func ping(ctx context.Context, c *llm.Client) error { ctx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() diff --git a/internal/router/eval/ru_routing_v1.json b/internal/router/eval/ru_routing_v1.json index 3cfb6a0..6c765f0 100644 --- a/internal/router/eval/ru_routing_v1.json +++ b/internal/router/eval/ru_routing_v1.json @@ -22,6 +22,7 @@ { "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener" }, { "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] }, { "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] }, + { "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" }, { "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] }, { "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] }, { "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" }, diff --git a/internal/router/knowledge.go b/internal/router/knowledge.go index 6a16e4b..d425e95 100644 --- a/internal/router/knowledge.go +++ b/internal/router/knowledge.go @@ -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"}.` } diff --git a/internal/router/knowledge_test.go b/internal/router/knowledge_test.go index 2c38fde..e84cc75 100644 --- a/internal/router/knowledge_test.go +++ b/internal/router/knowledge_test.go @@ -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) + } + } } diff --git a/internal/router/llmrouter.go b/internal/router/llmrouter.go index 3a3f4e2..33c47c5 100644 --- a/internal/router/llmrouter.go +++ b/internal/router/llmrouter.go @@ -23,48 +23,101 @@ func NewLLMRouter(c Completer) *LLMRouter { return &LLMRouter{c: c} } // routeGrammar — GBNF constraining the model to a JSON ARRAY of fixed-shape // action objects (one per ask; compound utterances → multiple). Enum + key set -// prevent free-form drift from a sub-1B model. +// prevent free-form drift from a sub-1B model. The string rule is length-bounded +// so a repetition loop cannot fill the whole token budget with one field and +// truncate the JSON. const routeGrammar = ` root ::= "[" ws action ("," ws action)* ws "]" action ::= "{" ws "\"intent\"" ws ":" ws intent ("," ws field)* ws "}" -intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\"" +intent ::= "\"fact\"" | "\"reminder\"" | "\"note\"" | "\"query\"" | "\"act\"" | "\"chat\"" | "\"system\"" | "\"unknown\"" field ::= key ws ":" ws string key ::= "\"key\"" | "\"value\"" | "\"text\"" | "\"verb\"" -string ::= "\"" ([^"\\] | "\\" .)* "\"" +string ::= "\"" ([^"\\] | "\\" .){0,120} "\"" ws ::= [ \t\n]* ` +// routeSystem — the router prompt. Changed 31-07-2026: the query test now sits +// above the fact test and there is an explicit question test. Before that, a +// question naming a fact key ("сколько воды я выпил с утра") matched the fact +// rule first and was stored as an assertion — 15 of 76 fixture cases. +// +// Changed again 31-07-2026: added the "unknown" escape hatch so the model can +// admit it cannot route (Vikunja #359). +// +// Changed again 31-07-2026: added the clock/calendar rule (Vikunja #374). The +// prompt never said which side "который час" or "какое число завтра" belong on, +// so the model guessed — `system→query ×4` in every eval run. The rule sits +// above the question test on purpose: these utterances all carry a question +// word, so a later rule would never be reached. The boundary is what the +// daemon can actually answer: only replySystem in cmd/mavend/voice.go owns the +// clock and the calendar formatter, while the agenda ("что у меня завтра") is +// answered inside the query branch, so that side stays query. +// +// The training workspace keeps its own copy of this prompt for relabelling, and +// `llm/check_prompt_parity.py` there compares the two. That copy is in another +// repo and was not touched, so parity will fail until it gets the same edits — +// both the rule reorder and the "unknown" wording (Vikunja #362) — and now the +// clock/calendar rule too. The training workspace is not checked out on this +// box at all, so it could not be updated here; #362 still covers the catch-up. const routeSystem = `Классифицируй ровно одно сообщение пользователя. Верни ОДИН JSON-массив действий. Ровно одно намерение: fact, reminder, note, query, act, chat, system. +Есть восьмое значение unknown — только для случаев, когда просьбу невозможно понять. Классифицируй по цели пользователя. Порядок решения: 1. Хочет напоминание в будущем → reminder 2. Явно просит сохранить информацию → note -3. Сообщает или обновляет текущее состояние/событие → fact -4. Хочет получить информацию → query -5. Просит выполнить работу → act -6. Про ассистента, настройки или память → system -7. Иначе → chat +3. Спрашивает только «который час» / «какое число» / «какой день недели» — сами часы или календарная дата, без своих данных → system +4. Задаёт вопрос: есть вопросительное слово (сколько, что, какой, когда, где, кто, почему, как) или знак «?» → query +5. Хочет получить информацию, в том числе о своих же данных → query +6. Утверждает: сообщает или обновляет текущее состояние/событие → fact +7. Просит выполнить работу → act +8. Про ассистента, настройки или память → system +9. Реплика — обрывок или указание на неназванное («это», «то», «потом»), и без него непонятно, что именно нужно сделать → unknown +10. Иначе → chat Различия: - note — сохранить информацию, без напоминания. text = суть. - reminder — уведомить позже. text = что напомнить. - fact — неявное обновление: пользователь сообщает, что что-то в мире изменилось (текущее/изменённое состояние, случившееся событие). key/value. +- unknown — редкий случай. Ставь его, только если в самой реплике нет ни предмета, ни действия. Короткая, простая или незнакомая тема — это не причина для unknown: приветствие и болтовня — это chat, вопрос на любую тему — это query, просьба сделать что-то названное — это act. +- system против query — часы и календарная дата сами по себе (сколько времени, какое число, какой день недели — можно и про завтра, и про другой город) — это system. А что записано в календаре или в памяти («что у меня завтра», «какие есть напоминания») — это query. Если в реплике есть просьба (напомни, запиши, сделай), то названное время — просто деталь просьбы, и это не system. +- query против fact — решает форма реплики, а не тема. Вопрос о состоянии — это query, даже если названо то же самое, что бывает в fact. Только утверждение — это fact. Примеры: "запиши пароль" → {"intent":"note","text":"пароль"} "напомни купить молоко" → {"intent":"reminder","text":"купить молоко"} "запиши купить молоко" → {"intent":"note","text":"купить молоко"} "я выпил воду" → {"intent":"fact","key":"water","value":"выпил"} +"сколько воды я выпил с утра" → {"intent":"query","text":"сколько воды я выпил с утра"} +"сколько раз я ел вчера?" → {"intent":"query","text":"сколько раз я ел вчера"} "мой любимый фильм — Интерстеллар" → {"intent":"note","text":"любимый фильм — Интерстеллар"} "что такое docker?" → {"intent":"query","text":"что такое docker"} "напиши письмо" → {"intent":"act","verb":"написать письмо"} "очисти память" → {"intent":"system"} +"который час?" → {"intent":"system"} +"какое число завтра?" → {"intent":"system"} "привет" → {"intent":"chat","text":"привет"} +"сделай это" → {"intent":"unknown"} +"ну это" → {"intent":"unknown"} +"потом" → {"intent":"unknown"} +Но не путай — здесь unknown не нужен: +"сделай кофе" → {"intent":"act","verb":"сделать кофе"} +"что такое кватернион?" → {"intent":"query","text":"что такое кватернион"} +"ага" → {"intent":"chat","text":"ага"} Ответ — JSON-массив: по одному объекту на каждую просьбу. Обычно один. Если в реплике несколько просьб — по объекту на каждую. "напомни купить молоко, и запиши что кофе кончился" → [{"intent":"reminder","text":"купить молоко"},{"intent":"note","text":"кофе кончился"}]. Только JSON, без пояснений.` +// routeRepeatPenalty — the sub-1B model loops one sentence inside the text field +// until it runs out of tokens, which truncates the JSON. 1.15 is enough to break +// the loop without hurting short slot values. +const routeRepeatPenalty = 1.15 + +// routeIntentUnknown — the model's way of saying "I could not route this". +// It is a wire value only: it never becomes a router.Intent, it just makes +// Route return ok=false so the caller drops to the classifier cascade. +const routeIntentUnknown = "unknown" + type routeAction struct { Intent string `json:"intent"` Key string `json:"key"` @@ -73,8 +126,18 @@ type routeAction struct { Verb string `json:"verb"` } +// Route asks the model for one decision. The bool is false when there is no +// decision to use: either the model failed (err set) or it refused with the +// "unknown" intent (err nil). Both mean the same thing to the caller — use the +// classifier instead. func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) (Decision, bool, error) { - raw, err := lr.c.Complete(ctx, llm.Req{System: routeSystem, User: utterance, Grammar: routeGrammar, MaxTokens: 128}) + raw, err := lr.c.Complete(ctx, llm.Req{ + System: routeSystem, + User: utterance, + Grammar: routeGrammar, + MaxTokens: 128, + RepeatPenalty: routeRepeatPenalty, + }) if err != nil { return Decision{}, false, err } @@ -90,6 +153,13 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time) // with the engine turn-on (Router.Route → []Decision, both voice.go handlers // loop). Until then only the first ask is honored. a := acts[0] + // The model refused. Report "no decision" without an error, which is the + // same fall-through the caller already uses for a parse failure — the + // classifier cascade gets the turn and its own confidence gate decides + // whether to ask. Better a slower second opinion than a confident guess. + if a.Intent == routeIntentUnknown { + return Decision{}, false, nil + } d := Decision{Utterance: utterance, Stage: 1, Confidence: 1.0} switch Intent(a.Intent) { case IntentFact: diff --git a/internal/router/llmrouter_test.go b/internal/router/llmrouter_test.go index 9a736b7..658dab7 100644 --- a/internal/router/llmrouter_test.go +++ b/internal/router/llmrouter_test.go @@ -3,15 +3,61 @@ package router import ( "context" "fmt" + "strings" "testing" "time" "github.com/kami/maven/internal/llm" ) -type mockLLM struct{ out string; err error } +type mockLLM struct { + out string + err error + got *llm.Req // last request, when the test wants to inspect it +} -func (m mockLLM) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err } +func (m mockLLM) Complete(_ context.Context, r llm.Req) (string, error) { + if m.got != nil { + *m.got = r + } + return m.out, m.err +} + +// Without a repeat penalty the model loops inside the text field until MaxTokens +// and the truncated JSON fails to parse. +func TestLLMRouterSetsRepeatPenalty(t *testing.T) { + var got llm.Req + lr := NewLLMRouter(mockLLM{out: `{"intent":"chat","text":"привет"}`, got: &got}) + if _, _, err := lr.Route(context.Background(), "привет", time.Now()); err != nil { + t.Fatalf("route: %v", err) + } + if got.RepeatPenalty <= 1 { + t.Fatalf("want repeat penalty above 1, got %v", got.RepeatPenalty) + } +} + +// An unbounded string rule lets one field eat the whole token budget. +func TestRouteGrammarBoundsStrings(t *testing.T) { + if !strings.Contains(routeGrammar, `string ::= "\"" ([^"\\] | "\\" .){0,120} "\""`) { + t.Fatal("grammar string rule lost its length bound") + } +} + +// A question naming a fact key used to be stored as a fact because the fact rule +// was tested first. Keep the query rule above it. +func TestRoutePromptTestsQueryBeforeFact(t *testing.T) { + query := strings.Index(routeSystem, "→ query") + fact := strings.Index(routeSystem, "состояние/событие → fact") + if query < 0 || fact < 0 { + t.Fatalf("prompt lost a rule: query=%d fact=%d", query, fact) + } + if query > fact { + t.Fatal("query rule must come before the fact rule") + } + if !strings.Contains(routeSystem, "Задаёт вопрос") { + t.Fatal("prompt lost the explicit question test") + } +} func TestLLMRouterFactMapping(t *testing.T) { lr := NewLLMRouter(mockLLM{out: `{"intent":"fact","key":"water","value":"выпил"}`}) @@ -54,8 +100,10 @@ func TestLLMRouterReminderMapping(t *testing.T) { } } +// An intent name that is not in the contract at all (as opposed to "unknown", +// which is a real refusal) still defaults to chat. func TestLLMRouterChatFallback(t *testing.T) { - lr := NewLLMRouter(mockLLM{out: `{"intent":"unknown"}`}) + lr := NewLLMRouter(mockLLM{out: `{"intent":"banana"}`}) d, ok, err := lr.Route(context.Background(), "как дела?", time.Now()) if err != nil || !ok { t.Fatalf("ok=%v err=%v", ok, err) @@ -65,6 +113,61 @@ func TestLLMRouterChatFallback(t *testing.T) { } } +// The model must be able to say "I could not route this". +func TestRouteGrammarAllowsUnknown(t *testing.T) { + if !strings.Contains(routeGrammar, `"\"unknown\""`) { + t.Fatal("grammar cannot express a refusal") + } +} + +// If the prompt does not tell the model when to refuse, it never will. +func TestRoutePromptExplainsUnknown(t *testing.T) { + if !strings.Contains(routeSystem, "unknown") { + t.Fatal("prompt never mentions the unknown intent") + } + if !strings.Contains(routeSystem, `"сделай это" → {"intent":"unknown"}`) { + t.Fatal("prompt lost its worked refusal example") + } + // A refusal-only router is useless, so the prompt must also show cases that + // look ambiguous but are not. + if !strings.Contains(routeSystem, "здесь unknown не нужен") { + t.Fatal("prompt lost its counter-examples") + } +} + +// A refusal is not an error. It reports "no decision" so the cascade moves on. +func TestLLMRouterUnknownRefuses(t *testing.T) { + lr := NewLLMRouter(mockLLM{out: `{"intent":"unknown"}`}) + _, ok, err := lr.Route(context.Background(), "сделай это", time.Now()) + if ok { + t.Fatal("a refusal must not produce a usable decision") + } + if err != nil { + t.Fatalf("a refusal is not an error, got %v", err) + } +} + +// The whole point of the refusal: the turn keeps going on the classifier, the +// same way it does when the model returns garbage. +func TestRouterFallsBackWhenLLMRefuses(t *testing.T) { + c := NewClassifier(NewHashEmbedder(1024)) + seedClassifier(t, c) + r := New(Config{ + Classifier: c, + Extractor: Extractor{Time: StubDateTimeParser{}, Facts: DefaultFactParser{}}, + Threshold: 0.4, + LLM: NewLLMRouter(mockLLM{out: `{"intent":"unknown"}`}), + }) + d, err := r.Route(context.Background(), "напомни позвонить маме", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + // Stage 1 is the LLM's own answer; the classifier lands on stage 2 or 3. + if d.Stage < 2 { + t.Fatalf("want the classifier to decide, got stage %d (%+v)", d.Stage, d) + } +} + func TestLLMRouterLLMError(t *testing.T) { lr := NewLLMRouter(mockLLM{out: "", err: fmt.Errorf("llm down")}) _, ok, err := lr.Route(context.Background(), "x", time.Now()) @@ -72,3 +175,87 @@ func TestLLMRouterLLMError(t *testing.T) { t.Fatal("want ok=false, err!=nil on llm error") } } + +// --- slot extraction on top of an LLM decision -------------------------------- + +// newLLMTestRouter — a router whose route always comes from the mock model. +func newLLMTestRouter(t *testing.T, out string) *Router { + t.Helper() + c := NewClassifier(NewHashEmbedder(1024)) + seedClassifier(t, c) + acts := DefaultActMatcher{Fns: []string{"restart", "stop", "run", "backup"}} + return New(Config{ + Classifier: c, + Extractor: Extractor{Time: StubDateTimeParser{}, Acts: acts, Facts: DefaultFactParser{}}, + Threshold: 0.4, + LLM: NewLLMRouter(mockLLM{out: out}), + }) +} + +// The model cannot produce a fire time, so without extraction every LLM-routed +// reminder was dropped as "no time". +func TestLLMDecisionGetsReminderTime(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`) + d, err := r.Route(context.Background(), "напомни позвонить маме через 2 часа", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Intent != IntentReminder { + t.Fatalf("want reminder, got %v", d.Intent) + } + if !d.Slots.HasTime || !d.Slots.Time.Equal(refNow().Add(2*time.Hour)) { + t.Fatalf("want time now+2h, got %+v", d.Slots) + } + if d.Slots.Text != "позвонить маме" { + t.Fatalf("extraction overwrote the model's text: %q", d.Slots.Text) + } +} + +// No time in the utterance ⇒ no time in the slots. Do not invent one; the +// daemon says it could not read the time. +func TestLLMReminderWithoutTimeStaysEmpty(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`) + d, err := r.Route(context.Background(), "напомни позвонить маме", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Slots.HasTime { + t.Fatalf("invented a time: %v", d.Slots.Time) + } +} + +// An act decision arrived with no Fn, so the tool never ran. +func TestLLMDecisionGetsActFn(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"act","verb":"restart nginx"}`) + d, err := r.Route(context.Background(), "слушай, restart nginx пожалуйста", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if !d.Slots.HasFn || d.Slots.Fn != "restart" || len(d.Slots.Args) != 1 || d.Slots.Args[0] != "nginx" { + t.Fatalf("want fn=restart args=[nginx], got %+v", d.Slots) + } +} + +// The model's own slots win; extraction only fills gaps. +func TestLLMSlotsWinOverExtraction(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"fact","key":"hydration","value":"выпил"}`) + d, err := r.Route(context.Background(), "я выпил воду", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if d.Slots.Key != "hydration" { + t.Fatalf("extraction overwrote the model's key: %q", d.Slots.Key) + } +} + +// A fact the model left keyless still gets one from the parser. +func TestLLMFactGetsKeyFromParser(t *testing.T) { + r := newLLMTestRouter(t, `{"intent":"fact","text":"я выпил воду"}`) + d, err := r.Route(context.Background(), "я выпил воду", refNow()) + if err != nil { + t.Fatalf("route: %v", err) + } + if !d.Slots.HasKey || d.Slots.Key != "water" { + t.Fatalf("want key=water, got %+v", d.Slots) + } +} diff --git a/internal/router/onnxembedder.go b/internal/router/onnxembedder.go index a7356ee..cbbf63c 100644 --- a/internal/router/onnxembedder.go +++ b/internal/router/onnxembedder.go @@ -12,6 +12,15 @@ import ( "golang.org/x/text/unicode/norm" ) +// The deployed model is multilingual-e5-small. e5 was trained with these two +// words glued to the front of every text, and it scores badly without them — +// they are part of the model, not a style choice. Swapping back to a symmetric +// paraphrase model means dropping them again. +const ( + queryPrefix = "query: " + passagePrefix = "passage: " +) + const ( padTokenID = 1 unkTokenID = 3 @@ -24,6 +33,7 @@ const ( type onnxEmbedder struct { tokenizer *unigramTokenizer session *ort.DynamicSession[int64, float32] + id string } func NewONNXEmbedder(modelPath, tokenizerPath, libPath string) (*onnxEmbedder, error) { @@ -49,12 +59,50 @@ func NewONNXEmbedder(modelPath, tokenizerPath, libPath string) (*onnxEmbedder, e return &onnxEmbedder{ tokenizer: tok, session: session, + id: modelIDFromPath(modelPath), }, nil } func (e *onnxEmbedder) Dim() int { return embedDim } +// ID names the loaded model for the DB marker (Vikunja #378): the model file's +// own name plus the dimension, so pointing the config at another model changes +// the string on its own. +func (e *onnxEmbedder) ID() string { return e.id } + +// modelIDFromPath turns /opt/.../multilingual-e5-small.onnx into +// "multilingual-e5-small@384". +func modelIDFromPath(modelPath string) string { + name := modelPath + if i := strings.LastIndexAny(name, "/\\"); i >= 0 { + name = name[i+1:] + } + name = strings.TrimSuffix(name, ".onnx") + if name == "" { + name = "onnx" + } + return fmt.Sprintf("%s@%d", name, embedDim) +} + +// Embed treats the text as a query. The classifier compares one short +// utterance to another short seed phrase, so both sides get the same prefix +// and the comparison stays fair. The recall path must call EmbedQuery and +// EmbedPassage instead. func (e *onnxEmbedder) Embed(ctx context.Context, text string) ([]float32, error) { + return e.embed(ctx, queryPrefix+text) +} + +// EmbedQuery — the question the user just asked. +func (e *onnxEmbedder) EmbedQuery(ctx context.Context, text string) ([]float32, error) { + return e.embed(ctx, queryPrefix+text) +} + +// EmbedPassage — a note or fact being stored, or re-scored at lookup time. +func (e *onnxEmbedder) EmbedPassage(ctx context.Context, text string) ([]float32, error) { + return e.embed(ctx, passagePrefix+text) +} + +func (e *onnxEmbedder) embed(ctx context.Context, text string) ([]float32, error) { inputIDs, attentionMask, _ := e.tokenizer.Encode(text) inputShape := ort.NewShape(1, int64(maxLength)) @@ -313,4 +361,4 @@ func preTokenize(text string) []string { return out } -var _ Embedder = (*onnxEmbedder)(nil) +var _ AsymmetricEmbedder = (*onnxEmbedder)(nil) diff --git a/internal/router/router.go b/internal/router/router.go index 349534d..4b8fd18 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -88,6 +88,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De if r.llm != nil { if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok { d.Utterance = utterance + r.fillSlots(ctx, &d, now) return d, nil } else if err != nil { log.Printf("router: llm route fell back to classifier: %v", err) @@ -118,6 +119,39 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De return d, nil } +// fillSlots — run stage-2 extraction on an LLM decision and fill only the slots +// the model left empty. The LLM wins where it answered: it saw the sentence, the +// parsers are keyword tables. Extraction covers what the model cannot produce at +// all — a parsed reminder time and an allowlist fn. +// +// If a reminder still has no time, leave it missing. The daemon then says it +// could not read the time; inventing one would set a wrong alarm. +func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) { + ex := r.extractor.Extract(ctx, d.Intent, d.Utterance, now) + if !d.Slots.HasTime && ex.HasTime { + d.Slots.Time, d.Slots.HasTime = ex.Time, ex.HasTime + } + if !d.Slots.HasKey && ex.HasKey { + d.Slots.Key, d.Slots.Value, d.Slots.HasKey = ex.Key, ex.Value, ex.HasKey + } + if !d.Slots.HasFn && ex.HasFn { + d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = ex.Fn, ex.Args, ex.HasFn + } + // For an act the model returns the verb in Text ("restart nginx"), which is + // often cleaner than the raw utterance ("maven, could you restart nginx"). + // Try it too when the utterance did not match the allowlist. + if d.Intent == IntentAct && !d.Slots.HasFn && r.extractor.Acts != nil && + d.Slots.Text != "" && d.Slots.Text != d.Utterance { + if fn, args, ok := r.extractor.Acts.Match(d.Slots.Text); ok { + d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true + } + } + if d.Slots.Text == "" { + d.Slots.Text = ex.Text + } + // Stage stays 1: it says who decided the route, and that was the LLM. +} + // CorrectMisroute — the user corrected a bad classification. Appends a new // example for the corrected intent (append-only — grows the classifier, no // retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over diff --git a/internal/router/slots.go b/internal/router/slots.go index ea579f5..95998a2 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -449,16 +449,30 @@ func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) { return "", false } -// ParseCalendarDate detects RU calendar date words in text and returns the -// resolved time (midnight UTC+0 for "сегодня"/"today", next day for "завтра"/"tomorrow"). -// Returns zero time + false if no match. +// ParseCalendarDate detects RU/EN calendar day words in text and returns +// midnight of that day in now's own time zone. Handles "сегодня", "завтра", +// "послезавтра", "вчера" (and the English words). Returns zero time + false +// if no match. +// +// "послезавтра" is checked before "завтра" because it contains it. func ParseCalendarDate(text string, now time.Time) (time.Time, bool) { lower := strings.ToLower(text) - if strings.Contains(lower, "сегодня") || strings.Contains(lower, "today") { - return now.Truncate(24 * time.Hour), true - } - if strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow") { - return now.Truncate(24 * time.Hour).Add(24 * time.Hour), true + switch { + case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"): + return midnight(now, 0), true + case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"): + return midnight(now, 2), true + case strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow"): + return midnight(now, 1), true + case strings.Contains(lower, "вчера") || strings.Contains(lower, "yesterday"): + return midnight(now, -1), true } return time.Time{}, false } + +// midnight returns the start of the day that is `days` away from now, in +// now's time zone (now.Truncate(24h) would cut on a UTC boundary instead). +func midnight(now time.Time, days int) time.Time { + y, m, d := now.AddDate(0, 0, days).Date() + return time.Date(y, m, d, 0, 0, 0, 0, now.Location()) +} diff --git a/internal/router/slots_ru_test.go b/internal/router/slots_ru_test.go index 4c52d37..f3e9f85 100644 --- a/internal/router/slots_ru_test.go +++ b/internal/router/slots_ru_test.go @@ -45,6 +45,9 @@ func TestParseCalendarDate(t *testing.T) { {"расписание на завтра", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true}, {"what's today", time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC), true}, {"tomorrow plans", time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC), true}, + {"какое число послезавтра", time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC), true}, + {"что было вчера", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true}, + {"yesterday plans", time.Date(2026, 7, 5, 0, 0, 0, 0, time.UTC), true}, {"какая погода", time.Time{}, false}, {"сколько времени", time.Time{}, false}, {"", time.Time{}, false}, diff --git a/internal/routine/routine.go b/internal/routine/routine.go index 7e883c4..3ee082e 100644 --- a/internal/routine/routine.go +++ b/internal/routine/routine.go @@ -55,6 +55,47 @@ func Validate(routines []Routine) error { return nil } +// Accepted — an accepted routine proposal as the tick driver sees it. This is a +// different shape from Routine: the schedule is a plain interval the pattern +// detector measured, not an operator-written cron expression. Accepted is when +// the human said yes; LastFired is nil until the first nudge. +type Accepted struct { + ID int64 + Name string + IntervalDays float64 + Accepted time.Time + LastFired *time.Time +} + +// DueAccepted returns the accepted routines whose interval has passed. It does +// not mutate anything — the caller persists the new last-fired time, because +// that has to survive a restart (unlike Due's in-memory map). +// +// The clock starts at LastFired, or at Accepted for a routine that has never +// nudged. A routine with a non-positive interval never fires: a bad interval +// should mean silence, not a nudge every tick. +// +// One occurrence per call, no catch-up: the caller stamps the fire time as now, +// so a routine that was silent for a month nudges once and then waits a full +// interval. Never a backlog. +func DueAccepted(rs []Accepted, now time.Time) []Accepted { + var out []Accepted + for _, r := range rs { + if r.IntervalDays <= 0 { + continue + } + since := r.Accepted + if r.LastFired != nil { + since = *r.LastFired + } + gap := time.Duration(r.IntervalDays * 24 * float64(time.Hour)) + if !now.Before(since.Add(gap)) { + out = append(out, r) + } + } + return out +} + // Due returns the routines whose schedule crossed since their last fire and // records now as the new last-fire time for each one returned. The caller owns // `last` (the tick driver holds it across ticks); Due mutates it in place. diff --git a/internal/routine/routine_test.go b/internal/routine/routine_test.go index 8e1550a..7ca89c5 100644 --- a/internal/routine/routine_test.go +++ b/internal/routine/routine_test.go @@ -5,6 +5,40 @@ import ( "time" ) +func TestDueAcceptedFiresOncePerInterval(t *testing.T) { + accepted := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC) + fired := accepted.Add(3 * 24 * time.Hour) + rs := []Accepted{ + {ID: 1, Name: "полить цветы", IntervalDays: 3, Accepted: accepted}, + {ID: 2, Name: "покормить рыб", IntervalDays: 3, Accepted: accepted, LastFired: &fired}, + {ID: 3, Name: "битый интервал", IntervalDays: 0, Accepted: accepted}, + } + + // One day in: nothing has waited a full interval. + if got := DueAccepted(rs, accepted.Add(24*time.Hour)); len(got) != 0 { + t.Fatalf("want nothing due after 1 day, got %+v", got) + } + + // Three days in: the never-fired one is due. The one that already fired at + // day 3 starts its next three days from there. A zero interval never fires. + got := DueAccepted(rs, fired) + if len(got) != 1 || got[0].ID != 1 { + t.Fatalf("want only routine 1 due at day 3, got %+v", got) + } + + // Six days in: both real routines are due. + if got := DueAccepted(rs, accepted.Add(6*24*time.Hour)); len(got) != 2 { + t.Fatalf("want both routines due at day 6, got %+v", got) + } + + // A month later the zero-interval routine is still silent. + for _, r := range DueAccepted(rs, accepted.Add(30*24*time.Hour)) { + if r.ID == 3 { + t.Fatal("a routine with a zero interval must never fire") + } + } +} + func TestValidate(t *testing.T) { ok := []Routine{{Name: "morning", Cron: "0 8 * * *", Body: "доброе утро"}} if err := Validate(ok); err != nil { diff --git a/internal/store/backfill.go b/internal/store/backfill.go new file mode 100644 index 0000000..2de783d --- /dev/null +++ b/internal/store/backfill.go @@ -0,0 +1,161 @@ +package store + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// EmbedFunc embeds one piece of stored text. The caller passes +// router.EmbedPassage — the STORE side of the query/passage asymmetry, which is +// the side every vector in the DB was written with. (Passing the query side +// would put the stored vectors in the wrong half of the space and quietly halve +// recall.) A func instead of an interface keeps this package free of any +// dependency on internal/router. +type EmbedFunc func(ctx context.Context, text string) ([]float32, error) + +// BackfillResult is what the re-embed run did, for logging. +type BackfillResult struct { + Skipped bool // marker already matched — nothing to do + Notes int // rows rewritten in the notes table + Facts int // fact rows rewritten in memory_vectors + MemNotes int // note rows rewritten in memory_vectors + NoText int // memory_vectors rows with no text in their meta, left alone + Took time.Duration +} + +// ReembedAll rewrites every stored vector with the currently configured +// embedder and then records that embedder as the one that owns the DB. +// +// Both places a vector lives are rewritten in the same pass: the `notes` table +// `embedding` column and the `memory_vectors` rows (notes AND facts). Doing +// only one would leave the two indexes disagreeing, which is worse than leaving +// both stale. +// +// Safe to re-run: if the marker already names the current embedder there is +// nothing to fix, so it returns immediately with Skipped set. +// +// Crash safety: everything — every vector and the marker — happens inside one +// transaction. If anything fails or the process dies partway, the transaction +// rolls back: no vectors changed and no marker written, so the next run does +// the whole job again. The marker is never set unless the full rewrite +// committed. +func (s *Store) ReembedAll(ctx context.Context, currentID string, embed EmbedFunc) (BackfillResult, error) { + start := time.Now() + var res BackfillResult + + stored, err := s.Meta(ctx, metaKeyEmbedderID) + if err != nil { + return res, err + } + if stored == currentID { + res.Skipped = true + res.Took = time.Since(start) + return res, nil + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return res, fmt.Errorf("reembed: begin: %w", err) + } + defer tx.Rollback() // no-op once committed + + // ----- notes table ----- + type noteRow struct { + id int64 + text string + } + var notes []noteRow + rows, err := tx.QueryContext(ctx, `SELECT id, text FROM notes WHERE text != ''`) + if err != nil { + return res, fmt.Errorf("reembed: read notes: %w", err) + } + for rows.Next() { + var n noteRow + if err := rows.Scan(&n.id, &n.text); err != nil { + rows.Close() + return res, fmt.Errorf("reembed: note row: %w", err) + } + notes = append(notes, n) + } + rows.Close() + if err := rows.Err(); err != nil { + return res, fmt.Errorf("reembed: notes: %w", err) + } + + for _, n := range notes { + vec, err := embed(ctx, n.text) + if err != nil { + return res, fmt.Errorf("reembed: embed note %d: %w", n.id, err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE notes SET embedding = ? WHERE id = ?`, floatsToBlob(vec), n.id); err != nil { + return res, fmt.Errorf("reembed: write note %d: %w", n.id, err) + } + res.Notes++ + } + + // ----- memory_vectors (the unified index: notes AND facts) ----- + // The text to re-embed is the one carried in the row's meta blob, which is + // exactly the text that was embedded when the row was written. + type vecRow struct { + id, text, kind string + } + var vecs []vecRow + rows, err = tx.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`) + if err != nil { + return res, fmt.Errorf("reembed: read memory vectors: %w", err) + } + for rows.Next() { + var id, metaJSON string + if err := rows.Scan(&id, &metaJSON); err != nil { + rows.Close() + return res, fmt.Errorf("reembed: memory row: %w", err) + } + meta := map[string]string{} + if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil { + rows.Close() + return res, fmt.Errorf("reembed: meta for %q: %w", id, err) + } + if meta["text"] == "" { + res.NoText++ + continue + } + vecs = append(vecs, vecRow{id: id, text: meta["text"], kind: meta["type"]}) + } + rows.Close() + if err := rows.Err(); err != nil { + return res, fmt.Errorf("reembed: memory vectors: %w", err) + } + + for _, v := range vecs { + vec, err := embed(ctx, v.text) + if err != nil { + return res, fmt.Errorf("reembed: embed %q: %w", v.id, err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE memory_vectors SET vec = ? WHERE id = ?`, encodeVec(vec), v.id); err != nil { + return res, fmt.Errorf("reembed: write %q: %w", v.id, err) + } + if v.kind == "fact" { + res.Facts++ + } else { + res.MemNotes++ + } + } + + // Same transaction as the rewrite, on purpose: the marker can only exist if + // every vector above was written. + if _, err := tx.ExecContext(ctx, + `INSERT INTO meta (key, value) VALUES (?,?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + metaKeyEmbedderID, currentID); err != nil { + return res, fmt.Errorf("reembed: write marker: %w", err) + } + if err := tx.Commit(); err != nil { + return res, fmt.Errorf("reembed: commit: %w", err) + } + res.Took = time.Since(start) + return res, nil +} diff --git a/internal/store/backfill_test.go b/internal/store/backfill_test.go new file mode 100644 index 0000000..01cf00d --- /dev/null +++ b/internal/store/backfill_test.go @@ -0,0 +1,171 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +// markerVec is a recognisable vector: nothing in these tests writes it except +// the backfill, so finding it proves the row really was rewritten. +var markerVec = []float32{9, 9, 9} + +func newEmbedder(calls *int) EmbedFunc { + return func(_ context.Context, _ string) ([]float32, error) { + *calls++ + return markerVec, nil + } +} + +// seedOldVectors puts one note (notes table + unified index) and one fact +// (unified index only) in the DB, both carrying obviously-old vectors. +func seedOldVectors(t *testing.T, s *Store) { + t.Helper() + ctx := context.Background() + old := []float32{0.1, 0.2, 0.3} + id, err := s.WriteNote(ctx, time.Now(), "молоко в холодильнике", old, "voice") + if err != nil { + t.Fatalf("WriteNote: %v", err) + } + mem := s.VectorMemory() + if err := mem.Insert(ctx, "note:1", old, map[string]string{ + "type": "note", "text": "молоко в холодильнике", + }); err != nil { + t.Fatalf("Insert note vector: %v", err) + } + if err := mem.Insert(ctx, "fact:water:1", old, map[string]string{ + "type": "fact", "text": "я пил воду", + }); err != nil { + t.Fatalf("Insert fact vector: %v", err) + } + _ = id +} + +func noteVec(t *testing.T, s *Store) []float32 { + t.Helper() + var blob []byte + if err := s.db.QueryRow(`SELECT embedding FROM notes LIMIT 1`).Scan(&blob); err != nil { + t.Fatalf("read note embedding: %v", err) + } + return blobToFloats(blob) +} + +func memVec(t *testing.T, s *Store, id string) []float32 { + t.Helper() + var blob []byte + if err := s.db.QueryRow(`SELECT vec FROM memory_vectors WHERE id = ?`, id).Scan(&blob); err != nil { + t.Fatalf("read memory vector %s: %v", id, err) + } + return decodeVec(blob) +} + +func sameVec(a, b []float32) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// The deployed case: old vectors everywhere, no marker. Every vector in both +// places must be rewritten and the marker recorded. +func TestReembedAllRewritesEveryVector(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedOldVectors(t, s) + + calls := 0 + res, err := s.ReembedAll(ctx, "multilingual-e5-small@384", newEmbedder(&calls)) + if err != nil { + t.Fatalf("ReembedAll: %v", err) + } + if res.Skipped { + t.Fatal("first run should not skip") + } + if res.Notes != 1 || res.MemNotes != 1 || res.Facts != 1 { + t.Fatalf("counts: notes=%d memNotes=%d facts=%d", res.Notes, res.MemNotes, res.Facts) + } + if calls != 3 { + t.Fatalf("embedder called %d times, want 3", calls) + } + if !sameVec(noteVec(t, s), markerVec) { + t.Fatalf("notes table not rewritten: %v", noteVec(t, s)) + } + if !sameVec(memVec(t, s, "note:1"), markerVec) { + t.Fatal("unified index note row not rewritten") + } + if !sameVec(memVec(t, s, "fact:water:1"), markerVec) { + t.Fatal("unified index fact row not rewritten") + } + got, err := s.Meta(ctx, metaKeyEmbedderID) + if err != nil { + t.Fatalf("Meta: %v", err) + } + if got != "multilingual-e5-small@384" { + t.Fatalf("marker = %q", got) + } +} + +// Re-running must do nothing at all — not a second pass over the same rows. +func TestReembedAllSecondRunIsNoop(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedOldVectors(t, s) + + calls := 0 + if _, err := s.ReembedAll(ctx, "e5@384", newEmbedder(&calls)); err != nil { + t.Fatalf("first run: %v", err) + } + first := calls + + res, err := s.ReembedAll(ctx, "e5@384", newEmbedder(&calls)) + if err != nil { + t.Fatalf("second run: %v", err) + } + if !res.Skipped { + t.Fatal("second run should report Skipped") + } + if calls != first { + t.Fatalf("second run embedded %d more rows, want 0", calls-first) + } +} + +// A failure partway must leave the DB exactly as it was: no marker, and the old +// vectors still in place (one transaction, rolled back). +func TestReembedAllPartialFailureLeavesMarkerUnset(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + seedOldVectors(t, s) + before := noteVec(t, s) + + calls := 0 + boom := func(_ context.Context, _ string) ([]float32, error) { + calls++ + if calls == 2 { + return nil, errors.New("onnx blew up") + } + return markerVec, nil + } + if _, err := s.ReembedAll(ctx, "e5@384", boom); err == nil { + t.Fatal("expected an error") + } + got, err := s.Meta(ctx, metaKeyEmbedderID) + if err != nil { + t.Fatalf("Meta: %v", err) + } + if got != "" { + t.Fatalf("marker was set to %q after a failed run", got) + } + if !sameVec(noteVec(t, s), before) { + t.Fatal("a failed run left a partially rewritten notes table") + } + // And the mismatch warning must still fire, so the user knows to re-run. + if _, mismatch, err := s.CheckEmbedder(ctx, "e5@384"); err != nil || !mismatch { + t.Fatalf("CheckEmbedder after failed backfill: mismatch=%v err=%v", mismatch, err) + } +} diff --git a/internal/store/delivery.go b/internal/store/delivery.go index 8c37b14..b0b46df 100644 --- a/internal/store/delivery.go +++ b/internal/store/delivery.go @@ -13,11 +13,15 @@ import ( // unknown = a pending row found stale at startup: the process that started it // is gone, and the send may or may not have reached the external channel. // Never auto-resolved into sent or failed — that would be guessing. +// dropped = the routing table deliberately suppressed this one (a care nudge +// while you're away). Nothing was sent and nothing went wrong; the row exists +// so "she dropped it" and "the rule never fired" don't look the same later. const ( DeliveryPending = "pending" DeliverySent = "sent" DeliveryFailed = "failed" DeliveryUnknown = "unknown" + DeliveryDropped = "dropped" ) // BeginDeliveryAttempt durably records intent to send BEFORE the external @@ -43,10 +47,11 @@ func (s *Store) BeginDeliveryAttempt(ctx context.Context, kind, rule string, rem } // CompleteDeliveryAttempt records the sink's outcome for a prior -// BeginDeliveryAttempt. status is "sent" or "failed" — never "pending" or -// "unknown" (those are set only by Begin and reconciliation respectively). +// BeginDeliveryAttempt. status is "sent", "failed" or "dropped" — never +// "pending" or "unknown" (those are set only by Begin and reconciliation +// respectively). func (s *Store) CompleteDeliveryAttempt(ctx context.Context, id int64, status string, now time.Time) error { - if status != DeliverySent && status != DeliveryFailed { + if status != DeliverySent && status != DeliveryFailed && status != DeliveryDropped { return fmt.Errorf("store: invalid delivery completion status %q", status) } _, err := s.db.ExecContext(ctx, diff --git a/internal/store/delivery_test.go b/internal/store/delivery_test.go new file mode 100644 index 0000000..4a7e23d --- /dev/null +++ b/internal/store/delivery_test.go @@ -0,0 +1,34 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// TestDroppedDeliveryAttemptRoundTrips — Vikunja #370. A suppressed nudge is +// recorded as 'dropped'. The status column has a CHECK constraint, so this +// only works if migration #12 widened it; a fake outbox in a unit test would +// not catch that. +func TestDroppedDeliveryAttemptRoundTrips(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now() + + id, err := s.BeginDeliveryAttempt(ctx, "nudge", "water", 0, "drop", "abc123", now) + if err != nil { + t.Fatalf("BeginDeliveryAttempt: %v", err) + } + if err := s.CompleteDeliveryAttempt(ctx, id, DeliveryDropped, now); err != nil { + t.Fatalf("CompleteDeliveryAttempt: %v", err) + } + + var status string + err = s.db.QueryRowContext(ctx, `SELECT status FROM delivery_attempts WHERE id = ?`, id).Scan(&status) + if err != nil { + t.Fatalf("read back: %v", err) + } + if status != DeliveryDropped { + t.Fatalf("status: want %q, got %q", DeliveryDropped, status) + } +} diff --git a/internal/store/dialogue.go b/internal/store/dialogue.go new file mode 100644 index 0000000..2d58db3 --- /dev/null +++ b/internal/store/dialogue.go @@ -0,0 +1,74 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// DialogueSessionRow — one saved follow-up session. Data is the session +// encoded by the dialogue package; the store does not look inside it. +type DialogueSessionRow struct { + ID string + Data []byte + Ts time.Time + TTL time.Duration + Expires time.Time +} + +// SaveDialogueSession — write (or replace) the session for one dialogue id. +// One row per id: a newer turn overwrites the older state. +func (s *Store) SaveDialogueSession(ctx context.Context, id string, data []byte, ts time.Time, ttl time.Duration) error { + expires := ts.Add(ttl) + _, err := s.db.ExecContext(ctx, ` + INSERT INTO dialogue_sessions (id, data, ts, ttl_ms, expires_ts) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET data = excluded.data, + ts = excluded.ts, + ttl_ms = excluded.ttl_ms, + expires_ts = excluded.expires_ts`, + id, data, ts.UnixMilli(), ttl.Milliseconds(), expires.UnixMilli()) + if err != nil { + return fmt.Errorf("save dialogue session: %w", err) + } + return nil +} + +// DeleteDialogueSession — drop one session (ended, or expired). +func (s *Store) DeleteDialogueSession(ctx context.Context, id string) error { + if _, err := s.db.ExecContext(ctx, `DELETE FROM dialogue_sessions WHERE id = ?`, id); err != nil { + return fmt.Errorf("delete dialogue session: %w", err) + } + return nil +} + +// LoadDialogueSessions — return the sessions still alive at `now` and delete +// the ones that already ran out. An expired session is dead: it never comes +// back after a restart. +func (s *Store) LoadDialogueSessions(ctx context.Context, now time.Time) ([]DialogueSessionRow, error) { + if _, err := s.db.ExecContext(ctx, + `DELETE FROM dialogue_sessions WHERE expires_ts <= ?`, now.UnixMilli()); err != nil { + return nil, fmt.Errorf("prune dialogue sessions: %w", err) + } + rows, err := s.db.QueryContext(ctx, + `SELECT id, data, ts, ttl_ms, expires_ts FROM dialogue_sessions ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("load dialogue sessions: %w", err) + } + defer rows.Close() + var out []DialogueSessionRow + for rows.Next() { + var r DialogueSessionRow + var tsMilli, ttlMilli, expMilli int64 + if err := rows.Scan(&r.ID, &r.Data, &tsMilli, &ttlMilli, &expMilli); err != nil { + return nil, fmt.Errorf("scan dialogue session: %w", err) + } + r.Ts = time.UnixMilli(tsMilli).UTC() + r.TTL = time.Duration(ttlMilli) * time.Millisecond + r.Expires = time.UnixMilli(expMilli).UTC() + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("load dialogue sessions: %w", err) + } + return out, nil +} diff --git a/internal/store/meta.go b/internal/store/meta.go new file mode 100644 index 0000000..652110f --- /dev/null +++ b/internal/store/meta.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// metaKeyEmbedderID names the embedder that wrote the stored vectors. +// +// Why one value for the whole DB and not a column on every vector row: the +// vectors are only ever rewritten all at once (one backfill re-embeds every +// note and fact together), so a per-row marker would hold the same string in +// every row and cost a column on two tables for nothing. +const metaKeyEmbedderID = "embedder_id" + +// Meta reads a single value from the meta table. Missing key ⇒ empty string. +func (s *Store) Meta(ctx context.Context, key string) (string, error) { + var v string + err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("read meta %s: %w", key, err) + } + return v, nil +} + +// SetMeta writes (or overwrites) a single meta value. +func (s *Store) SetMeta(ctx context.Context, key, value string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO meta (key, value) VALUES (?,?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value) + if err != nil { + return fmt.Errorf("write meta %s: %w", key, err) + } + return nil +} + +// EmbedderUnknown is the stored id reported for a DB that already holds +// vectors but never recorded who wrote them. +const EmbedderUnknown = "unknown (written before this marker existed)" + +// CheckEmbedder compares the embedder now configured against the one that +// wrote the stored vectors. Returns the stored id and whether it differs. +// +// Vectors from two different models live in different spaces, so cosine +// between them is noise rather than a low score — and both of our models are +// 384-dimensional, so nothing else catches it. +// +// Three cases, and the middle one is the one that actually matters: +// +// - marker present ⇒ compare the two ids. +// - marker absent but vectors already stored ⇒ this is a DB from before the +// marker, so we cannot know who wrote them. Report a mismatch. This is the +// real case on the deployed box: those vectors came from the old embedder, +// and claiming them for the current one would hide the exact problem the +// marker was added to catch. +// - marker absent and no vectors ⇒ fresh DB, claim it, nothing to fix. +// +// On a mismatch the fix is ReembedAll (backfill.go), run explicitly with +// `mavend -reembed`. Nothing is re-embedded here: that work is minutes of CPU +// on the laptop and must not stall a normal start. +func (s *Store) CheckEmbedder(ctx context.Context, currentID string) (stored string, mismatch bool, err error) { + stored, err = s.Meta(ctx, metaKeyEmbedderID) + if err != nil { + return "", false, err + } + if stored != "" { + return stored, stored != currentID, nil + } + n, err := s.countVectors(ctx) + if err != nil { + return "", false, err + } + if n > 0 { + return EmbedderUnknown, true, nil + } + return currentID, false, s.SetMeta(ctx, metaKeyEmbedderID, currentID) +} + +// countVectors — how many stored rows carry an embedding. Used only to tell a +// fresh DB apart from one that predates the marker. +func (s *Store) countVectors(ctx context.Context) (int, error) { + var notes, vecs int + if err := s.db.QueryRowContext(ctx, + `SELECT count(*) FROM notes WHERE embedding IS NOT NULL`).Scan(¬es); err != nil { + return 0, fmt.Errorf("count note vectors: %w", err) + } + if err := s.db.QueryRowContext(ctx, + `SELECT count(*) FROM memory_vectors`).Scan(&vecs); err != nil { + return 0, fmt.Errorf("count memory vectors: %w", err) + } + return notes + vecs, nil +} diff --git a/internal/store/meta_test.go b/internal/store/meta_test.go new file mode 100644 index 0000000..e63f07d --- /dev/null +++ b/internal/store/meta_test.go @@ -0,0 +1,100 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// A fresh DB has no marker yet, so the current embedder is recorded and +// nothing is flagged. +func TestCheckEmbedderFreshDBRecords(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + stored, mismatch, err := s.CheckEmbedder(ctx, "multilingual-e5-small@384") + if err != nil { + t.Fatalf("CheckEmbedder: %v", err) + } + if mismatch { + t.Fatal("fresh DB reported a mismatch") + } + if stored != "multilingual-e5-small@384" { + t.Fatalf("stored = %q", stored) + } + got, err := s.Meta(ctx, metaKeyEmbedderID) + if err != nil { + t.Fatalf("Meta: %v", err) + } + if got != "multilingual-e5-small@384" { + t.Fatalf("marker not persisted, got %q", got) + } +} + +// The deployed box: notes were written by the old embedder, before the marker +// existed. Claiming them for the current one would hide exactly the problem +// the marker is for, so an unmarked DB that already holds vectors is a +// mismatch. +func TestCheckEmbedderUnmarkedDBWithVectorsIsMismatch(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + if _, err := s.WriteNote(ctx, time.Now(), "молоко в холодильнике", []float32{0.1, 0.2}, "voice"); err != nil { + t.Fatalf("WriteNote: %v", err) + } + + stored, mismatch, err := s.CheckEmbedder(ctx, "multilingual-e5-small@384") + if err != nil { + t.Fatalf("CheckEmbedder: %v", err) + } + if !mismatch { + t.Fatal("an unmarked DB with stored vectors should report a mismatch") + } + if stored != EmbedderUnknown { + t.Fatalf("stored = %q, want %q", stored, EmbedderUnknown) + } + // It must NOT claim the DB — that would silence the warning on restart. + got, err := s.Meta(ctx, metaKeyEmbedderID) + if err != nil { + t.Fatalf("Meta: %v", err) + } + if got != "" { + t.Fatalf("marker written despite unknown provenance: %q", got) + } +} + +// Both models are 384-dim, so this is the only thing that catches the swap. +func TestCheckEmbedderDifferentModelMismatch(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + if err := s.SetMeta(ctx, metaKeyEmbedderID, "paraphrase-multilingual-MiniLM-L12-v2@384"); err != nil { + t.Fatalf("SetMeta: %v", err) + } + stored, mismatch, err := s.CheckEmbedder(ctx, "multilingual-e5-small@384") + if err != nil { + t.Fatalf("CheckEmbedder: %v", err) + } + if !mismatch { + t.Fatal("different embedder not detected") + } + if stored != "paraphrase-multilingual-MiniLM-L12-v2@384" { + t.Fatalf("stored = %q", stored) + } +} + +// The same embedder must never raise a false alarm, including on re-check. +func TestCheckEmbedderSameModelNoAlarm(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + + for i := 0; i < 2; i++ { + _, mismatch, err := s.CheckEmbedder(ctx, "multilingual-e5-small@384") + if err != nil { + t.Fatalf("CheckEmbedder: %v", err) + } + if mismatch { + t.Fatalf("false alarm on pass %d", i) + } + } +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index c19d52f..8bc86eb 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -70,6 +70,48 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2 CHECK (resolution_state IN ('none','pending','resolved','ambiguous','not_found')); CREATE INDEX IF NOT EXISTS idx_facts_entity_id ON facts (entity_id) WHERE entity_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_facts_resolution_pending ON facts (resolution_state) WHERE resolution_state = 'pending';`, // #7 — entity-aware memory (Vikunja #279): facts about a subject get resolved to a Nexus entity_id async + + `CREATE INDEX IF NOT EXISTS idx_nudges_snoozed ON nudges (outcome_ts) WHERE outcome = 'snoozed';`, // #8 — SnoozedUntil runs every tick; keep it off a full scan (Vikunja #364) + `ALTER TABLE proposed_routines ADD COLUMN accepted_ts INTEGER; + ALTER TABLE proposed_routines ADD COLUMN last_fired_ts INTEGER;`, // #9 — accepted routines keep firing (Vikunja #366): the tick loop needs to know when a routine was accepted and when it last nudged + + `CREATE TABLE IF NOT EXISTS dialogue_sessions ( + id TEXT PRIMARY KEY, + data BLOB NOT NULL, + ts INTEGER NOT NULL, + ttl_ms INTEGER NOT NULL, + expires_ts INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_dialogue_sessions_expires ON dialogue_sessions (expires_ts);`, // #10 — the follow-up session survives a restart (Vikunja #363); small, TTL-pruned table, not a history log + + `CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + );`, // #11 — small key/value table for facts about the DB itself; first key is embedder_id (Vikunja #378) + + // #12 — a suppressed nudge gets a 'dropped' row (Vikunja #370). sqlite + // can't widen a CHECK constraint in place, so the table is rebuilt; the + // index goes with the old table and is recreated. The columns are listed + // out rather than `SELECT *` — copying by position would silently shuffle + // every row if the old table's column order ever differed from this one. + `CREATE TABLE delivery_attempts_v12 ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL CHECK (kind IN ('nudge','reminder')), + rule TEXT NOT NULL DEFAULT '', + reminder_id INTEGER NOT NULL DEFAULT 0, + channel TEXT NOT NULL, + body_hash TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed','unknown','dropped')), + created_ts INTEGER NOT NULL, + completed_ts INTEGER + ); + INSERT INTO delivery_attempts_v12 + (id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts) + SELECT id, kind, rule, reminder_id, channel, body_hash, status, created_ts, completed_ts + FROM delivery_attempts; + DROP TABLE delivery_attempts; + ALTER TABLE delivery_attempts_v12 RENAME TO delivery_attempts; + CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`, } // migrate applies every migration with a number greater than the DB's current diff --git a/internal/store/nudges.go b/internal/store/nudges.go index 2a0db37..a295050 100644 --- a/internal/store/nudges.go +++ b/internal/store/nudges.go @@ -28,6 +28,20 @@ const ( NudgeIgnored = "ignored" ) +// SnoozeDuration — how long one `snoozed` outcome keeps its rule quiet. +// +// The nudges table records THAT a snooze happened and when, never for how +// long: nothing upstream can supply a length. ResolveNudge takes only +// (id, outcome, ts), and so do the IPC method and the web/telegram callers +// behind it. So a fixed default it is, rather than a new column no writer +// could fill. +// +// Two hours: longer than every rule's base cooldown (15–60m) so a snooze +// actually buys quiet instead of being swallowed by the cooldown, and short +// enough that a snooze the operator forgets about clears the same day. A +// snooze can never outlive this window, so Maven cannot go quiet forever. +const SnoozeDuration = 2 * time.Hour + var ( ErrNudgeNotFound = errors.New("store: nudge not found") ErrNudgeOutcome = errors.New("store: nudge already resolved") @@ -138,6 +152,37 @@ func (s *Store) UnackedTelegramRules(ctx context.Context) ([]string, error) { return out, rows.Err() } +// SnoozedUntil — per rule, when its most recent snooze runs out. This is the +// read behind the gate's snooze check: the `snoozed` outcome already in the +// nudges table IS the restraint memory, so there is no snooze table. +// +// Rules with no live snooze are absent from the map, which is what the gate +// wants (a missing key means "not snoozed"). Expired snoozes are filtered out +// in SQL, so an old snooze can never come back as a silent forever-mute. +// +// Called every tick (~60s). One indexed lookup over the snoozed rows only. +func (s *Store) SnoozedUntil(ctx context.Context, now time.Time) (map[string]time.Time, error) { + cutoff := now.Add(-SnoozeDuration).UnixMilli() + rows, err := s.db.QueryContext(ctx, + `SELECT rule, MAX(outcome_ts) FROM nudges + WHERE outcome = 'snoozed' AND outcome_ts > ? + GROUP BY rule`, cutoff) + if err != nil { + return nil, fmt.Errorf("snoozed until: %w", err) + } + defer rows.Close() + out := make(map[string]time.Time) + for rows.Next() { + var rule string + var tsMilli int64 + if err := rows.Scan(&rule, &tsMilli); err != nil { + return nil, err + } + out[rule] = time.UnixMilli(tsMilli).UTC().Add(SnoozeDuration) + } + return out, rows.Err() +} + // RecentNudges — the newest n nudges across all rules, with outcomes, for the // monitoring dash. Newest first. func (s *Store) RecentNudges(ctx context.Context, n int) ([]Nudge, error) { diff --git a/internal/store/nudges_snooze_test.go b/internal/store/nudges_snooze_test.go new file mode 100644 index 0000000..b9321ea --- /dev/null +++ b/internal/store/nudges_snooze_test.go @@ -0,0 +1,104 @@ +package store + +import ( + "context" + "testing" + "time" +) + +// snoozeNudge records a nudge and immediately snoozes it at ts. +func snoozeNudge(t *testing.T, s *Store, rule string, ts time.Time) { + t.Helper() + ctx := context.Background() + id, err := s.RecordNudge(ctx, rule, "voice", "drink water", ts) + if err != nil { + t.Fatalf("RecordNudge: %v", err) + } + if err := s.ResolveNudge(ctx, id, NudgeSnoozed, ts); err != nil { + t.Fatalf("ResolveNudge: %v", err) + } +} + +func TestSnoozedUntilPerRule(t *testing.T) { + s := newTestStore(t) + now := time.Now().UTC().Truncate(time.Millisecond) + + snoozeNudge(t, s, "water", now.Add(-10*time.Minute)) + snoozeNudge(t, s, "break", now.Add(-30*time.Minute)) + + got, err := s.SnoozedUntil(context.Background(), now) + if err != nil { + t.Fatalf("SnoozedUntil: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 snoozed rules, got %v", got) + } + wantWater := now.Add(-10 * time.Minute).Add(SnoozeDuration) + if !got["water"].Equal(wantWater) { + t.Fatalf("water until = %v, want %v", got["water"], wantWater) + } +} + +// The map must only ever hold the newest snooze for a rule, so a stale one +// can't shorten (or lengthen) the live one. +func TestSnoozedUntilUsesNewestSnooze(t *testing.T) { + s := newTestStore(t) + now := time.Now().UTC().Truncate(time.Millisecond) + + snoozeNudge(t, s, "water", now.Add(-90*time.Minute)) + snoozeNudge(t, s, "water", now.Add(-5*time.Minute)) + + got, err := s.SnoozedUntil(context.Background(), now) + if err != nil { + t.Fatalf("SnoozedUntil: %v", err) + } + want := now.Add(-5 * time.Minute).Add(SnoozeDuration) + if !got["water"].Equal(want) { + t.Fatalf("water until = %v, want %v", got["water"], want) + } +} + +// A snooze must expire. If this ever regresses Maven goes quiet forever and +// nobody can tell why. +func TestSnoozedUntilExpires(t *testing.T) { + s := newTestStore(t) + now := time.Now().UTC().Truncate(time.Millisecond) + + snoozeNudge(t, s, "water", now.Add(-SnoozeDuration-time.Minute)) + + got, err := s.SnoozedUntil(context.Background(), now) + if err != nil { + t.Fatalf("SnoozedUntil: %v", err) + } + if _, ok := got["water"]; ok { + t.Fatalf("expired snooze still active: %v", got) + } +} + +// Other outcomes are not snoozes. +func TestSnoozedUntilIgnoresOtherOutcomes(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Millisecond) + + for _, outcome := range []string{NudgeActed, NudgeIgnored} { + id, err := s.RecordNudge(ctx, "water", "voice", "drink water", now) + if err != nil { + t.Fatalf("RecordNudge: %v", err) + } + if err := s.ResolveNudge(ctx, id, outcome, now); err != nil { + t.Fatalf("ResolveNudge: %v", err) + } + } + if _, err := s.RecordNudge(ctx, "break", "voice", "stand up", now); err != nil { + t.Fatalf("RecordNudge: %v", err) + } + + got, err := s.SnoozedUntil(ctx, now) + if err != nil { + t.Fatalf("SnoozedUntil: %v", err) + } + if len(got) != 0 { + t.Fatalf("want no snoozes, got %v", got) + } +} diff --git a/internal/store/proposed_routines.go b/internal/store/proposed_routines.go index 6233817..741fd0a 100644 --- a/internal/store/proposed_routines.go +++ b/internal/store/proposed_routines.go @@ -8,10 +8,23 @@ import ( "time" ) -// ProposedRoutine — a detected pattern the system wants to turn into a -// recurring reminder. Status 'proposed' means awaiting human confirmation; -// 'accepted' means the human confirmed and a reminder was created (reminder_id -// set); 'dismissed' means the human declined and we won't re-propose. +// The three states a proposal can be in. A proposal starts 'proposed' and +// moves once, either way, and never moves again. +const ( + RoutineProposed = "proposed" + RoutineAccepted = "accepted" + RoutineDismissed = "dismissed" +) + +// ProposedRoutine — a detected pattern the system wants to nudge about on a +// repeating interval. Status 'proposed' means awaiting human confirmation; +// 'accepted' means the human confirmed and the tick loop now owns the schedule; +// 'dismissed' means the human declined and we won't re-propose. +// +// AcceptedTs is when the human said yes; it is the clock start for the first +// nudge. LastFiredTs is when the last nudge went out, nil until the first one. +// ReminderID is only set on rows accepted before Vikunja #366, when accepting +// created a one-shot reminder instead. type ProposedRoutine struct { ID int64 Action string @@ -19,7 +32,9 @@ type ProposedRoutine struct { IntervalDays float64 Status string // proposed | accepted | dismissed CreatedTs time.Time - ReminderID *int64 // set when accepted + ReminderID *int64 + AcceptedTs *time.Time + LastFiredTs *time.Time } var ( @@ -30,6 +45,15 @@ var ( // CreateProposedRoutine inserts a new proposed routine. Returns // ErrProposedRoutineExists if one already exists for this action+object (any // status) — the pattern detector should only propose once per pair. +// +// action+object is the "same routine" key. It is UNIQUE in the table, so a +// routine the human already dismissed can never come back: the detector will +// keep finding the pattern, and every re-propose is refused here. Maven is not +// a nag. +// +// TODO(vikunja#46): the detector currently only writes here from the voice +// path. Once digestion runs the detector on its own tick, that tick should +// call this too, so a pattern gets noticed even with nobody at the mic. func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) { res, err := s.db.ExecContext(ctx, `INSERT INTO proposed_routines (action, object, interval_days, status, created_ts) @@ -57,7 +81,7 @@ func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string // nil (no error) when no row exists. func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string) (*ProposedRoutine, error) { row := s.db.QueryRowContext(ctx, ` - SELECT id, action, object, interval_days, status, created_ts, reminder_id + SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts FROM proposed_routines WHERE action = ? AND object = ?`, action, object) r, err := scanProposedRoutine(row) @@ -70,14 +94,25 @@ func (s *Store) LookupProposedRoutine(ctx context.Context, action, object string return &r, nil } -// ListProposedRoutines returns all proposed routines with status='proposed', -// newest first. +// ListProposedRoutines returns the routines still waiting for an answer, +// newest first. This is what the /routines page shows. func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) { - rows, err := s.db.QueryContext(ctx, ` - SELECT id, action, object, interval_days, status, created_ts, reminder_id - FROM proposed_routines - WHERE status = 'proposed' - ORDER BY created_ts DESC, id DESC`) + return s.ListProposedRoutinesByStatus(ctx, RoutineProposed) +} + +// ListProposedRoutinesByStatus returns routines in one status, newest first. +// An empty status returns every row. +func (s *Store) ListProposedRoutinesByStatus(ctx context.Context, status string) ([]ProposedRoutine, error) { + q := `SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts + FROM proposed_routines` + var args []any + if status != "" { + q += ` WHERE status = ?` + args = append(args, status) + } + q += ` ORDER BY created_ts DESC, id DESC` + + rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("list proposed routines: %w", err) } @@ -93,12 +128,22 @@ func (s *Store) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, er return out, rows.Err() } -// AcceptProposedRoutine flips status to 'accepted', links a reminder_id. -// Returns error if not in 'proposed' status. -func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) error { +// Status changes below are an in-place UPDATE, on purpose. Facts are +// append-only (a correction writes a new row and sets voids_id) because a fact +// is a claim about the world and the old claim is still history worth keeping. +// A proposal is not a claim, it is a question with one answer, and the same +// shape already exists for tools (tools.status flips in place). The guard +// `AND status = 'proposed'` makes the move one-way: an answered proposal can +// never be answered again. +// +// AcceptProposedRoutine flips status to 'accepted' and records when. From that +// timestamp the tick loop owns the schedule: it re-reads accepted rows every +// tick and nudges when the interval has passed. Returns an error if the row is +// not in 'proposed' status. +func (s *Store) AcceptProposedRoutine(ctx context.Context, id int64, ts time.Time) error { res, err := s.db.ExecContext(ctx, - `UPDATE proposed_routines SET status = 'accepted', reminder_id = ? WHERE id = ? AND status = 'proposed'`, - reminderID, id) + `UPDATE proposed_routines SET status = 'accepted', accepted_ts = ? WHERE id = ? AND status = 'proposed'`, + ts.UnixMilli(), id) if err != nil { return fmt.Errorf("accept proposed routine: %w", err) } @@ -109,6 +154,42 @@ func (s *Store) AcceptProposedRoutine(ctx context.Context, id, reminderID int64) return nil } +// ListAcceptedRoutines returns every accepted routine, oldest first. The tick +// loop reads this each tick and decides which ones are due. +func (s *Store) ListAcceptedRoutines(ctx context.Context) ([]ProposedRoutine, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, action, object, interval_days, status, created_ts, reminder_id, accepted_ts, last_fired_ts + FROM proposed_routines + WHERE status = 'accepted' + ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("list accepted routines: %w", err) + } + defer rows.Close() + var out []ProposedRoutine + for rows.Next() { + r, err := scanProposedRoutine(rows) + if err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +// MarkRoutineFired records that a routine just nudged. The stored time is the +// nudge time, not the time it was theoretically due, so a routine that was +// silent for a while starts its next interval from now — missed occurrences are +// dropped, never replayed as a backlog. +func (s *Store) MarkRoutineFired(ctx context.Context, id int64, ts time.Time) error { + if _, err := s.db.ExecContext(ctx, + `UPDATE proposed_routines SET last_fired_ts = ? WHERE id = ?`, + ts.UnixMilli(), id); err != nil { + return fmt.Errorf("mark routine fired: %w", err) + } + return nil +} + // DismissProposedRoutine flips status to 'dismissed'. Idempotent. func (s *Store) DismissProposedRoutine(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, @@ -125,12 +206,24 @@ func scanProposedRoutine(sc scanner) (ProposedRoutine, error) { var r ProposedRoutine var created int64 var reminderID sql.NullInt64 - if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID); err != nil { + var accepted, lastFired sql.NullInt64 + if err := sc.Scan(&r.ID, &r.Action, &r.Object, &r.IntervalDays, &r.Status, &created, &reminderID, &accepted, &lastFired); err != nil { return ProposedRoutine{}, err } r.CreatedTs = time.UnixMilli(created).UTC() if reminderID.Valid { r.ReminderID = &reminderID.Int64 } + r.AcceptedTs = millisToTime(accepted) + r.LastFiredTs = millisToTime(lastFired) return r, nil } + +// millisToTime turns a nullable unix-millis column into a *time.Time. +func millisToTime(v sql.NullInt64) *time.Time { + if !v.Valid { + return nil + } + t := time.UnixMilli(v.Int64).UTC() + return &t +} diff --git a/internal/store/proposed_routines_test.go b/internal/store/proposed_routines_test.go index fe29c97..81080d9 100644 --- a/internal/store/proposed_routines_test.go +++ b/internal/store/proposed_routines_test.go @@ -43,12 +43,7 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) { } // Accept - // First create a reminder to link - remID, err := s.CreateReminder(ctx, now.Add(7*24*time.Hour), `{"text":"refill cat water"}`, "0 10 * * 0") - if err != nil { - t.Fatalf("CreateReminder: %v", err) - } - if err := s.AcceptProposedRoutine(ctx, id, remID); err != nil { + if err := s.AcceptProposedRoutine(ctx, id, now); err != nil { t.Fatalf("AcceptProposedRoutine: %v", err) } @@ -60,8 +55,50 @@ func TestCreateAndAcceptProposedRoutine(t *testing.T) { if r.Status != "accepted" { t.Fatalf("want status=accepted, got %s", r.Status) } - if r.ReminderID == nil || *r.ReminderID != remID { - t.Fatalf("want reminder_id=%d, got %v", remID, r.ReminderID) + if r.AcceptedTs == nil || !r.AcceptedTs.Equal(now.Truncate(time.Millisecond)) { + t.Fatalf("want accepted_ts=%v, got %v", now, r.AcceptedTs) + } + if r.LastFiredTs != nil { + t.Fatalf("a freshly accepted routine has not fired yet, got %v", r.LastFiredTs) + } +} + +// TestAcceptedRoutineFiredTimestamp — the tick loop's two reads: the accepted +// list, and the last-fired stamp it writes back after a nudge. +func TestAcceptedRoutineFiredTimestamp(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Millisecond) + + id, err := s.CreateProposedRoutine(ctx, "полить", "цветы", 3.0, now) + if err != nil { + t.Fatalf("CreateProposedRoutine: %v", err) + } + if err := s.AcceptProposedRoutine(ctx, id, now); err != nil { + t.Fatalf("AcceptProposedRoutine: %v", err) + } + + list, err := s.ListAcceptedRoutines(ctx) + if err != nil { + t.Fatalf("ListAcceptedRoutines: %v", err) + } + if len(list) != 1 || list[0].ID != id { + t.Fatalf("want the one accepted routine, got %+v", list) + } + if list[0].IntervalDays != 3.0 { + t.Fatalf("want interval_days=3, got %v", list[0].IntervalDays) + } + + fired := now.Add(3 * 24 * time.Hour) + if err := s.MarkRoutineFired(ctx, id, fired); err != nil { + t.Fatalf("MarkRoutineFired: %v", err) + } + list, err = s.ListAcceptedRoutines(ctx) + if err != nil { + t.Fatalf("ListAcceptedRoutines: %v", err) + } + if list[0].LastFiredTs == nil || !list[0].LastFiredTs.Equal(fired) { + t.Fatalf("want last_fired_ts=%v, got %v", fired, list[0].LastFiredTs) } } @@ -131,6 +168,92 @@ func TestListProposedRoutines(t *testing.T) { } } +// A dismissed routine must never be proposed again. The detector will keep +// finding the same pattern; the store is what stops maven nagging about it. +func TestDismissedProposedRoutineStaysDismissed(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + id, err := s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now) + if err != nil { + t.Fatalf("CreateProposedRoutine: %v", err) + } + if err := s.DismissProposedRoutine(ctx, id); err != nil { + t.Fatalf("DismissProposedRoutine: %v", err) + } + + // The detector re-proposes the same pattern. + _, err = s.CreateProposedRoutine(ctx, "clean", "litter_box", 3.0, now.Add(24*time.Hour)) + if !errors.Is(err, ErrProposedRoutineExists) { + t.Fatalf("want ErrProposedRoutineExists on re-propose, got %v", err) + } + + // And it must not reappear on the review page. + list, err := s.ListProposedRoutines(ctx) + if err != nil { + t.Fatalf("ListProposedRoutines: %v", err) + } + if len(list) != 0 { + t.Fatalf("want 0 proposed, got %d", len(list)) + } + + // Dismissing again is a no-op, and accepting is refused. + if err := s.DismissProposedRoutine(ctx, id); err != nil { + t.Fatalf("second DismissProposedRoutine: %v", err) + } + if err := s.AcceptProposedRoutine(ctx, id, time.Now().UTC()); !errors.Is(err, ErrProposedRoutineNotFound) { + t.Fatalf("want ErrProposedRoutineNotFound accepting a dismissed routine, got %v", err) + } + r, err := s.LookupProposedRoutine(ctx, "clean", "litter_box") + if err != nil { + t.Fatalf("LookupProposedRoutine: %v", err) + } + if r.Status != RoutineDismissed { + t.Fatalf("want status=dismissed, got %s", r.Status) + } +} + +func TestListProposedRoutinesByStatus(t *testing.T) { + s := newTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + keep, err := s.CreateProposedRoutine(ctx, "water", "plants", 4.0, now) + if err != nil { + t.Fatalf("CreateProposedRoutine: %v", err) + } + drop, err := s.CreateProposedRoutine(ctx, "walk", "dog", 1.0, now.Add(time.Hour)) + if err != nil { + t.Fatalf("CreateProposedRoutine: %v", err) + } + if err := s.AcceptProposedRoutine(ctx, keep, now); err != nil { + t.Fatalf("AcceptProposedRoutine: %v", err) + } + if err := s.DismissProposedRoutine(ctx, drop); err != nil { + t.Fatalf("DismissProposedRoutine: %v", err) + } + + cases := []struct { + status string + want int + }{ + {RoutineProposed, 0}, + {RoutineAccepted, 1}, + {RoutineDismissed, 1}, + {"", 2}, // empty status ⇒ every row + } + for _, c := range cases { + list, err := s.ListProposedRoutinesByStatus(ctx, c.status) + if err != nil { + t.Fatalf("ListProposedRoutinesByStatus(%q): %v", c.status, err) + } + if len(list) != c.want { + t.Fatalf("status %q: want %d, got %d", c.status, c.want, len(list)) + } + } +} + func TestLookupMissingProposedRoutine(t *testing.T) { s := newTestStore(t) ctx := context.Background() diff --git a/internal/ttsnorm/ttsnorm.go b/internal/ttsnorm/ttsnorm.go index f88d2c5..99e83e9 100644 --- a/internal/ttsnorm/ttsnorm.go +++ b/internal/ttsnorm/ttsnorm.go @@ -55,4 +55,9 @@ func spokenDate(dd, mm, yyyy string) string { } func mustInt(s string) int { n, _ := strconv.Atoi(s); return n } -func gap(y string) string { if y == "" { return "" }; return " " + y } +func gap(y string) string { + if y == "" { + return "" + } + return " " + y +} diff --git a/kill-maven.sh b/kill-maven.sh index 573da5b..df3ec80 100755 --- a/kill-maven.sh +++ b/kill-maven.sh @@ -1,18 +1,97 @@ #!/usr/bin/env bash # Unified script to stop all Maven services. # Usage: ./kill-maven.sh -# - Graceful SIGTERM is attempted first. -# - If any process lingers, force with SIGKILL. +# - Docker deploy: `docker compose stop` (see why below). +# - Bare-metal / dev run: graceful SIGTERM first, SIGKILL if anything lingers. +# Exits non-zero if it cannot confirm everything is stopped. It must never say +# "stopped" unless it checked. set -euo pipefail # The llama-server the phraser spawns has NO "maven" in its command line (its -# args are `-m /path/to/LFM2.5-...gguf --port ...`), so a `llama-server.*maven` +# args are `-m /path/to/.gguf --port ...`), so a `llama-server.*maven` # pattern matches nothing and leaks it — the exact bug that let orphans pile up -# and OOM the box. Match the model instead. Override MODEL if you change it. -MODEL="${MODEL:-LFM2}" +# and OOM the box. +# +# We used to match the model name, defaulting to LFM2. The deploy now runs +# Qwen3.5-0.8B, so that default matched nothing and the server survived every +# kill. Match any llama-server serving a .gguf instead, so swapping the model in +# deploy/mavend.json cannot break this script again. Set MODEL to narrow it if +# some other llama-server on this box must be left alone. +MODEL="${MODEL:-}" PAT='mavend|mavsttd|mavttsd|mavweb|mavpoll|mavenclient' -LLM="llama-server.*${MODEL}" +if [ -n "$MODEL" ]; then + LLM="llama-server.*${MODEL}" +else + LLM='llama-server.*\.gguf' +fi + +COMPOSE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/docker-compose.yml" + +# --- containerised deploy ------------------------------------------------ +# docker-compose.yml does not set `pid: host`, so each container has its own +# PID namespace: pkill on the host sees nothing inside them. This script used +# to print "all stopped" while every daemon was still happily running. Stop the +# containers through compose instead — that actually reaches them. +# +# running_containers prints the ids of the project's running containers, or +# nothing. Empty output plus a non-zero return means "could not ask docker", +# which is different from "nothing is running" and is handled below. +running_containers() { + docker compose -f "$COMPOSE_FILE" ps -q --status running 2>/dev/null +} + +DOCKER_OK=0 +CONTAINERS="" +if command -v docker >/dev/null 2>&1 && [ -f "$COMPOSE_FILE" ]; then + if CONTAINERS="$(running_containers)"; then + DOCKER_OK=1 + fi +fi + +if [ "$DOCKER_OK" = 1 ] && [ -n "$CONTAINERS" ]; then + echo "--- Maven is running in containers: stopping via docker compose ---" + if ! docker compose -f "$COMPOSE_FILE" stop; then + echo "ERROR: 'docker compose stop' failed. Containers may still be running." >&2 + exit 1 + fi + echo "--- Verifying containers are gone ---" + LEFT="$(running_containers || true)" + if [ -n "$LEFT" ]; then + echo "ERROR: containers still running after stop:" >&2 + docker compose -f "$COMPOSE_FILE" ps >&2 || true + exit 1 + fi + echo "All containers stopped." + exit 0 +fi + +# --- bare-metal / dev run ----------------------------------------------- +# pgrep -f matches whole command lines, so a shell that merely mentions +# "mavend" (this script's own parent, for one) shows up. Drop ourselves and our +# parent, otherwise the SIGKILL sweep can take out the terminal you ran this in. +host_pids() { + pgrep -f "$PAT|$LLM" | grep -v -e "^$$\$" -e "^$PPID\$" | paste -sd, - || true +} +HOST_PIDS=$(host_pids) + +if [ -z "$HOST_PIDS" ]; then + # Nothing on the host. Whether that means "already down" depends on whether + # we managed to ask docker, and the two must not read the same. + if [ "$DOCKER_OK" = 1 ]; then + # Docker answered and named no running containers, and there is nothing + # on the host either. That is a real answer: Maven is already stopped. + echo "Nothing to stop: no Maven processes and no running containers." + exit 0 + fi + # We could not ask docker, so Maven may be alive in a container we cannot + # see. Saying "stopped" here is the exact false success this script had. + echo "ERROR: no Maven processes on this host, and docker could not be asked." >&2 + echo " If this is the container deploy it may still be running:" >&2 + echo " docker compose -f $COMPOSE_FILE stop" >&2 + echo " Nothing was stopped. Check by hand before assuming Maven is down." >&2 + exit 1 +fi echo "--- Sending graceful SIGTERM to Maven services ---" pkill -TERM -f "$PAT" || true @@ -22,12 +101,18 @@ pkill -TERM -f "$LLM" || true echo "--- Verifying processes are gone ---" sleep 1 -PIDS=$(pgrep -d ',' -f "$PAT|$LLM") || PIDS="" +PIDS=$(host_pids) if [ -n "$PIDS" ]; then echo "Warning: some processes still alive. PIDs: $PIDS" echo "--- Force killing with SIGKILL ---" echo "$PIDS" | tr ',' '\n' | xargs -r kill -9 + sleep 1 + LEFT=$(host_pids) + if [ -n "$LEFT" ]; then + echo "ERROR: still alive after SIGKILL. PIDs: $LEFT" >&2 + exit 1 + fi echo "Done (SIGKILL)." else echo "All services gracefully stopped." -fi \ No newline at end of file +fi