Compare commits
230 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0db31d21b9 | |||
| df3220d039 | |||
| 76a251a20d | |||
| 724e90759e | |||
| 2bf11f052d | |||
| 2ca5ffa4f9 | |||
| 77888c1a9c | |||
| 71b42e31bd | |||
| cb04799b09 | |||
| 2a3701d012 | |||
| 327726a06a | |||
| 57161fb762 | |||
| 8846b7e43c | |||
| b436be69c3 | |||
| d0e98a9419 | |||
| 9a9f4464d5 | |||
| 543aefde4b | |||
| e926e4e6df | |||
| 694d9e4e45 | |||
| 4b052fb9d2 | |||
| 61ba58388f | |||
| 5e52b55ee9 | |||
| 59cdcc4e19 | |||
| 3588da9e28 | |||
| d3fcc1dfdb | |||
| 89afe4ca99 | |||
| fa783cba8f | |||
| f8af9299dd | |||
| 5c17b2db06 | |||
| 6316354518 | |||
| 88d25d31ac | |||
| 708a69375f | |||
| d68708b5e1 | |||
| 3ff2a9340a | |||
| 617476772e | |||
| 802d5961ac | |||
| 252f773223 | |||
| f432eb0b25 | |||
| 5aaecd2a53 | |||
| ec5167de3a | |||
| f02f3b55b6 | |||
| da62a2f25e | |||
| 52f56947bb | |||
| 5e0417306b | |||
| 87d03cf8c6 | |||
| 1c94df76b7 | |||
| 9e383eb751 | |||
| 8c6332f95c | |||
| bddf52d1ee | |||
| b3c2fad4ec | |||
| d62ba093f5 | |||
| c21d8fdcee | |||
| 810076451f | |||
| fa799bc051 | |||
| 4757ff6d7b | |||
| 7ab9b48259 | |||
| aee20a6abc | |||
| b2eb08bb51 | |||
| ba33a677f8 | |||
| f891a81ab2 | |||
| 38b09ded95 | |||
| 6c81df17ec | |||
| d69a1f8076 | |||
| e4bfcd958f | |||
| 012bdcc1ae | |||
| 4f012e350c | |||
| 4e4c9170e3 | |||
| 88c841cb0e | |||
| 0e83ddf3df | |||
| 49dfeb879e | |||
| 7f42cc73be | |||
| 927e46bca3 | |||
| 08f3db318f | |||
| 69e2800ef3 | |||
| a8fcb404be | |||
| dc4c5b7841 | |||
| 33e53ee897 | |||
| 45b5e16eff | |||
| 4eca20bd94 | |||
| fed33a4e16 | |||
| 62cc072f8c | |||
| 7c7bd8ceeb | |||
| aa1a26532c | |||
| d92349ca6e | |||
| 8d5e357b57 | |||
| 95ae900a58 | |||
| be066a4b04 | |||
| ad074cea31 | |||
| 2c1b0eede0 | |||
| cb3641e7bb | |||
| ee7bec11e3 | |||
| f42d1594ef | |||
| b4646155b4 | |||
| da647e87d0 | |||
| bf6ccf9aea | |||
| 7b2b96b957 | |||
| c8444813e2 | |||
| ed9bdd5e09 | |||
| 49f089d8a6 | |||
| 3af290152c | |||
| dc7c72a3d7 | |||
| 766ca091a7 | |||
| c5317eb2b4 | |||
| 9190f897a3 | |||
| d29e7ba813 | |||
| f7e1187823 | |||
| ed48c59ba7 | |||
| b09967f9e6 | |||
| b4a3867479 | |||
| 88d07b5175 | |||
| c00e3003bf | |||
| c0f9834528 | |||
| ad5eb2d1cf | |||
| 5253123d99 | |||
| 2abf98dea6 | |||
| f5c71b87f3 | |||
| 5934110fa8 | |||
| 6e47a3d736 | |||
| 67a5eb3805 | |||
| 029449eefa | |||
| ac36216f5d | |||
| db17cfcc65 | |||
| 7d676eb941 | |||
| fe3a4e9514 | |||
| a906f2afad | |||
| a2031a31d1 | |||
| 9b8bdf73cc | |||
| 7ad3c9a408 | |||
| 84e1478823 | |||
| 9c8d0baffe | |||
| b0f5a16ec9 | |||
| 67563ed1f6 | |||
| f0f7ebc9b2 | |||
| d12de589a2 | |||
| 50cc17f33a | |||
| 73d13f1ea6 | |||
| 0ca5748699 | |||
| b43bb265b5 | |||
| b9a24334ea | |||
| c97aebf55a | |||
| 891136c65d | |||
| 41c7c13f42 | |||
| a324e8f624 | |||
| 51805e7f35 | |||
| 533f0acda8 | |||
| 4f59ba78c6 | |||
| d0afd9d4f6 | |||
| 6b67e6f3c2 | |||
| 742b2ad1d7 | |||
| b300ac5c70 | |||
| 13e5170e9e | |||
| 0b90952e55 | |||
| aa8f5b2ee2 | |||
| d7cdcb63bd | |||
| ddb658ffbb | |||
| c7dadc97d9 | |||
| c9d88c152e | |||
| 1890ff5d5d | |||
| 0110e9bc8c | |||
| 50ca8c8b5a | |||
| de09471421 | |||
| d65c16a567 | |||
| 062d4252ef | |||
| 2c27e2ce1f | |||
| ccc5cba2a3 | |||
| 89d83c0b11 | |||
| a97f554802 | |||
| f4de2fc5e1 | |||
| eef5d4da4f | |||
| 09f1696fce | |||
| 80f7322294 | |||
| fa5aebfbe4 | |||
| 59cec63da1 | |||
| 02e8786695 | |||
| 0272dc9d89 | |||
| 2ad7635501 | |||
| 9949b309b1 | |||
| a788ca3915 | |||
| 62d47d28ac | |||
| e9ff2c4912 | |||
| 3dbf67f8f9 | |||
| 84ba217892 | |||
| f179ae2fde | |||
| d00929ac0b | |||
| b6f47fbeb6 | |||
| 2e9b9ec1cf | |||
| bfb57c3148 | |||
| d1f6f6355f | |||
| 92ecb691de | |||
| 4282f6b9a9 | |||
| 7bb9f9be06 | |||
| 1e47eaca5a | |||
| 892330eb84 | |||
| 9a3bcd7c46 | |||
| 98ee701e03 | |||
| 04c1088088 | |||
| 07c191d8b8 | |||
| c668310b3e | |||
| 1bd2acdc2a | |||
| 15e5dd8eaa | |||
| 10cf6f525c | |||
| e2210f6844 | |||
| 214a4032cf | |||
| dc70a5a7ab | |||
| 06aded6ab0 | |||
| d2be98ee2a | |||
| 62d320f93a | |||
| 796e6af3cf | |||
| 74a70880a8 | |||
| a40bc559d5 | |||
| 1db0fcfcd0 | |||
| 4ca68d2f3f | |||
| 5a1d465db5 | |||
| 43838445ab | |||
| 11831c6ace | |||
| 93c1a41d4a | |||
| bce5ed210c | |||
| c31f0d1001 | |||
| 34521c30b8 | |||
| 1d48755d12 | |||
| f6d5a2a7a4 | |||
| 751c2a705f | |||
| 5d5b0cfd49 | |||
| bd16ca69e5 | |||
| 0ed386eca6 | |||
| ee3e6a9eaf | |||
| 8acb8a97c6 | |||
| 0b3b8d0a9e | |||
| fe0e654ab1 | |||
| a54ebac0cb |
@@ -6,6 +6,9 @@
|
||||
/mavweb
|
||||
/mavpoll
|
||||
/mavcaldav
|
||||
/mavwaked
|
||||
/mavmaild
|
||||
/mavupdate
|
||||
|
||||
# Certs (private keys, don't commit)
|
||||
certs/
|
||||
@@ -33,6 +36,10 @@ deps
|
||||
deploy/db_key.env
|
||||
# Deploy secret (telegram bot token + chat id) — never commit
|
||||
deploy/telegram.env
|
||||
# zenmoney API token, read by mavpoll (never in argv, never committed)
|
||||
deploy/zenmoney.token
|
||||
# IMAP password, read by mavmaild (never in argv, never committed)
|
||||
deploy/imap.password
|
||||
|
||||
# Temp files
|
||||
/tmp/
|
||||
@@ -45,3 +52,5 @@ coverage.out
|
||||
|
||||
# Agent worktrees and local agent state
|
||||
.claude/
|
||||
/models/stt
|
||||
/models/tts
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
@@ -23,7 +34,7 @@ CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored to
|
||||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||||
|
||||
```sh
|
||||
make build # all 8 binaries
|
||||
make build # all 9 binaries
|
||||
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
||||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||||
```
|
||||
@@ -51,6 +62,7 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
||||
| `mavpoll` | Telegram long-poll reach. |
|
||||
| `mavcaldav` | CalDAV calendar sync. |
|
||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||
|
||||
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
||||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||||
@@ -58,19 +70,41 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g
|
||||
|
||||
## Routing — read this before touching the router
|
||||
|
||||
`internal/router/` has TWO layered engines and the committed default is an **interim
|
||||
stopgap, not the intended design** (see memory `routing-architecture-target`):
|
||||
`internal/router/` has TWO layered engines. **The LLM router is now the default and it is
|
||||
on in deploy** — this section used to say it was wired `nil`, which stopped being true on
|
||||
2026-07-31.
|
||||
|
||||
- **Target (REARCH.md):** LLM-as-router. One resident Qwen3-1.7B (`llmrouter.go`) emits
|
||||
GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is demoted
|
||||
from a routing gate to a RAG hint.
|
||||
- **Current stopgap:** `llmrouter` is wired `nil` (around `voice.go`), so the
|
||||
`classifier.go` + `embedder.go` nearest-neighbour cascade actually runs. It routes by
|
||||
similarity to frozen seed phrases — the known cause of weak RU query handling.
|
||||
- **LLM router (the intended design, REARCH.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
||||
emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is
|
||||
demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via
|
||||
`pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router`
|
||||
(`config.go`), `DefaultLLMRouter` is **on**, and `deploy/mavend.json` sets it `true`.
|
||||
- **Classifier cascade (the failure floor, not dead code):** `classifier.go` +
|
||||
`embedder.go` nearest-neighbour over frozen seed phrases. It runs when the LLM router is
|
||||
off, when there is no llama-server to talk to (`pickLLMRouter` logs that and degrades),
|
||||
and on any per-turn LLM error. Do not delete it — routing by seed similarity is the known
|
||||
cause of weak RU query handling, but a turn must never break on the model.
|
||||
|
||||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||||
|
||||
Measured on the 77-case RU fixture (`MODEL-BAKEOFF-31-07-2026.md`): the classifier scores
|
||||
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
|
||||
cascade at p50 ≈2.7s. Accuracy roughly doubled, latency is ~90× worse, and that trade was
|
||||
accepted deliberately. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||||
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
|
||||
`gateLLMDecision` in `router.go`. Note the second half of that bug: the LLM branch never
|
||||
consulted `r.threshold` at all, so a correct low confidence would have been discarded anyway.
|
||||
|
||||
Re-measured on the fixture after the fix: **missed clarify 6/6 → 1**, at the cost of 3 false
|
||||
clarifies and 2.6pt of full accuracy (72.7% → 70.1%, intent-only 67.5% → 74.0%). Two of the
|
||||
three false clarifies are acts the model mis-routed and the gate caught — asking beats wrongly
|
||||
executing, so the fixture and the daemon disagree about what is correct there. The third,
|
||||
`"поужинал"`, is a real defect: **the single-token rule is an English intuition and does not
|
||||
transfer to Russian**, where one word is routinely a whole sentence. Narrow or drop it.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
@@ -82,8 +116,26 @@ 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
|
||||
self-reference must use feminine forms (the user is male; see memory `maven-persona-gender`).
|
||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
||||
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
||||
(she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name
|
||||
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||||
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
||||
|
||||
**"Never phones home" is DEPRECATED** (owner's call, 2026-07-31). It used to be a hard
|
||||
constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+2
-1
@@ -51,7 +51,8 @@ RUN go build -o /out/mavend ./cmd/mavend && \
|
||||
go build -o /out/mavttsd ./cmd/mavttsd && \
|
||||
go build -o /out/mavweb ./cmd/mavweb && \
|
||||
go build -o /out/mavpoll ./cmd/mavpoll && \
|
||||
go build -o /out/mavcaldav ./cmd/mavcaldav
|
||||
go build -o /out/mavcaldav ./cmd/mavcaldav && \
|
||||
go build -o /out/mavmaild ./cmd/mavmaild
|
||||
|
||||
# llama.cpp Vulkan build — the phraser/router LFM engine (llama-server). Built
|
||||
# from source (not a prebuilt vendored blob) so the binary's glibc/GLIBCXX match
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# 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:<port> make eval-router`
|
||||
(`TestLLMRouterBaseline`). (This line used to say there is no `make eval-models` target.
|
||||
There is one now — start a server with the gguf you want, then
|
||||
`make eval-models MAVEN_LLM_URL=http://127.0.0.1:<port>`. It runs only the LLM test, since
|
||||
the classifier baselines do not depend on the model.)
|
||||
- All three models served by the same `llama-server` flags — `-c 2048 -ngl 99 -t 6`, only
|
||||
`-m` and `--port` differ. One server at a time on an otherwise idle box, so latencies are
|
||||
real and not contention.
|
||||
- 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`.~~ **Resolved the same evening:** the LLM router is wired at `voice.go:214`
|
||||
behind `voice.llm_router`, the default is on, and `deploy/mavend.json` sets it `true`.
|
||||
These numbers are the production path now, so the p50 ≈2.7s is a real per-turn cost and
|
||||
not a bench artifact.
|
||||
- ~~`/mnt/hdd1/llms/LFM2.5/Qwen3-1.7B-UD-Q4_K_XL.gguf` is a 293 MB truncated download
|
||||
in the wrong directory.~~ **Deleted 2026-07-31.** The good 1.13 GB copy in `qwen3/` is
|
||||
what `deploy/mavend.json` loads.
|
||||
- Harness: `scratchpad/bakeoff.sh`, one server at a time, health-checked before each
|
||||
run, `/v1/models` recorded per run. Never run two LLM consumers at once — see the
|
||||
contamination note in `TALK-EVAL-31-07-2026.md`.
|
||||
@@ -16,11 +16,11 @@ 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 fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
|
||||
.PHONY: simulate stt-fixtures test-stt-golden 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
|
||||
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav
|
||||
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update
|
||||
|
||||
build-stt:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
@@ -50,6 +50,15 @@ build-poll:
|
||||
build-caldav:
|
||||
$(GO) build $(GOFLAGS) -o mavcaldav ./cmd/mavcaldav/
|
||||
|
||||
build-mail:
|
||||
$(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/
|
||||
|
||||
# mavupdate is an operator CLI, not a daemon: nothing runs it but a human on the
|
||||
# box. It is built with the rest so a broken update path is caught by `make
|
||||
# build` rather than the first time it is needed.
|
||||
build-update:
|
||||
$(GO) build $(GOFLAGS) -o mavupdate ./cmd/mavupdate/
|
||||
|
||||
run-web: build-web
|
||||
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
||||
|
||||
@@ -82,6 +91,14 @@ vet:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) vet ./internal/... ./cmd/...
|
||||
|
||||
# simulate — replay every scripted day under cmd/mavend/testdata/scenarios
|
||||
# through the real router, store, tick loop and intake journal, on a fake clock
|
||||
# (Vikunja #284). Verbose so the transcript of each scenario lands in the
|
||||
# terminal. Also runs as part of `make test`; this target is for reading it.
|
||||
simulate:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -v -count=1 -run TestSimulator ./cmd/mavend/
|
||||
|
||||
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/...
|
||||
@@ -103,14 +120,17 @@ eval-router:
|
||||
eval-recall:
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/memory/recalleval/
|
||||
|
||||
# eval-phrasing -- score nudge phrasing (internal/phraser/eval). Verbose so the
|
||||
# eval-phrasing -- score nudge phrasing AND the conversational paths (chat,
|
||||
# query, general knowledge) in internal/phraser/eval. Verbose so the
|
||||
# report and every generated message land in the terminal. With no environment
|
||||
# it scores the deterministic Stub only, which is what CI runs. Set
|
||||
# MAVEN_LLM_URL to add the resident model:
|
||||
# MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing
|
||||
# The model run is slow (minutes) -- the timeout is raised to match.
|
||||
# The model run is slow (minutes) -- the timeout is raised to match. It covers
|
||||
# two fixtures now (15 nudges + 27 conversational cases, and the chat replies are
|
||||
# the long ones), hence 90m rather than 40m.
|
||||
eval-phrasing:
|
||||
$(GO) test -v -count=1 -timeout 40m ./internal/phraser/eval/
|
||||
$(GO) test -v -count=1 -timeout 90m ./internal/phraser/eval/
|
||||
|
||||
# eval-models — score ONE llama-server against the same fixture, for the
|
||||
# resident-model bake-off (#278, #250). Start a server with the gguf you want,
|
||||
@@ -127,6 +147,22 @@ eval-models:
|
||||
MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \
|
||||
-run TestLLMRouterBaseline ./internal/router/eval/
|
||||
|
||||
# stt-fixtures — regenerate the golden STT audio in cmd/mavsttd/testdata from
|
||||
# the piper voices (#288). The committed WAVs are synthesised, never recorded,
|
||||
# so this is the only way they should ever change. The spoken text is read out
|
||||
# of testdata/golden_v1.json, so edit the transcript there and rerun this.
|
||||
#
|
||||
# test-stt-golden runs both golden tests: TestGoldenAudioTranscription, which
|
||||
# scores the fixtures against ggml-small and self-skips when the model is
|
||||
# absent, and TestGoldenFixturesAreCanonical, which checks the committed audio
|
||||
# and the manifest with no model at all.
|
||||
stt-fixtures:
|
||||
./scripts/gen-stt-fixtures.sh
|
||||
|
||||
test-stt-golden:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -v -count=1 -run TestGolden ./cmd/mavsttd/
|
||||
|
||||
run-stt: build-stt
|
||||
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL)
|
||||
@@ -152,9 +188,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)
|
||||
@@ -178,4 +218,4 @@ download-embedder:
|
||||
@echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/'
|
||||
|
||||
clean:
|
||||
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked
|
||||
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked mavmaild
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+153
-2
@@ -75,6 +75,14 @@ same vector. A note is indexed in both places with the same embedding, so if it
|
||||
`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
|
||||
@@ -83,14 +91,157 @@ sqlite-backed `store.MemoryStore` and `memory.InMemoryStore` identically — bot
|
||||
(`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** (`top1 − top2 > δ`) — as the routing eval
|
||||
concluded, absolute cosine cannot see a flat distribution.
|
||||
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.
|
||||
|
||||
+138
-5
@@ -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.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Conversational phrasing eval — 31-07-2026
|
||||
|
||||
Every score measured tonight, on the three paths the nudge eval never touched:
|
||||
chat, query-with-notes, and general knowledge.
|
||||
|
||||
**Short version: the plumbing got fixed and the score barely moved.** Grammar and
|
||||
Russian prompts together took the composite from ~9 to ~14 of 27. Everything
|
||||
still failing is the model not knowing things or not holding a constraint, and
|
||||
prompting is out of levers. Settles the measurement half of Vikunja #395 / #398 /
|
||||
#400.
|
||||
|
||||
## How to reproduce
|
||||
|
||||
```sh
|
||||
# llama-server: -c 4096 -ngl 99 -t 6, model /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf
|
||||
MAVEN_LLM_URL=http://127.0.0.1:18099 no_proxy=127.0.0.1,localhost \
|
||||
deps/go/go/bin/go test -count=1 -timeout 40m \
|
||||
-run TestLLMTalkBaseline ./internal/phraser/eval/ -v
|
||||
```
|
||||
|
||||
Three runs per configuration, always. The fixture is 27 cases, so one reply
|
||||
changing moves the composite by 3.7 points — a single run cannot tell a real
|
||||
change from sampling noise. This was learned the expensive way: an earlier claim
|
||||
that "one nudge case fails every run" turned out to be three different cases
|
||||
across three runs.
|
||||
|
||||
**Run the box otherwise idle.** See the contamination note at the bottom.
|
||||
|
||||
## Composite, per configuration
|
||||
|
||||
| config | overall /27 | chat /9 | query /9 | knowledge /9 | canned fallbacks |
|
||||
|---|---|---|---|---|---|
|
||||
| baseline, no grammar | 7, 12, 7 | 1, 1, 0 | 2, 4, 2 | 4, 7, 5 | 0, 0, 0 |
|
||||
| + GBNF grammar (#398) | 14, 15, 8 | 1, 3, 0 | 5, 6, 3 | 8, 6, 5 | 0, 0, 0 |
|
||||
| + Russian prompts (#400) | 11, 17, 15 | 1, 5, 3 | 5, 6, 8 | 5, 6, 4 | 0, 0, 0 |
|
||||
| + truncation fix, 1000ch/768tok | 12, 13, 10 | 2, 2, 1 | 7, 7, 5 | 3, 4, 4 | 3, 3, 6 |
|
||||
| + rebalanced, 600ch/1024tok | **void — contaminated** | | | | |
|
||||
|
||||
"Canned fallbacks" counts replies that came back as the hardcoded `"не знаю."`
|
||||
or `"поговорили."`. It is not a check, it is a health signal: those strings mean
|
||||
the phraser gave up, and the eval scores them as ordinary bad replies.
|
||||
|
||||
## Per-check
|
||||
|
||||
| check | no grammar | + grammar | + RU prompts | + truncation fix |
|
||||
|---|---|---|---|---|
|
||||
| nonempty | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||
| ellipsis | 20, 19, 23 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||
| lang | 13, 16, 15 | 23, 26, 26 | 25, 26, 25 | 26, 27, 27 |
|
||||
| feminine | — | — | 25, 24, 26 | 25, 25, 27 |
|
||||
| address | — | — | 21, 22, 22 | 22, 21, 22 |
|
||||
| ontopic | — | — | 17, 24, 18 | 17, 19, 14 |
|
||||
|
||||
`nonempty` reading 27/27 everywhere is not good news — it was a broken check.
|
||||
It tested for a non-blank string, so replies of literally `{` and `"15-16"`
|
||||
passed it. Fixed on `overnight/fix-truncation`; it needs a letter now.
|
||||
|
||||
## What each change actually bought
|
||||
|
||||
**GBNF grammar (#398) — the biggest single win.** Qwen3.5-0.8B writes
|
||||
`Thinking Process:` as plain text with no tags, `stripThink` only handles
|
||||
`</think>`, so the JSON never closed and the plain-text fallback shipped the
|
||||
literal reasoning. `ellipsis` went 20→27 and `lang` 13→26. The router had been
|
||||
using a grammar for ages; the phraser asking nicely in the prompt was the
|
||||
oversight.
|
||||
|
||||
**Russian prompts (#400) — modest, plus a large latency win.** Chat 1.3→3.0
|
||||
average, query 4.7→6.3, knowledge 6.3→5.0. All inside the run-to-run spread, so
|
||||
"probably better on the paths it targeted, not provable in three runs". p50
|
||||
latency dropped from ~11.5s to ~2.3s and that part is consistent across all
|
||||
three runs — shorter prompts, and she stopped emitting English reasoning first.
|
||||
|
||||
**Truncation fix — necessary, and did not help the score.** Two real bugs
|
||||
(replies of `{`, and a `nonempty` check that passed them), both fixed, and the
|
||||
composite went nowhere. A complete rambling wrong answer fails the same checks a
|
||||
truncated one did. Worth doing anyway: the daemon was shipping `{` to a
|
||||
text-to-speech voice.
|
||||
|
||||
## The truncation bug, since the cause was counter-intuitive
|
||||
|
||||
The grammar's `string ::= ... {0,400}` rule was the cause, not the token cap.
|
||||
Measured against Qwen3.5-0.8B at three caps — 256, 768 and 2048 — the reply came
|
||||
back **exactly 400 characters every time, cut mid-word** (`"Нужно записать и,"`).
|
||||
|
||||
Then I raised the bound to 1000 while the cap was 768 tokens and made it worse:
|
||||
Russian runs ~1.5 characters per token here, so generation died on the *token*
|
||||
cap instead, mid-object, and the new guard correctly refused it and shipped
|
||||
`"не знаю."` — 3, 3 and 6 fallbacks per run, from zero. **The two limits have to
|
||||
agree.** 600 characters needs ~400 tokens; the cap is 1024.
|
||||
|
||||
## Where the remaining failures live
|
||||
|
||||
`address` is stuck at 21-22 of 27 and `ontopic` at 14-19. Both resist prompting.
|
||||
|
||||
**The prompt now explicitly forbids exactly what she does.** It says never "вы",
|
||||
use the singular — and she writes `вашей`, `подождите`, `делаете`, `хотите`,
|
||||
`напишите`. Telling a 0.8B "never do X" does not work. Same for
|
||||
`feminine`: `я готов`, `я понял`, `я нашел`, `я заметил`, `я сказал`.
|
||||
|
||||
**Some of `ontopic` is the fixture, not the model.** `chat-how-are-you` got
|
||||
`"Привет! Я здесь, чтобы поговорить. Как дела сегодня?"` — a fine reply that
|
||||
fails because `want_any` is `[норм, хорош, порядк, тут, работ]`. It fails in
|
||||
every run, so it inflates the count. The `ontopic` column currently measures the
|
||||
fixture as much as the model. Not fixed yet, deliberately: changing it would
|
||||
break comparability with the runs above.
|
||||
|
||||
**Two replies worth reading, because they are not fixable by prompting:**
|
||||
|
||||
- Thunder and lightning: *"Скорость молнии — 8-10 тысяч километров в секунду, но
|
||||
звук — 300 метров в секунду, что делает молнию громче."* Confidently wrong,
|
||||
and it concludes lightning is *louder* rather than sound being *slower*.
|
||||
- "расскажи обо мне": *"Ты — прекрасное существо, с душой и вниманием… Спасибо за
|
||||
твою улыбку… О тебе — заповедь любви."* Sycophantic filler, zero information,
|
||||
and precisely the "not a relationship" non-goal.
|
||||
- Boiling an egg: `"15-16"` one run, `"1"` another. No unit, wrong number.
|
||||
|
||||
The first argues for reading instead of recalling (#403 — Kiwix retrieval scores
|
||||
8/8 on the same questions given English keywords). The second and third argue
|
||||
for templates on the paths where correctness matters (#392).
|
||||
|
||||
## Contamination note — how the last row got voided
|
||||
|
||||
I started the query-rewrite agent against the same llama-server the sweep was
|
||||
using, and assumed contention would only affect latency. It did not. The
|
||||
knowledge path collapsed to 0 of 9 with eight canned `"не знаю."` replies, p95
|
||||
tripled to 23.7s, and **the report still said "0 errors"**.
|
||||
|
||||
That is Vikunja #397, and it is worse than filed: a merely *busy* server
|
||||
produces a clean-looking report with a third of the fixture silently answering
|
||||
`"не знаю."`. `PhraseChat` and `PhraseQuery` swallow every failure and return a
|
||||
hardcoded string, so infrastructure trouble is indistinguishable from bad
|
||||
phrasing in the score. The talk test guards the *start* and *end* of a run with
|
||||
a model check, which catches a dead server but not a loaded one.
|
||||
|
||||
**Until #397 is fixed, treat any run made on a busy box as void.**
|
||||
|
||||
## Next
|
||||
|
||||
- Re-run 600ch/1024tok clean, to fill the void row.
|
||||
- Score `Qwen3.5-2B-UD-Q4_K_XL` (already at `/mnt/hdd1/llms/qwen3.5/`, never
|
||||
measured) on this fixture and the router fixture. Not the 4B — too big for
|
||||
this box, owner's call.
|
||||
- Newer sub-500M candidates (LFM2.5 200M/300M) are worth a run for routing.
|
||||
Note `MODEL-BAKEOFF-31-07-2026.md` found LFM2.5-**1.2B** worse than
|
||||
Qwen3.5-0.8B at Russian routing and 2.4× slower — but those are a different,
|
||||
older generation, so that result does not predict the small ones.
|
||||
- Fix `chat-how-are-you`'s `want_any`, and re-baseline once, so `ontopic`
|
||||
measures the model.
|
||||
- #397 first if anything, since it decides whether any of the above is
|
||||
trustworthy.
|
||||
+85
-142
@@ -1,17 +1,24 @@
|
||||
// mavcaldav — the CalDAV poller module.
|
||||
// mavcaldav — the CalDAV module: reads calendars into facts, and renders
|
||||
// maven's own reminders back out to a calendar she owns.
|
||||
//
|
||||
// Polls a Radicale (or any CalDAV) server for today's events and writes
|
||||
// `facts (kind=env, source=poll:caldav)` through core's IPC socket.
|
||||
// Key-free, restart-free, fail-independent — crashes can't touch the
|
||||
// store key, worst case a stale calendar_busy fact until the next poll.
|
||||
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
|
||||
// today's events and writes `facts (kind=env, source=poll:caldav)` through
|
||||
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
|
||||
// touch the store key, worst case a stale calendar_busy fact until the next
|
||||
// poll. Two facts:
|
||||
//
|
||||
// Two facts written:
|
||||
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
||||
// nudges during meetings
|
||||
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
||||
//
|
||||
// Append-only discipline: a fact is written only when its value CHANGED
|
||||
// vs the latest for that key+source.
|
||||
// Append-only discipline: a fact is written only when its value CHANGED vs the
|
||||
// latest for that key+source.
|
||||
//
|
||||
// RENDER side (Vikunja #127, off unless -render-url is given): publishes each
|
||||
// pending reminder as a single-event iCal resource in a collection maven owns.
|
||||
// The calendar is a view, sqlite is the store — see render.go. The render URL
|
||||
// must differ from the read URL, checked at startup, so the render target can
|
||||
// never be a calendar maven is only supposed to read.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -27,6 +34,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
@@ -43,6 +51,10 @@ func run(args []string) error {
|
||||
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
||||
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
|
||||
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
|
||||
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
|
||||
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
|
||||
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -54,6 +66,9 @@ func run(args []string) error {
|
||||
if *url == "" || *user == "" || *pass == "" {
|
||||
return fmt.Errorf("-url, -user, -pass are required")
|
||||
}
|
||||
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -64,16 +79,36 @@ func run(args []string) error {
|
||||
}
|
||||
defer core.Close()
|
||||
|
||||
hc := &http.Client{Timeout: *timeout}
|
||||
p := &poller{
|
||||
core: core,
|
||||
http: &http.Client{Timeout: *timeout},
|
||||
http: hc,
|
||||
url: strings.TrimRight(*url, "/"),
|
||||
user: *user,
|
||||
pass: *pass,
|
||||
}
|
||||
|
||||
var rend *renderer
|
||||
if *renderURL != "" {
|
||||
ru, rp := *renderUser, *renderPass
|
||||
if ru == "" {
|
||||
ru = *user
|
||||
}
|
||||
if rp == "" {
|
||||
rp = *pass
|
||||
}
|
||||
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
||||
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
||||
}
|
||||
|
||||
log.Printf("mavcaldav: polling %s every %s", *url, *interval)
|
||||
p.pollOnce(ctx) // fire immediately
|
||||
tick := func() {
|
||||
p.pollOnce(ctx)
|
||||
if rend != nil {
|
||||
rend.renderOnce(ctx)
|
||||
}
|
||||
}
|
||||
tick() // fire immediately
|
||||
t := time.NewTicker(*interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
@@ -82,11 +117,39 @@ func run(args []string) error {
|
||||
log.Printf("mavcaldav: bye")
|
||||
return nil
|
||||
case <-t.C:
|
||||
p.pollOnce(ctx)
|
||||
tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkRenderTarget refuses a render URL that is also one of the read URLs.
|
||||
// This is the structural half of #127's "cannot write to your work calendar":
|
||||
// the write credential and the write URL are separate flags, and a calendar
|
||||
// maven is known to only read is rejected as a target at startup rather than
|
||||
// trusted at runtime.
|
||||
//
|
||||
// It takes the whole read set, not one URL. The guarantee in the package
|
||||
// comment is about every calendar maven reads, and a second read target added
|
||||
// later must not quietly fall outside the check.
|
||||
func checkRenderTarget(readURLs []string, renderURL string) error {
|
||||
if renderURL == "" {
|
||||
return nil
|
||||
}
|
||||
for _, read := range readURLs {
|
||||
if read == "" {
|
||||
continue
|
||||
}
|
||||
if sameCollection(read, renderURL) {
|
||||
return fmt.Errorf("-render-url must differ from the read URL %s: maven renders into a calendar she owns, never into one she reads", read)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameCollection(a, b string) bool {
|
||||
return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
|
||||
}
|
||||
|
||||
type poller struct {
|
||||
core ipc.CoreAPI
|
||||
http *http.Client
|
||||
@@ -95,12 +158,6 @@ type poller struct {
|
||||
pass string
|
||||
}
|
||||
|
||||
type icalEvent struct {
|
||||
start time.Time
|
||||
end time.Time
|
||||
summary string
|
||||
}
|
||||
|
||||
func (p *poller) pollOnce(ctx context.Context) {
|
||||
now := time.Now()
|
||||
events, err := p.fetchEvents(ctx, now)
|
||||
@@ -109,38 +166,30 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
busy := false
|
||||
for _, e := range events {
|
||||
if !now.Before(e.start) && now.Before(e.end) {
|
||||
busy = true
|
||||
break
|
||||
}
|
||||
}
|
||||
busyVal := "false"
|
||||
if busy {
|
||||
if calendar.Busy(events, now) {
|
||||
busyVal = "true"
|
||||
}
|
||||
|
||||
// Write calendar_busy on change.
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", "poll:caldav", busyVal, now); err != nil {
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now); err != nil {
|
||||
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Write per-event facts (one per event, keyed by event summary + start).
|
||||
// Write per-event facts (one per event, keyed by day + event summary).
|
||||
// This lets the note RAG path answer "what's on my calendar" without
|
||||
// reaching back to Radicale.
|
||||
for _, e := range events {
|
||||
val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04"))
|
||||
eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary))
|
||||
if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil {
|
||||
log.Printf("mavcaldav: write %s: %v", eventKey, err)
|
||||
key := calendar.FactKey(e)
|
||||
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start); err != nil {
|
||||
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, error) {
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -162,119 +211,13 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, e
|
||||
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
||||
}
|
||||
|
||||
return parseICal(body, now), nil
|
||||
}
|
||||
|
||||
// parseICal scans iCal text for VEVENT components. Returns events that overlap
|
||||
// with today (UTC day boundaries) to keep the response manageable.
|
||||
func parseICal(body []byte, now time.Time) []icalEvent {
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
todayEnd := todayStart.AddDate(0, 0, 1)
|
||||
|
||||
var events []icalEvent
|
||||
text := string(body)
|
||||
for {
|
||||
veventStart := strings.Index(text, "BEGIN:VEVENT")
|
||||
if veventStart < 0 {
|
||||
break
|
||||
}
|
||||
text = text[veventStart+len("BEGIN:VEVENT"):]
|
||||
veventEnd := strings.Index(text, "END:VEVENT")
|
||||
if veventEnd < 0 {
|
||||
break
|
||||
}
|
||||
block := text[:veventEnd]
|
||||
text = text[veventEnd+len("END:VEVENT"):]
|
||||
|
||||
e := parseVEVENT(block)
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
// Only keep events overlapping today.
|
||||
if e.end.After(todayStart) && e.start.Before(todayEnd) {
|
||||
events = append(events, *e)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// parseVEVENT extracts start, end, summary from a VEVENT block.
|
||||
// Supports both UTC (DTEND:20260703T100000Z) and local (DTSTART;TZID=...:...)
|
||||
// formats. Returns nil for all-day events (no DTSTART/DTEND time component) or
|
||||
// parse failures.
|
||||
func parseVEVENT(block string) *icalEvent {
|
||||
var e icalEvent
|
||||
lines := strings.Split(block, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
if t, ok := parseDT(line); ok {
|
||||
e.start = t
|
||||
}
|
||||
case strings.HasPrefix(line, "DTEND"):
|
||||
if t, ok := parseDT(line); ok {
|
||||
e.end = t
|
||||
}
|
||||
case strings.HasPrefix(line, "SUMMARY"):
|
||||
if idx := strings.Index(line, ":"); idx >= 0 {
|
||||
e.summary = strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.start.IsZero() || e.end.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &e
|
||||
}
|
||||
|
||||
// parseDT parses a DTSTART/DTEND value. Supports:
|
||||
// - UTC: DTEND:20260703T100000Z
|
||||
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
|
||||
// - Value-date (all-day): DTSTART;VALUE=DATE:20260703 (returns zero time)
|
||||
func parseDT(line string) (time.Time, bool) {
|
||||
if strings.Contains(line, "VALUE=DATE:") {
|
||||
return time.Time{}, false // all-day, skip
|
||||
}
|
||||
idx := strings.LastIndex(line, ":")
|
||||
if idx < 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
val := line[idx+1:]
|
||||
val = strings.TrimSuffix(val, "Z")
|
||||
|
||||
// Try UTC first (has Z suffix, or ended in Z before TrimSuffix).
|
||||
if strings.HasSuffix(line, "Z") {
|
||||
t, err := time.Parse("20060102T150405", val)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t.UTC(), true
|
||||
}
|
||||
|
||||
// Local time — treat as UTC for simplicity (CalDAV server and poller
|
||||
// run in the same timezone; the gate only needs busy/not-busy accuracy).
|
||||
t, err := time.Parse("20060102T150405", val)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t.UTC(), true
|
||||
}
|
||||
|
||||
// safeKey makes an event summary safe to use as a fact key (alphanumeric + dash).
|
||||
func safeKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
b.WriteRune(r)
|
||||
} else if r == ' ' || r == '_' {
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
return calendar.ParseICalDay(body, now), nil
|
||||
}
|
||||
|
||||
// writeIfChanged writes a fact only when the value differs from the latest.
|
||||
// Everything this poller writes is a calendar read, which is full confidence by
|
||||
// definition; a source that is not, such as the notification relay, does not
|
||||
// come through here.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
|
||||
+6
-163
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
type fakeCore struct {
|
||||
ipc.CoreAPI
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts map[string]ipc.Fact // composite key "key|source" → Fact
|
||||
writeLog []ipc.WriteFactReq
|
||||
writeErr error
|
||||
@@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er
|
||||
return int64(len(f.writeLog)), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseICal(t *testing.T) {
|
||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
body := []byte(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260703T090000Z
|
||||
DTEND:20260703T100000Z
|
||||
SUMMARY:Morning standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260703T140000Z
|
||||
DTEND:20260703T150000Z
|
||||
SUMMARY:Team sync
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260702T140000Z
|
||||
DTEND:20260702T150000Z
|
||||
SUMMARY:Yesterday retro
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260704T090000Z
|
||||
DTEND:20260704T100000Z
|
||||
SUMMARY:Tomorrow standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20260704
|
||||
DTEND;VALUE=DATE:20260705
|
||||
SUMMARY:All-day event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`)
|
||||
|
||||
events := parseICal(body, now)
|
||||
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d events, want 2 (today events, no all-day/past/future)", len(events))
|
||||
}
|
||||
|
||||
// Morning standup — overlaps today.
|
||||
if events[0].summary != "Morning standup" {
|
||||
t.Errorf("events[0].summary = %q, want %q", events[0].summary, "Morning standup")
|
||||
}
|
||||
wantStart0 := time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)
|
||||
if !events[0].start.Equal(wantStart0) {
|
||||
t.Errorf("events[0].start = %v, want %v", events[0].start, wantStart0)
|
||||
}
|
||||
wantEnd0 := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)
|
||||
if !events[0].end.Equal(wantEnd0) {
|
||||
t.Errorf("events[0].end = %v, want %v", events[0].end, wantEnd0)
|
||||
}
|
||||
|
||||
// Team sync — overlaps today.
|
||||
if events[1].summary != "Team sync" {
|
||||
t.Errorf("events[1].summary = %q, want %q", events[1].summary, "Team sync")
|
||||
}
|
||||
wantStart1 := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
if !events[1].start.Equal(wantStart1) {
|
||||
t.Errorf("events[1].start = %v, want %v", events[1].start, wantStart1)
|
||||
}
|
||||
wantEnd1 := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC)
|
||||
if !events[1].end.Equal(wantEnd1) {
|
||||
t.Errorf("events[1].end = %v, want %v", events[1].end, wantEnd1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVEVENT(t *testing.T) {
|
||||
// Normal event with TZID in DTSTART and UTC DTEND.
|
||||
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
||||
e := parseVEVENT(block)
|
||||
if e == nil {
|
||||
t.Fatal("expected non-nil icalEvent")
|
||||
}
|
||||
wantStart := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
||||
if !e.start.Equal(wantStart) {
|
||||
t.Errorf("start = %v, want %v", e.start, wantStart)
|
||||
}
|
||||
wantEnd := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
if !e.end.Equal(wantEnd) {
|
||||
t.Errorf("end = %v, want %v", e.end, wantEnd)
|
||||
}
|
||||
if e.summary != "Stand up meeting" {
|
||||
t.Errorf("summary = %q, want %q", e.summary, "Stand up meeting")
|
||||
}
|
||||
|
||||
// All-day event (VALUE=DATE) → nil.
|
||||
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
||||
if e2 := parseVEVENT(allDay); e2 != nil {
|
||||
t.Error("expected nil for all-day event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDT(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
want time.Time
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "UTC",
|
||||
line: "DTEND:20260703T100000Z",
|
||||
want: time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC),
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "local time",
|
||||
line: "DTSTART;TZID=Europe/Moscow:20260703T130000",
|
||||
want: time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC),
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "all-day",
|
||||
line: "DTSTART;VALUE=DATE:20260703",
|
||||
want: time.Time{},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
line: "DTSTART:garbage",
|
||||
want: time.Time{},
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseDT(tt.line)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if !got.Equal(tt.want) {
|
||||
t.Errorf("got = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Stand up meeting", "Stand-up-meeting"},
|
||||
{"Hello_World", "Hello-World"},
|
||||
{"special@#$chars!!", "specialchars"},
|
||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := safeKey(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("safeKey(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core logic tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -349,13 +190,15 @@ func TestPollOnce(t *testing.T) {
|
||||
t.Errorf("calendar_busy ts is zero")
|
||||
}
|
||||
|
||||
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM"
|
||||
// Second write: calendar_event_<date>_<summary> = "<summary> @ HH:MM-HH:MM".
|
||||
// The iCal states the event in UTC and the fact is stamped on the owner's
|
||||
// clock, so the expected key date and times are the local reading of it.
|
||||
eventReq := fc.writeLog[1]
|
||||
expectedKey := "calendar_event_" + start.Format("20060102") + "_Current-meeting"
|
||||
expectedKey := "calendar_event_" + start.Local().Format("20060102") + "_Current-meeting"
|
||||
if eventReq.Key != expectedKey {
|
||||
t.Errorf("event key = %q, want %q", eventReq.Key, expectedKey)
|
||||
}
|
||||
expectedVal := "Current meeting @ " + start.Format("15:04") + "-" + end.Format("15:04")
|
||||
expectedVal := "Current meeting @ " + start.Local().Format("15:04") + "-" + end.Local().Format("15:04")
|
||||
if eventReq.Value != expectedVal {
|
||||
t.Errorf("event value = %q, want %q", eventReq.Value, expectedVal)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// renderer is the write half of maven's own local calendar (Vikunja #127).
|
||||
//
|
||||
// It is a RENDER TARGET, not a store. sqlite stays canonical: every tick the
|
||||
// renderer reads the pending reminders out of core and publishes each one as a
|
||||
// single-event iCal resource in a CalDAV collection maven owns. Nothing is ever
|
||||
// read back from that collection, and losing it costs nothing — the next tick
|
||||
// rebuilds it.
|
||||
//
|
||||
// It structurally cannot write to a calendar maven only reads. The URL comes
|
||||
// from its own flag, checked at startup against every read URL (see
|
||||
// run in main.go), and the only paths it ever addresses carry
|
||||
// calendar.ReminderUIDPrefix — so even pointed at the wrong collection it can
|
||||
// only touch resources it created.
|
||||
type renderer struct {
|
||||
core ipc.CoreAPI
|
||||
http *http.Client
|
||||
url string
|
||||
user string
|
||||
pass string
|
||||
dur time.Duration
|
||||
|
||||
// published maps reminder id → the body last successfully PUT, so an
|
||||
// unchanged reminder costs nothing. Purely an optimisation: a restart
|
||||
// re-publishes every reminder once, which is idempotent.
|
||||
published map[int64]string
|
||||
|
||||
// reconciled — whether the collection has been read once since start. It
|
||||
// has to be, because published is in-memory: withdrawal used to cover only
|
||||
// the reminders THIS process published, so a reminder that fired while the
|
||||
// daemon was down kept its event in the calendar forever, and nothing ever
|
||||
// revisited it.
|
||||
reconciled bool
|
||||
}
|
||||
|
||||
func newRenderer(core ipc.CoreAPI, hc *http.Client, url, user, pass string, dur time.Duration) *renderer {
|
||||
return &renderer{
|
||||
core: core,
|
||||
http: hc,
|
||||
url: strings.TrimRight(url, "/"),
|
||||
user: user,
|
||||
pass: pass,
|
||||
dur: dur,
|
||||
published: make(map[int64]string),
|
||||
}
|
||||
}
|
||||
|
||||
// renderOnce publishes every pending reminder and withdraws the ones that are
|
||||
// no longer pending. Errors are logged and skipped: a calendar maven cannot
|
||||
// reach must never break the reminder itself, which lives in sqlite.
|
||||
func (r *renderer) renderOnce(ctx context.Context) {
|
||||
reminders, err := r.core.ListReminders(ctx, renderMaxReminders)
|
||||
if err != nil {
|
||||
log.Printf("mavcaldav: list reminders: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
live := make(map[int64]bool, len(reminders))
|
||||
for _, rem := range reminders {
|
||||
if rem.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
live[rem.ID] = true
|
||||
e := calendar.ReminderEvent(rem.ID, fireTime(rem), rem.Payload, r.dur)
|
||||
body := calendar.RenderICal([]calendar.Event{e})
|
||||
if r.published[rem.ID] == body {
|
||||
continue
|
||||
}
|
||||
if err := r.put(ctx, calendar.ReminderPath(rem.ID), body); err != nil {
|
||||
log.Printf("mavcaldav: render reminder %d: %v", rem.ID, err)
|
||||
continue
|
||||
}
|
||||
r.published[rem.ID] = body
|
||||
log.Printf("mavcaldav: rendered reminder %d (%s)", rem.ID, e.Summary)
|
||||
}
|
||||
|
||||
stale := make(map[int64]bool)
|
||||
for id := range r.published {
|
||||
if !live[id] {
|
||||
stale[id] = true
|
||||
}
|
||||
}
|
||||
if !r.reconciled {
|
||||
remote, err := r.listPublished(ctx)
|
||||
if err != nil {
|
||||
// Try again next tick. A collection maven cannot read is not a
|
||||
// reason to stop publishing to it.
|
||||
log.Printf("mavcaldav: reconcile: %v", err)
|
||||
} else {
|
||||
r.reconciled = true
|
||||
for _, id := range remote {
|
||||
if !live[id] {
|
||||
stale[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range stale {
|
||||
if err := r.delete(ctx, calendar.ReminderPath(id)); err != nil {
|
||||
log.Printf("mavcaldav: withdraw reminder %d: %v", id, err)
|
||||
continue
|
||||
}
|
||||
delete(r.published, id)
|
||||
log.Printf("mavcaldav: withdrew reminder %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
// listPublished PROPFINDs the collection and returns the reminder ids maven has
|
||||
// events for in it. Only resources carrying calendar.ReminderUIDPrefix are
|
||||
// reported, so a reconciliation pass can never propose deleting a file maven
|
||||
// did not create — the same bound every other path in this file has.
|
||||
func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
|
||||
const body = `<?xml version="1.0" encoding="utf-8"?>` +
|
||||
`<D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/></D:prop></D:propfind>`
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", r.url+"/", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
|
||||
req.Header.Set("Depth", "1")
|
||||
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusMultiStatus && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
|
||||
return nil, fmt.Errorf("PROPFIND %s: %s", r.url, resp.Status)
|
||||
}
|
||||
|
||||
var ms struct {
|
||||
Responses []struct {
|
||||
Href string `xml:"href"`
|
||||
} `xml:"response"`
|
||||
}
|
||||
if err := xml.Unmarshal(raw, &ms); err != nil {
|
||||
return nil, fmt.Errorf("PROPFIND %s: %w", r.url, err)
|
||||
}
|
||||
var ids []int64
|
||||
for _, resp := range ms.Responses {
|
||||
href, err := url.PathUnescape(strings.TrimSpace(resp.Href))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if id, ok := calendar.ReminderIDFromPath(href); ok {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// renderMaxReminders bounds the read. Reminders past this count are older than
|
||||
// anything a calendar view is useful for.
|
||||
const renderMaxReminders = 200
|
||||
|
||||
// fireTime prefers NextFireTs — for a recurring reminder that is the occurrence
|
||||
// worth showing; FireTs is the original statement.
|
||||
func fireTime(rem ipc.Reminder) time.Time {
|
||||
if !rem.NextFireTs.IsZero() {
|
||||
return rem.NextFireTs
|
||||
}
|
||||
return rem.FireTs
|
||||
}
|
||||
|
||||
func (r *renderer) put(ctx context.Context, name, body string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, r.url+"/"+name, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
req.Header.Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
return r.do(req, name)
|
||||
}
|
||||
|
||||
func (r *renderer) delete(ctx context.Context, name string) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, r.url+"/"+name, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.SetBasicAuth(r.user, r.pass)
|
||||
return r.do(req, name)
|
||||
}
|
||||
|
||||
// do runs the request and treats any 2xx, plus 404 on a DELETE, as success —
|
||||
// a resource that is already gone is the state the caller wanted.
|
||||
func (r *renderer) do(req *http.Request, name string) error {
|
||||
resp, err := r.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
|
||||
switch {
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return nil
|
||||
case req.Method == http.MethodDelete && resp.StatusCode == http.StatusNotFound:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s %s: %s", req.Method, name, resp.Status)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// reminderCore is a fakeCore that also answers ListReminders.
|
||||
type reminderCore struct {
|
||||
fakeCore
|
||||
reminders []ipc.Reminder
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (c *reminderCore) ListReminders(context.Context, int) ([]ipc.Reminder, error) {
|
||||
if c.listErr != nil {
|
||||
return nil, c.listErr
|
||||
}
|
||||
return c.reminders, nil
|
||||
}
|
||||
|
||||
// calSrv records what a CalDAV collection received. existing seeds resources
|
||||
// that were already in the collection before this process started, which is
|
||||
// what a restart looks like from the renderer's side.
|
||||
type calSrv struct {
|
||||
mu sync.Mutex
|
||||
puts map[string]string
|
||||
dels []string
|
||||
existing []string
|
||||
propfind int
|
||||
status int
|
||||
*httptest.Server
|
||||
}
|
||||
|
||||
func newCalSrv() *calSrv {
|
||||
s := &calSrv{puts: map[string]string{}, status: http.StatusCreated}
|
||||
s.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
switch r.Method {
|
||||
case http.MethodPut:
|
||||
s.puts[strings.TrimPrefix(r.URL.Path, "/cal/")] = string(body)
|
||||
case http.MethodDelete:
|
||||
s.dels = append(s.dels, strings.TrimPrefix(r.URL.Path, "/cal/"))
|
||||
case "PROPFIND":
|
||||
s.propfind++
|
||||
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
io.WriteString(w, s.multistatusLocked(r.URL.Path))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(s.status)
|
||||
}))
|
||||
return s
|
||||
}
|
||||
|
||||
// multistatusLocked renders the collection listing. Caller holds the lock.
|
||||
func (s *calSrv) multistatusLocked(base string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0"?><D:multistatus xmlns:D="DAV:">`)
|
||||
b.WriteString("<D:response><D:href>" + base + "</D:href></D:response>")
|
||||
names := append([]string{}, s.existing...)
|
||||
for name := range s.puts {
|
||||
names = append(names, name)
|
||||
}
|
||||
for _, name := range names {
|
||||
if slices.Contains(s.dels, name) {
|
||||
continue
|
||||
}
|
||||
b.WriteString("<D:response><D:href>/cal/" + name + "</D:href></D:response>")
|
||||
}
|
||||
b.WriteString("</D:multistatus>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *calSrv) deleted() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string{}, s.dels...)
|
||||
}
|
||||
|
||||
func (s *calSrv) putCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.puts)
|
||||
}
|
||||
|
||||
func TestRenderOncePublishesPendingReminders(t *testing.T) {
|
||||
fire := time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC)
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 7, FireTs: fire, Payload: "позвонить маме", Status: "pending"},
|
||||
{ID: 8, FireTs: fire, Payload: "уже сделано", Status: "fired"},
|
||||
{ID: 9, FireTs: fire, Payload: "отменено", Status: "cancelled"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal/", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
srv.mu.Lock()
|
||||
body, ok := srv.puts["maven-reminder-7.ics"]
|
||||
n := len(srv.puts)
|
||||
srv.mu.Unlock()
|
||||
|
||||
if n != 1 {
|
||||
t.Fatalf("expected exactly the pending reminder to be published, got %d PUTs", n)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("pending reminder 7 was not published")
|
||||
}
|
||||
if !strings.Contains(body, "SUMMARY:позвонить маме") {
|
||||
t.Errorf("payload missing from rendered body:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "UID:maven-reminder-7") {
|
||||
t.Errorf("UID missing from rendered body:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOnceSkipsUnchanged(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.putCount(); got != 1 {
|
||||
t.Fatalf("an unchanged reminder was re-published: %d distinct PUTs", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOnceWithdrawsResolvedReminders(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 5, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "встреча", Status: "pending"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
core.reminders[0].Status = "fired"
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
srv.mu.Lock()
|
||||
dels := append([]string(nil), srv.dels...)
|
||||
srv.mu.Unlock()
|
||||
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
||||
t.Fatalf("resolved reminder was not withdrawn: %v", dels)
|
||||
}
|
||||
if len(r.published) != 0 {
|
||||
t.Errorf("published map still holds %v", r.published)
|
||||
}
|
||||
}
|
||||
|
||||
// A calendar maven cannot reach must never break anything: sqlite is canonical.
|
||||
func TestRenderOnceSurvivesServerErrors(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
srv.status = http.StatusInternalServerError
|
||||
defer srv.Close()
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 1, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "x", Status: "pending"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
if len(r.published) != 0 {
|
||||
t.Error("a failed PUT must not be recorded as published, or it never retries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderOnceUsesNextFireForRecurring(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
next := time.Date(2026, 8, 2, 7, 0, 0, 0, time.UTC)
|
||||
core := &reminderCore{reminders: []ipc.Reminder{{
|
||||
ID: 3,
|
||||
FireTs: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
|
||||
NextFireTs: next,
|
||||
Payload: "зарядка",
|
||||
Status: "pending",
|
||||
Cron: "0 7 * * *",
|
||||
}}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
srv.mu.Lock()
|
||||
body := srv.puts["maven-reminder-3.ics"]
|
||||
srv.mu.Unlock()
|
||||
if !strings.Contains(body, "DTSTART:20260802T070000Z") {
|
||||
t.Errorf("recurring reminder should render its next occurrence:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckRenderTargetRefusesTheCalendarItReads(t *testing.T) {
|
||||
read := "http://localhost:5232/kami/personal"
|
||||
if err := checkRenderTarget([]string{read}, ""); err != nil {
|
||||
t.Fatalf("rendering off must be fine: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget([]string{read}, "http://localhost:5232/kami/maven"); err != nil {
|
||||
t.Fatalf("a distinct collection must be accepted: %v", err)
|
||||
}
|
||||
if err := checkRenderTarget([]string{read}, read); err == nil {
|
||||
t.Error("rendering into the read calendar must be refused")
|
||||
}
|
||||
if err := checkRenderTarget([]string{read}, read+"/"); err == nil {
|
||||
t.Error("a trailing slash must not defeat the check")
|
||||
}
|
||||
if err := checkRenderTarget([]string{read}, strings.ToUpper(read)); err == nil {
|
||||
t.Error("case must not defeat the check")
|
||||
}
|
||||
// Every read target is checked, not the first one. A second calendar to
|
||||
// read must not fall outside the guarantee just by being added later.
|
||||
work := "http://localhost:5232/kami/work"
|
||||
if err := checkRenderTarget([]string{read, work}, work); err == nil {
|
||||
t.Error("rendering into the second read calendar must be refused")
|
||||
}
|
||||
if err := checkRenderTarget([]string{read, work}, "http://localhost:5232/kami/maven"); err != nil {
|
||||
t.Fatalf("a collection maven owns must still be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Withdrawal has to survive a restart. published is in-memory, so a fresh
|
||||
// process knows nothing about the events an earlier one wrote: fire a reminder,
|
||||
// restart mavcaldav, and its event used to sit in the collection forever
|
||||
// because nothing ever revisited it. The first tick reads the collection and
|
||||
// reconciles what it finds against what is pending.
|
||||
func TestRenderOnceWithdrawsAfterRestart(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
// Left behind by a previous process: 4 is still pending, 5 has fired.
|
||||
// The third file is not maven's and must not be touched.
|
||||
srv.existing = []string{"maven-reminder-4.ics", "maven-reminder-5.ics", "dentist.ics"}
|
||||
|
||||
core := &reminderCore{reminders: []ipc.Reminder{
|
||||
{ID: 4, FireTs: time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), Payload: "выпить воды", Status: "pending"},
|
||||
{ID: 5, FireTs: time.Date(2026, 8, 1, 8, 0, 0, 0, time.UTC), Payload: "уже прозвенело", Status: "fired"},
|
||||
}}
|
||||
r := newRenderer(core, srv.Client(), srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
|
||||
dels := srv.deleted()
|
||||
if len(dels) != 1 || dels[0] != "maven-reminder-5.ics" {
|
||||
t.Fatalf("deleted %v, want only the fired reminder's event", dels)
|
||||
}
|
||||
|
||||
// The collection is read once, not on every tick.
|
||||
r.renderOnce(context.Background())
|
||||
srv.mu.Lock()
|
||||
n := srv.propfind
|
||||
srv.mu.Unlock()
|
||||
if n != 1 {
|
||||
t.Errorf("PROPFIND ran %d times, want once per process", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A collection maven cannot read is not a reason to stop publishing to it, and
|
||||
// the reconciliation must be retried rather than skipped for the process.
|
||||
func TestRenderOnceRetriesReconcile(t *testing.T) {
|
||||
srv := newCalSrv()
|
||||
defer srv.Close()
|
||||
srv.existing = []string{"maven-reminder-6.ics"}
|
||||
failing := &http.Client{Transport: &propfindFailure{base: srv.Client().Transport}}
|
||||
|
||||
core := &reminderCore{}
|
||||
r := newRenderer(core, failing, srv.URL+"/cal", "u", "p", 0)
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.deleted(); len(got) != 0 {
|
||||
t.Fatalf("nothing can be withdrawn on a failed read: %v", got)
|
||||
}
|
||||
if r.reconciled {
|
||||
t.Fatal("a failed read must not count as reconciled")
|
||||
}
|
||||
|
||||
r.http = srv.Client()
|
||||
r.renderOnce(context.Background())
|
||||
if got := srv.deleted(); len(got) != 1 || got[0] != "maven-reminder-6.ics" {
|
||||
t.Fatalf("deleted %v, want the orphaned event on the retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
// propfindFailure fails PROPFIND and passes everything else through.
|
||||
type propfindFailure struct{ base http.RoundTripper }
|
||||
|
||||
func (f *propfindFailure) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if req.Method == "PROPFIND" {
|
||||
return nil, errors.New("collection unreachable")
|
||||
}
|
||||
return f.base.RoundTrip(req)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// actionTable dispatches applyAction's per-intent bodies. Each of the 7
|
||||
// intents (fact, reminder, note, query, act, chat, system) has one handler
|
||||
// here with the signature:
|
||||
//
|
||||
// func(h *reactiveHandler, ctx context.Context, dec router.Decision) string
|
||||
//
|
||||
// same contract as applyAction itself: "" means "let the Replier phrase the
|
||||
// reply", a non-empty string OVERRIDES it. This is a straight extraction of
|
||||
// applyAction's old switch cases (formerly ~300 lines in voice.go) — no
|
||||
// reordering of side effects, no new abstractions inside a handler.
|
||||
//
|
||||
// What does NOT belong in this table, because it is not per-intent:
|
||||
//
|
||||
// - the dec.Clarify short-circuit ("" when the router's stage-3 fired) —
|
||||
// stays in applyAction, before dispatch, since it applies to every
|
||||
// intent identically.
|
||||
// - the destructive-act confirm gate (park / resolveConfirm / confirmTTL)
|
||||
// and the enabled-tool allowlist. Both live entirely inside
|
||||
// actionAct/handleAct in actions_act.go, exactly where they lived in the old
|
||||
// switch's IntentAct case — they are act-specific (a fact or a note
|
||||
// can't be destructive), not shared across intents, so they do not need
|
||||
// to move to a separate layer. The important invariant, preserved
|
||||
// as-is: applyAction runs identically whether dec came from a fresh
|
||||
// route or from a completed clarify answer (see finishClarified in
|
||||
// clarify.go and its comment "filling in an argument never grants
|
||||
// authority") — a handler must never special-case a clarify-completed
|
||||
// decision to skip the confirm gate or the allowlist.
|
||||
// - detectPattern and dialogue-session bookkeeping (rememberTurn,
|
||||
// followUpMerge) run in the callers (runTurn,
|
||||
// finishClarified), not per-intent, and are untouched by this slice.
|
||||
//
|
||||
// Each handler lives in actions_<intent>.go; the small ones (chat, system)
|
||||
// and the table itself stay here.
|
||||
//
|
||||
// Adding an intent: write its handler in its own file, add one line to
|
||||
// actionHandlers. Do not grow applyAction's switch back.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// actionHandlers is the per-intent dispatch table used by applyAction.
|
||||
var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, router.Decision) string{
|
||||
router.IntentFact: (*reactiveHandler).actionFact,
|
||||
router.IntentReminder: (*reactiveHandler).actionReminder,
|
||||
router.IntentAct: (*reactiveHandler).actionAct,
|
||||
router.IntentChat: (*reactiveHandler).actionChat,
|
||||
router.IntentSystem: (*reactiveHandler).actionSystem,
|
||||
router.IntentNote: (*reactiveHandler).actionNote,
|
||||
router.IntentQuery: (*reactiveHandler).actionQuery,
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||
// Conversational: build history from dialogue session (prior user turns)
|
||||
// and let the LLM respond from general knowledge + context.
|
||||
history := h.chatHistory()
|
||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||
if err != nil {
|
||||
log.Printf("voice: chat: %v", err)
|
||||
return "поговорили."
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) actionSystem(ctx context.Context, dec router.Decision) string {
|
||||
return h.replySystem(ctx, dec)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// actionAct handles router.IntentAct: match a verb to an enabled tool, offer
|
||||
// it to the ecosystems first, and run it behind the confirm gate and the
|
||||
// allowlist. proposeGap and the confirm gate itself live in confirm.go.
|
||||
func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string {
|
||||
// tool executor: run the matched fn against the enabled allowlist.
|
||||
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
|
||||
// didn't go through the stage-0 act grammar).
|
||||
if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil {
|
||||
if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok {
|
||||
dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true
|
||||
}
|
||||
}
|
||||
|
||||
// Praxis ecosystem tools: intercept before the system command executor.
|
||||
if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn {
|
||||
if reply := h.handlePraxisAct(ctx, dec); reply != "" {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
|
||||
// Hexis ecosystem action: if ecosystem is configured and we have a verb
|
||||
// + entity text, try to resolve the entity and execute via Hexis.
|
||||
if h.ecosystem != nil && h.ecosystem.hexis != nil && dec.Slots.Text != "" {
|
||||
if reply := h.handleHexisAct(ctx, dec); reply != "" {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
|
||||
// HasFn still false ⇒ no allowlist match: scaffold a 'proposed' tool
|
||||
// the user can enable on the authed surface ("earn the right to ask").
|
||||
if !dec.Slots.HasFn {
|
||||
return h.proposeGap(ctx, dec)
|
||||
}
|
||||
out, err := h.tools.Exec(ctx, dec.Slots.Fn, dec.Slots.Args, false)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, tool.ErrNeedsConfirm):
|
||||
// destructive: park it and ask. The next utterance answers.
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
|
||||
case errors.Is(err, tool.ErrNotEnabled):
|
||||
return h.proposeGap(ctx, dec)
|
||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
||||
// The row is enabled and the backend is gone. Drafting a proposal
|
||||
// for it (the ErrNotEnabled path) would be answering the wrong
|
||||
// question.
|
||||
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
||||
case errors.Is(err, mcp.ErrToolGone):
|
||||
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||
// An MCP tool that wants named arguments a spoken verb cannot
|
||||
// supply. Guessing them would be a wrong act, so she says so
|
||||
// instead — the tool is still runnable from the authed surface,
|
||||
// where a human types them.
|
||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
if out != "" {
|
||||
return "не получилось выполнить команду: " + firstLine(out)
|
||||
}
|
||||
return "не получилось выполнить команду."
|
||||
}
|
||||
if out != "" {
|
||||
return "готово: " + firstLine(out)
|
||||
}
|
||||
return "готово."
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
||||
// it for recall, and let pattern detection propose a routine.
|
||||
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
||||
if !dec.Slots.HasKey {
|
||||
return "не разобрала, что записать — попробуй иначе."
|
||||
}
|
||||
now := h.now()
|
||||
req := ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
Kind: "self",
|
||||
Key: dec.Slots.Key,
|
||||
Value: dec.Slots.Value,
|
||||
Source: "tap:voice",
|
||||
Confidence: 1.0,
|
||||
// Subject: the key doubles as the entity-resolution candidate —
|
||||
// a voice-tapped fact's key is usually the thing/person it's
|
||||
// about ("espresso_machine", "kate"), so queueing it for Nexus
|
||||
// resolution costs one async lookup and is a no-op (not_found)
|
||||
// for the abstract self-state keys (mood, water) that aren't
|
||||
// entities at all.
|
||||
Subject: dec.Slots.Key,
|
||||
}
|
||||
factID, err := h.api.WriteFact(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("voice: write fact: %v", err)
|
||||
return "не получилось сохранить факт."
|
||||
}
|
||||
// Index the fact utterance in long-term memory (best-effort, must not
|
||||
// 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 := 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",
|
||||
"type": "fact",
|
||||
"text": dec.Utterance,
|
||||
"ts": strconv.FormatInt(now.Unix(), 10),
|
||||
}); err != nil {
|
||||
log.Printf("voice: memory insert fact: %v", err)
|
||||
}
|
||||
}
|
||||
// Event extraction + pattern detection (best-effort, must not fail the
|
||||
// fact write). If the fact describes a recognizable action, it becomes a
|
||||
// normalized event; if ≥3 events for the same action+object show stable
|
||||
// intervals, a proposed routine is created and parked for confirmation.
|
||||
if h.dataStore != nil {
|
||||
if phrase := h.detectPattern(ctx, factID, dec.Slots.Key, dec.Slots.Value, now); phrase != "" {
|
||||
return phrase // "ты заправляешь ... напоминать?"
|
||||
}
|
||||
}
|
||||
return "" // replier phrases the success reply
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
// Money questions (Vikunja #125).
|
||||
//
|
||||
// This is the whole read side: mavpoll holds the zenmoney token and writes
|
||||
// facts(kind=env, source=poll:zenmoney); core reads them back when he asks.
|
||||
// Core never sees the token, never calls zenmoney, and has no rule on these
|
||||
// keys — a total is never a reason for Maven to speak first. Maven is not a
|
||||
// nag, least of all about his money.
|
||||
//
|
||||
// Nothing here can reach the external search capability: the figures are read
|
||||
// from the store and rendered locally, and his financial data is never search
|
||||
// input.
|
||||
|
||||
// queryMoney — "сколько я потратил сегодня?", "покажи мои траты".
|
||||
//
|
||||
// Answers only from the latest fact the poller wrote. Three honest outcomes and
|
||||
// no fourth: the figure, "the fact is old and here is its date", or "money
|
||||
// tracking is not connected". It never computes, estimates or rounds a total of
|
||||
// its own — an invented number about his money is the worst thing this could do.
|
||||
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if q.Window == router.MoneyUnsupported {
|
||||
// Two windows are stored and no others. Answering "сколько я потратил
|
||||
// вчера?" with the month-to-date total answers a different question
|
||||
// with a real number, which is the shape of a lie he cannot spot.
|
||||
return "я храню только сегодняшние траты и за этот месяц.", true
|
||||
}
|
||||
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
|
||||
if q.Window == router.MoneyToday {
|
||||
key, phrase = zenmoney.KeySpentToday, "сегодня"
|
||||
}
|
||||
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
|
||||
if err != nil {
|
||||
// No fact at all is the normal state when the capability is off. Claim
|
||||
// the turn anyway: falling through to recall would answer a question
|
||||
// about money with whatever note happens to be nearest.
|
||||
if !isNoFactErr(err) {
|
||||
log.Printf("voice: money fact: %v", err)
|
||||
}
|
||||
return "я не отслеживаю траты — не подключено.", true
|
||||
}
|
||||
val, err := zenmoney.ParseFactValue(fact.Value)
|
||||
if err != nil {
|
||||
log.Printf("voice: money fact: decode: %v", err)
|
||||
return "не получилось прочитать траты.", true
|
||||
}
|
||||
now := h.now()
|
||||
if q.Window == router.MoneyToday && !val.CoversDay(now) {
|
||||
// The day window rolled over and the poller had nothing to write,
|
||||
// because he has not spent anything yet today. The fact is fresh by ts
|
||||
// and covers yesterday, so no staleness check can catch it — only the
|
||||
// window stamp inside the value can.
|
||||
return "сегодня пока ничего не вижу.", true
|
||||
}
|
||||
reply := val.FormatRU(phrase)
|
||||
if q.Income {
|
||||
reply = val.FormatIncomeRU(phrase)
|
||||
}
|
||||
if reply == "" {
|
||||
return "по тратам пока нечего сказать.", true
|
||||
}
|
||||
// A stale fact is reported as stale rather than spoken as today's number.
|
||||
// The age is measured from when the figure was last READ, not from when it
|
||||
// last changed: a month with no spending in it does not go stale.
|
||||
asOf := val.AsOf
|
||||
if asOf.IsZero() {
|
||||
asOf = fact.Ts
|
||||
}
|
||||
if now.Sub(asOf) > zenmoney.StaleAfter {
|
||||
return "данные от " + asOf.Local().Format("02.01") + ": " + reply, true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it. The
|
||||
// hand-rolled loop this replaces missed any error implementing Is(error) bool.
|
||||
func isNoFactErr(err error) bool {
|
||||
return errors.Is(err, ipc.ErrNoFact)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
// moneyAPI answers only LatestFactBySource; everything else is unimplemented,
|
||||
// which is the assertion that answering a money question costs no model call
|
||||
// and reaches no network.
|
||||
type moneyAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
fact ipc.Fact
|
||||
err error
|
||||
gotKey string
|
||||
gotSrc string
|
||||
callCnt int
|
||||
}
|
||||
|
||||
func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||
a.gotKey, a.gotSrc = key, source
|
||||
a.callCnt++
|
||||
return a.fact, a.err
|
||||
}
|
||||
|
||||
func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) }
|
||||
|
||||
func moneyFact(ts time.Time, val string) ipc.Fact {
|
||||
return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts}
|
||||
}
|
||||
|
||||
func TestQueryMoneyAnswersFromTheFact(t *testing.T) {
|
||||
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the money source must claim a money question")
|
||||
}
|
||||
if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source {
|
||||
t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc)
|
||||
}
|
||||
if !strings.Contains(reply, "1749.5") {
|
||||
t.Errorf("reply = %q, want the exact figure", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "в этом месяце") {
|
||||
t.Errorf("reply = %q, want the window named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMoneyPicksTodaysKey(t *testing.T) {
|
||||
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
if _, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
|
||||
}); !ok {
|
||||
t.Fatal("expected the source to claim it")
|
||||
}
|
||||
if api.gotKey != zenmoney.KeySpentToday {
|
||||
t.Errorf("key = %q, want today's", api.gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// The capability is off unless configured, and then there is no fact. She says
|
||||
// so instead of letting the recall pass answer a money question from a note.
|
||||
func TestQueryMoneySaysNotConnected(t *testing.T) {
|
||||
h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow}
|
||||
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the source to claim it")
|
||||
}
|
||||
if !strings.Contains(reply, "не подключено") {
|
||||
t.Errorf("reply = %q, want an honest 'not connected'", reply)
|
||||
}
|
||||
// No number of any kind in that answer.
|
||||
for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} {
|
||||
if strings.Contains(reply, d) {
|
||||
t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A fact older than the staleness bound is dated rather than spoken as if it
|
||||
// were current: the poller can be down, and last week's total presented as
|
||||
// today's is a lie by omission.
|
||||
func TestQueryMoneyDatesAStaleFact(t *testing.T) {
|
||||
old := moneyNow().Add(-72 * time.Hour)
|
||||
api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил?"},
|
||||
})
|
||||
if !strings.Contains(reply, "данные от") {
|
||||
t.Errorf("reply = %q, want the stale fact dated", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryMoneyPassesOtherQuestions(t *testing.T) {
|
||||
api := &moneyAPI{}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} {
|
||||
if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||
t.Errorf("the money source claimed %q", u)
|
||||
}
|
||||
}
|
||||
if api.callCnt != 0 {
|
||||
t.Error("a non-money question must not read the money facts")
|
||||
}
|
||||
}
|
||||
|
||||
// Money must be answered before the recall sources, or a question about
|
||||
// spending gets answered by the nearest note.
|
||||
func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
|
||||
moneyAt, notesAt := -1, -1
|
||||
for i, src := range querySources {
|
||||
switch src.name {
|
||||
case "money":
|
||||
moneyAt = i
|
||||
case "notes":
|
||||
notesAt = i
|
||||
}
|
||||
}
|
||||
if moneyAt < 0 || notesAt < 0 {
|
||||
t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt)
|
||||
}
|
||||
if moneyAt > notesAt {
|
||||
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
|
||||
}
|
||||
}
|
||||
|
||||
// The day window rolls over at midnight and the poller writes nothing until the
|
||||
// first spend of the new day, so the last money_today fact is fresh by ts and
|
||||
// covers yesterday. No staleness check can catch that.
|
||||
func TestQueryMoneyRefusesYesterdaysDayTotal(t *testing.T) {
|
||||
yesterday, _ := zenmoney.DayWindow(moneyNow().AddDate(0, 0, -1))
|
||||
sum := zenmoney.Summary{From: yesterday, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 1749.5}}, Count: 3}
|
||||
val, ok := sum.Value(moneyNow().AddDate(0, 0, -1).Add(2 * time.Hour))
|
||||
if !ok {
|
||||
t.Fatal("want a fact value")
|
||||
}
|
||||
api := &moneyAPI{fact: ipc.Fact{
|
||||
Kind: "env", Key: zenmoney.KeySpentToday, Value: val,
|
||||
Source: zenmoney.Source, Ts: moneyNow().Add(-11 * time.Hour),
|
||||
}}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, claimed := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
|
||||
})
|
||||
if !claimed {
|
||||
t.Fatal("expected the source to claim it")
|
||||
}
|
||||
if strings.Contains(reply, "1749.5") {
|
||||
t.Errorf("reply = %q — that is yesterday's spending spoken as today's", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Ts advances only when the number moves, so a quiet month used to be reported
|
||||
// as stale while being current. The read stamp inside the value is what the
|
||||
// staleness check means.
|
||||
func TestQueryMoneyMeasuresStalenessFromTheRead(t *testing.T) {
|
||||
from, _ := zenmoney.MonthWindow(moneyNow())
|
||||
sum := zenmoney.Summary{From: from, Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}}, Count: 1}
|
||||
val, _ := sum.Value(moneyNow().Add(-time.Hour))
|
||||
// The fact itself last CHANGED three days ago: nothing was spent since.
|
||||
api := &moneyAPI{fact: ipc.Fact{
|
||||
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
|
||||
Source: zenmoney.Source, Ts: moneyNow().Add(-72 * time.Hour),
|
||||
}}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
|
||||
})
|
||||
if strings.Contains(reply, "данные от") {
|
||||
t.Errorf("reply = %q — the figure was read an hour ago and is current", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Two windows are stored and no others. Answering "вчера" with the
|
||||
// month-to-date total answers a different question with a real number.
|
||||
func TestQueryMoneyRefusesWindowsItDoesNotKeep(t *testing.T) {
|
||||
api := &moneyAPI{}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, ok := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я потратил вчера?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("a money question must be claimed, not passed to recall")
|
||||
}
|
||||
if !strings.Contains(reply, "только") {
|
||||
t.Errorf("reply = %q, want her to say which windows she keeps", reply)
|
||||
}
|
||||
if api.callCnt != 0 {
|
||||
t.Error("a window she does not keep must not read a fact")
|
||||
}
|
||||
}
|
||||
|
||||
// "сколько я заработал" reads the same fact and must lead with the income.
|
||||
func TestQueryMoneyLeadsWithIncomeWhenAsked(t *testing.T) {
|
||||
from, _ := zenmoney.MonthWindow(moneyNow())
|
||||
sum := zenmoney.Summary{
|
||||
From: from,
|
||||
Spent: []zenmoney.Money{{Currency: "RUB", Amount: 100}},
|
||||
Earned: []zenmoney.Money{{Currency: "RUB", Amount: 3000}},
|
||||
Count: 2,
|
||||
}
|
||||
val, _ := sum.Value(moneyNow())
|
||||
api := &moneyAPI{fact: ipc.Fact{
|
||||
Kind: "env", Key: zenmoney.KeySpentMonth, Value: val,
|
||||
Source: zenmoney.Source, Ts: moneyNow(),
|
||||
}}
|
||||
h := &reactiveHandler{api: api, now: moneyNow}
|
||||
reply, _ := h.queryMoney(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "сколько я заработал в этом месяце?"},
|
||||
})
|
||||
if strings.Index(reply, "3000") > strings.Index(reply, "100") {
|
||||
t.Errorf("reply = %q, want the income he asked about first", reply)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||
// index it for recall.
|
||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||
// An utterance that explicitly files a task is work, not recall, and
|
||||
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||
// is paid for. Everything else is a note, exactly as before.
|
||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||
return reply
|
||||
}
|
||||
// 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 := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return "не получилось сохранить заметку."
|
||||
}
|
||||
noteTs := h.now()
|
||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||
if err != nil {
|
||||
log.Printf("voice: write note: %v", err)
|
||||
return "не получилось сохранить заметку."
|
||||
}
|
||||
// Insert into long-term memory (best-effort, must not fail the note write).
|
||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||
if h.memStore != nil {
|
||||
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "note",
|
||||
"text": dec.Utterance,
|
||||
"ts": strconv.FormatInt(noteTs.Unix(), 10),
|
||||
}); err != nil {
|
||||
log.Printf("voice: memory insert: %v", err)
|
||||
}
|
||||
}
|
||||
return "" // replier phrases the "saved" reply
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/weather"
|
||||
)
|
||||
|
||||
// queryTurn is the per-turn scratch a chain of query sources shares: the
|
||||
// decision being answered plus the work an earlier source already paid for
|
||||
// (the query embedding, the notes it pulled). Sources read and fill it in
|
||||
// order, so a later source never re-embeds.
|
||||
type queryTurn struct {
|
||||
dec router.Decision
|
||||
vec []float32
|
||||
notes []ipc.Note
|
||||
}
|
||||
|
||||
// querySource — one answer source in the chain actionQuery walks. answer
|
||||
// returns (reply, true) when this source claims the question, ("", false)
|
||||
// when it passes to the next one. name is for reading the table, not logged.
|
||||
//
|
||||
// A struct of one func rather than an interface: every source is a plain
|
||||
// method on *reactiveHandler with no state of its own (what state a turn has
|
||||
// lives in queryTurn), so an interface would mean one empty type per source
|
||||
// to satisfy it — ceremony for nothing. Same reasoning as confirmResolver in
|
||||
// confirm.go, and the table then reads like actionHandlers: a flat list of
|
||||
// method expressions you extend with one line.
|
||||
type querySource struct {
|
||||
name string
|
||||
answer func(*reactiveHandler, context.Context, *queryTurn) (string, bool)
|
||||
}
|
||||
|
||||
// querySources is the ordered chain actionQuery walks; first source to claim
|
||||
// answers the turn. THE ORDER IS LOAD-BEARING — see the memory-before-notes
|
||||
// comment on queryMemory: running the notes-only pass first was #373, and the
|
||||
// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one
|
||||
// line here plus its method; where you put the line is the whole decision.
|
||||
var querySources = []querySource{
|
||||
{"fact-by-key", (*reactiveHandler).queryFactByKey},
|
||||
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
||||
// the more specific ask (its matcher requires a plan word), so the calendar
|
||||
// listing would otherwise swallow it.
|
||||
{"day-plan", (*reactiveHandler).queryDayPlan},
|
||||
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||||
// and the habit question is the more specific one. Its matcher requires a
|
||||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||
// Wednesday still reaches the calendar.
|
||||
{"habits", (*reactiveHandler).queryHabits},
|
||||
// Before "calendar" and before the recall sources: "что мне нужно
|
||||
// сделать?" is a question about the task list, and the notes pass would
|
||||
// otherwise answer it with whatever note happens to be nearest. Its
|
||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||
// date-bearing question still reaches the calendar.
|
||||
{"tasks", (*reactiveHandler).queryTasks},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{"money", (*reactiveHandler).queryMoney},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// a question about the feeds she reads, and general knowledge would answer
|
||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||
// "у меня новая лента в инстаграме" is untouched.
|
||||
{"feeds", (*reactiveHandler).queryFeeds},
|
||||
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||||
// a question about the house, and the notes pass would otherwise answer it
|
||||
// from whatever he once said about the lights. Its matcher needs a house
|
||||
// marker plus an ask plus a device word, and it bails out on weather
|
||||
// wording, so "какая температура на улице?" still reaches the weather
|
||||
// source.
|
||||
{"home", (*reactiveHandler).queryHome},
|
||||
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||
// question about the LAN, and the recall pass would otherwise answer it
|
||||
// from an old note about the router. Its matcher needs a network word plus
|
||||
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||
{"network", (*reactiveHandler).queryNetwork},
|
||||
{"calendar", (*reactiveHandler).queryCalendar},
|
||||
{"weather", (*reactiveHandler).queryWeather},
|
||||
{"embed", (*reactiveHandler).queryEmbed},
|
||||
{"memory", (*reactiveHandler).queryMemory},
|
||||
{"notes", (*reactiveHandler).queryNotes},
|
||||
// LAST before the model answers from memory, and that position is the whole
|
||||
// design (Vikunja #259): local sources first. His memory, his notes and —
|
||||
// once internal/kiwix is wired into this chain — the offline ZIMs all get
|
||||
// their turn before anything touches the network. The model does NOT: it
|
||||
// answers after this, because a URL he said out loud is an instruction and
|
||||
// a 1.7B guessing at a page it cannot read is how contents get invented.
|
||||
// This source only claims a turn where he named a URL, so it never competes
|
||||
// with a local answer.
|
||||
{"web", (*reactiveHandler).queryWeb},
|
||||
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||||
t := &queryTurn{dec: dec}
|
||||
for _, src := range querySources {
|
||||
if reply, ok := src.answer(h, ctx, t); ok {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
return "не знаю."
|
||||
}
|
||||
|
||||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||||
// a prior fact's key (e.g. "когда я это сделал?" after "запиши что я пил
|
||||
// воду"), look up the fact's value directly.
|
||||
func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
dec := t.dec
|
||||
if !dec.Slots.HasKey || dec.Slots.Key == "" {
|
||||
return "", false
|
||||
}
|
||||
f, err := h.api.LatestFact(ctx, dec.Slots.Key)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if dec.Slots.HasTime {
|
||||
// The query asks about timing — the fact's own timestamp is the
|
||||
// answer it's looking for. Format as a natural reply.
|
||||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
||||
}
|
||||
// General fact reference: describe what we know.
|
||||
if dec.Utterance == "" {
|
||||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
||||
}
|
||||
// The utterance still carries the question; fall through to normal RAG
|
||||
// with the resolved key in context.
|
||||
return "", false
|
||||
}
|
||||
|
||||
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||||
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||||
// reminders, and every morning checklist item today still has no evidence for,
|
||||
// including the ones whose window has closed.
|
||||
//
|
||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||
// day, so that phrasing trims what has already passed.
|
||||
//
|
||||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||
// The spoken recital here is the current answer, not the decided one.
|
||||
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
plan, err := h.api.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return "не получилось собрать план.", true
|
||||
}
|
||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
// rendered the whole day — one formatter, one persona.
|
||||
p := morning.Plan{Date: plan.Date}
|
||||
for _, it := range plan.Items {
|
||||
p.Items = append(p.Items, morning.PlanEntry{
|
||||
At: it.At,
|
||||
Text: it.Text,
|
||||
Kind: morning.PlanKind(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
})
|
||||
}
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
// over. Enough for a season of habits without scanning the whole store on every
|
||||
// question; the profile is recomputed on read, so the bound is the cost control.
|
||||
//
|
||||
// The read is kind-filtered in SQL, and that is the load-bearing part. When this
|
||||
// was a plain recent-facts read the window was a row budget over every writer,
|
||||
// and the machine writers dwarf the taps: mavpoll writes a wg_handshake row
|
||||
// whenever a peer rehandshakes, which is roughly every two minutes per peer, so
|
||||
// 2000 rows was under three days of history. A weekday habit needs
|
||||
// memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so
|
||||
// she answered "по вторникам у меня пока нет ничего постоянного" forever on a
|
||||
// store with a year of taps in it. Self facts come from voice taps, and he does
|
||||
// not tap seven hundred times a day.
|
||||
const habitFactWindow = 2000
|
||||
|
||||
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||||
// answer out of the fact log rather than asking the model to summarise a life:
|
||||
// see internal/memory/behavior.go for why nothing here is generated.
|
||||
func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseHabitQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return "не получилось посмотреть записи.", true
|
||||
}
|
||||
obs := make([]memory.Observation, 0, len(facts))
|
||||
for _, f := range facts {
|
||||
obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind})
|
||||
}
|
||||
profile := memory.BuildProfile(obs, h.now())
|
||||
if q.HasWeekday {
|
||||
return profile.FormatWeekdayRU(q.Weekday), true
|
||||
}
|
||||
if q.Weekend {
|
||||
return profile.FormatWeekendRU(), true
|
||||
}
|
||||
return profile.FormatOverallRU(), true
|
||||
}
|
||||
|
||||
// feedNoteWindow — how many recent FEED notes are scanned, and
|
||||
// feedReadOut — how many headlines she actually reads back. She summarises the
|
||||
// top of the pile, she does not recite a river.
|
||||
const (
|
||||
feedNoteWindow = 200
|
||||
feedReadOut = 3
|
||||
)
|
||||
|
||||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
// This is the ONLY way a feed item reaches him. The poller writes notes and
|
||||
// never speaks; asking is the trigger. If that ever changes, the thing that
|
||||
// changed is "Maven is not a nag", not a detail of this file.
|
||||
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if !h.feedsOn {
|
||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||
// letting general knowledge answer "что нового?" would be an invented
|
||||
// news bulletin.
|
||||
return "я пока не читаю ленты — они не настроены.", true
|
||||
}
|
||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||
// used to push the newest headline out of the window, and she answered "в
|
||||
// лентах пока ничего нового" while the poller was working fine.
|
||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return "не получилось посмотреть ленты.", true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
|
||||
continue
|
||||
}
|
||||
// The note carries title, summary, category tag and link; she reads the
|
||||
// title alone. The tag is for the match above, and piper reads brackets
|
||||
// out loud.
|
||||
picked = append(picked, rss.NoteHeadline(n.Text))
|
||||
if len(picked) == feedReadOut {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if q.Category != "" {
|
||||
return "по этой теме в лентах пока ничего.", true
|
||||
}
|
||||
return "в лентах пока ничего нового.", true
|
||||
}
|
||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||
}
|
||||
|
||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||||
// source can be tested at a fixed time like the rest.
|
||||
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
log.Printf("voice: calendar events: %v", err)
|
||||
return "не получилось проверить календарь.", true
|
||||
}
|
||||
// Provenance travels with each event. A work meeting relayed off a phone
|
||||
// notification (source ambient:notif, #126) is stored below full confidence
|
||||
// and gets hedged; a CalDAV read is recited plainly.
|
||||
entries := make([]router.CalendarEntry, len(events))
|
||||
for i, e := range events {
|
||||
entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0}
|
||||
}
|
||||
var f router.CalendarEventFormatter
|
||||
return f.FormatEntries(entries, date), true
|
||||
}
|
||||
|
||||
// queryHome answers a question about the house. Read-only by construction: it
|
||||
// calls States and nothing else, so there is no confirm turn here — the only
|
||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isHomeQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.home == nil {
|
||||
// Fall through rather than claim the turn. A capability that is off
|
||||
// must not change what an unconfigured box answers: "какая температура
|
||||
// в доме?" on a Maven with no smarthome block reached recall before
|
||||
// this source existed, and a stored fact is a better answer than
|
||||
// "дом не подключён" from a house that was never configured. The
|
||||
// unreachable case is different and homeSummary covers it.
|
||||
return "", false
|
||||
}
|
||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
return h.home.homeSummary(ctxH)
|
||||
}
|
||||
|
||||
// queryNetwork answers a question about the LAN with a bounded scan. There is
|
||||
// no confirm turn because nothing is changed, and no way to widen the range
|
||||
// because Scan takes no target — the utterance selects the question, never the
|
||||
// subnet.
|
||||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isNetworkQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
if h.netscan == nil {
|
||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
||||
// swallow "сколько устройств в сети?" before recall has looked.
|
||||
return "", false
|
||||
}
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isWeatherQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||||
if loc == "" {
|
||||
// He named no city and voice.weather.default_location is unset. Saying
|
||||
// so is the only honest answer; picking a city would be inventing one.
|
||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
if errors.Is(err, weather.ErrNotConfigured) {
|
||||
return "погода не настроена.", true
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("voice: weather: %v", err)
|
||||
return "не получилось узнать погоду.", true
|
||||
}
|
||||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
||||
}
|
||||
|
||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||||
// sources below both need, run once, in the position it always ran in. It
|
||||
// only claims the turn when the embedder fails.
|
||||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
}
|
||||
t.vec = vec
|
||||
return "", false
|
||||
}
|
||||
|
||||
// queryMemory — 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 source 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.
|
||||
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if h.memStore == nil {
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.memStore.Search(ctx, t.vec, 3)
|
||||
if herr != nil {
|
||||
log.Printf("voice: memory search: %v", herr)
|
||||
return "", false
|
||||
}
|
||||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
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, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
||||
return reply, true
|
||||
}
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
|
||||
// queryNotes — 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. Failing the gate passes the turn on to general
|
||||
// knowledge, which is what "don't read back the runner-up" means here.
|
||||
func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||
if err != nil {
|
||||
log.Printf("voice: query notes: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
}
|
||||
t.notes = notes
|
||||
noteScores := make([]float64, len(notes))
|
||||
for i, n := range notes {
|
||||
noteScores[i] = n.Score
|
||||
}
|
||||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
||||
return "", false
|
||||
}
|
||||
texts := make([]string, len(notes))
|
||||
for i, n := range notes {
|
||||
texts[i] = n.Text
|
||||
}
|
||||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, texts)
|
||||
if err != nil {
|
||||
log.Printf("voice: phrase query: %v", err)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = "вот что я нашла: " + texts[0]
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// webPageContextRunes — how much of a fetched page is handed to the phraser.
|
||||
// Less than the crawler keeps: the rest of the 4096-token window belongs to the
|
||||
// prompt, the persona block and the reply.
|
||||
const webPageContextRunes = 1500
|
||||
|
||||
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||
//
|
||||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||
// becoming a habit: no URL, no fetch, and the model answers from what is local.
|
||||
// What leaves the box is the URL and nothing else — no note, no fact, no history
|
||||
// travels with it.
|
||||
func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
link, ok := router.FirstURL(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
if h.crawler == nil {
|
||||
// Fall through. Reading pages is off unless configured, and on a daemon
|
||||
// where it was never turned on the older behaviour is right: the model
|
||||
// answers the question as if the URL had not been said. Announcing a
|
||||
// configuration status is for a capability that exists and failed, not
|
||||
// for one he never asked for.
|
||||
return "", false
|
||||
}
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
if errors.Is(err, crawl.ErrRobots) {
|
||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||
}
|
||||
log.Printf("voice: web: %v", err)
|
||||
return "не получилось прочитать страницу.", true
|
||||
}
|
||||
if page.Text == "" {
|
||||
return "страница открылась, но читать там нечего.", true
|
||||
}
|
||||
// The page is handed to the phraser the same way a note is: as context for
|
||||
// the question he actually asked. She answers the question, she does not
|
||||
// recite the page.
|
||||
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
||||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
||||
if perr != nil {
|
||||
log.Printf("voice: web: phrase: %v", perr)
|
||||
}
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// queryGeneral — general knowledge from the phraser, the last source before
|
||||
// giving up. It always claims: either the model answers or Maven says she
|
||||
// doesn't know.
|
||||
func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil)
|
||||
if err != nil || reply == "" {
|
||||
return "не знаю.", true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// actionReminder handles router.IntentReminder: parse the time when stage-0
|
||||
// skipped the extractor, then create the reminder.
|
||||
func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decision) string {
|
||||
if !dec.Slots.HasTime {
|
||||
// Stage-0 (reminder-wakeword grammar) skips the extractor, so the
|
||||
// time wasn't parsed. Run the parser as a fallback.
|
||||
if dec.Stage == 0 && h.timeParser != nil {
|
||||
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
|
||||
if err == nil && ok {
|
||||
dec.Slots.Time = t
|
||||
dec.Slots.HasTime = true
|
||||
}
|
||||
}
|
||||
if !dec.Slots.HasTime {
|
||||
return "не получилось разобрать время напоминания."
|
||||
}
|
||||
}
|
||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return "не получилось поставить напоминание."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
)
|
||||
|
||||
// Task capture on the voice/chat path (Vikunja #130).
|
||||
//
|
||||
// Two halves, both deliberately small:
|
||||
//
|
||||
// - captureTaskFromNote runs at the top of actionNote. An utterance that
|
||||
// explicitly files a task ("добавь в задачи купить молоко") goes to the task
|
||||
// store instead of the note store. Anything without an explicit marker is
|
||||
// still a note — see router.ParseTaskCapture for why "надо бы поспать" must
|
||||
// not become a task.
|
||||
// - queryTasks is a query source that reads the list back.
|
||||
//
|
||||
// Nothing here speaks unprompted. Tasks are answered when asked about; no tick
|
||||
// rule reads the table.
|
||||
|
||||
// captureTaskFromNote claims the turn when the utterance explicitly files a
|
||||
// task, returning the reply. ("", false) hands the turn back to the note path.
|
||||
func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||
cap, ok := router.ParseTaskCapture(dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
resp, err := h.api.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||
Text: cap.Text,
|
||||
Source: "tap:voice",
|
||||
Status: store.TaskOpen, // he stated it himself — not a candidate
|
||||
Weight: cap.Weight, // 0 unless he said "срочно" / "важно"
|
||||
Ts: h.now(),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: capture task: %v", err)
|
||||
return "не получилось записать задачу.", true
|
||||
}
|
||||
if resp.Promoted {
|
||||
// It was a candidate Maven derived from something she read, and he has
|
||||
// now said it himself. Saying "уже в списке" here would be answering a
|
||||
// confirmation with a shrug.
|
||||
return "поняла, беру в работу: " + cap.Text, true
|
||||
}
|
||||
if !resp.Created {
|
||||
return "это уже в списке.", true
|
||||
}
|
||||
return "записала: " + cap.Text, true
|
||||
}
|
||||
|
||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||
//
|
||||
// Reads the live set and recites it in priority order (Vikunja #129). The order
|
||||
// is computed by internal/tasks from what he told her — deadlines, the urgency
|
||||
// he stated, how long a task has been sitting — never asked of the model. The
|
||||
// rendering is the package's too, so the spoken list and the /tasks page can
|
||||
// never disagree about what comes first.
|
||||
func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !router.IsTaskListQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
live, err := h.api.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
log.Printf("voice: list tasks: %v", err)
|
||||
return "не получилось посмотреть задачи.", true
|
||||
}
|
||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
||||
}
|
||||
|
||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||
// internal/tasks so the ranker stays a pure package with no ipc (and therefore
|
||||
// no store, and therefore no cgo) dependency — the same posture as
|
||||
// internal/morning and internal/memory.
|
||||
func taskItems(ts []ipc.Task) []tasks.Item {
|
||||
out := make([]tasks.Item, len(ts))
|
||||
for i, t := range ts {
|
||||
out[i] = tasks.Item{
|
||||
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// taskAPI answers only the three task methods; every other call is
|
||||
// unimplemented, which is the assertion that capture needs nothing else — in
|
||||
// particular no embedder, so a filed task costs no model call.
|
||||
type taskAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
captured []ipc.CaptureTaskReq
|
||||
created bool
|
||||
promoted bool
|
||||
capErr error
|
||||
|
||||
tasks []ipc.Task
|
||||
listArg string
|
||||
listErr error
|
||||
}
|
||||
|
||||
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
a.captured = append(a.captured, req)
|
||||
if a.capErr != nil {
|
||||
return ipc.CaptureTaskResp{}, a.capErr
|
||||
}
|
||||
return ipc.CaptureTaskResp{ID: 1, Created: a.created, Promoted: a.promoted}, nil
|
||||
}
|
||||
|
||||
func (a *taskAPI) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
a.listArg = status
|
||||
return a.tasks, a.listErr
|
||||
}
|
||||
|
||||
func taskNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
||||
|
||||
func taskHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||
return &reactiveHandler{api: api, now: taskNow}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteFilesTheTask(t *testing.T) {
|
||||
api := &taskAPI{created: true}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Intent: router.IntentNote, Utterance: "добавь в задачи купить молоко",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("an explicit capture must claim the turn")
|
||||
}
|
||||
if len(api.captured) != 1 {
|
||||
t.Fatalf("captured %d, want 1", len(api.captured))
|
||||
}
|
||||
got := api.captured[0]
|
||||
if got.Text != "купить молоко" {
|
||||
t.Errorf("text = %q, want the marker stripped", got.Text)
|
||||
}
|
||||
if got.Source != "tap:voice" {
|
||||
t.Errorf("source = %q, want tap:voice", got.Source)
|
||||
}
|
||||
if got.Status != "open" {
|
||||
t.Errorf("status = %q — work he stated is open, never a candidate", got.Status)
|
||||
}
|
||||
if !got.Ts.Equal(taskNow()) {
|
||||
t.Errorf("ts = %v, want the handler clock", got.Ts)
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want it to read the task back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A note is still a note: capture only fires on an explicit marker, so
|
||||
// ordinary recall is untouched.
|
||||
func TestCaptureTaskFromNotePassesOrdinaryNotes(t *testing.T) {
|
||||
api := &taskAPI{}
|
||||
h := taskHandler(api)
|
||||
for _, u := range []string{"надо бы поспать", "мне понравился этот фильм", "запиши что я пил воду"} {
|
||||
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: u}); ok {
|
||||
t.Errorf("%q was captured as a task", u)
|
||||
}
|
||||
}
|
||||
if len(api.captured) != 0 {
|
||||
t.Errorf("captured %d requests, want none", len(api.captured))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteSaysAlreadyOnTheList(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{created: false})
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь в задачи купить молоко"})
|
||||
if !ok {
|
||||
t.Fatal("expected the capture path to claim it")
|
||||
}
|
||||
if !strings.Contains(reply, "уже") {
|
||||
t.Errorf("reply = %q — a deduped capture must not claim it saved something new", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{capErr: errors.New("db is on fire")})
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{Utterance: "добавь задачу починить кран"})
|
||||
if !ok {
|
||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want an honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open"},
|
||||
{ID: 2, Text: "продлить страховку", Status: "candidate"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the task source must claim a task-list question")
|
||||
}
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("ListTasks(%q), want \"live\" — a resolved task is not outstanding work", api.listArg)
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") || !strings.Contains(reply, "продлить страховку") {
|
||||
t.Errorf("reply = %q, want both tasks", reply)
|
||||
}
|
||||
// The candidate must be named as unconfirmed, not recited as his work.
|
||||
openIdx := strings.Index(reply, "купить молоко")
|
||||
candIdx := strings.Index(reply, "продлить страховку")
|
||||
if !(openIdx < candIdx) {
|
||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "не подтвердил") {
|
||||
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The stated urgency rides through capture as a weight, so the ranker can use
|
||||
// it later (Vikunja #129). "срочно" is not part of the task text.
|
||||
func TestCaptureTaskCarriesStatedUrgency(t *testing.T) {
|
||||
api := &taskAPI{created: true}
|
||||
h := taskHandler(api)
|
||||
if _, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Utterance: "добавь в задачи срочно оплатить интернет",
|
||||
}); !ok {
|
||||
t.Fatal("expected a capture")
|
||||
}
|
||||
got := api.captured[0]
|
||||
if got.Text != "оплатить интернет" {
|
||||
t.Errorf("text = %q, want the urgency word out of the task", got.Text)
|
||||
}
|
||||
if got.Weight == 0 {
|
||||
t.Error("weight = 0 — he said срочно and it was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// The recital is ordered by the ranker, not by insertion: a deadline he named
|
||||
// comes before undated work.
|
||||
func TestQueryTasksRecitesInPriorityOrder(t *testing.T) {
|
||||
due := taskNow()
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: taskNow()},
|
||||
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: taskNow(), Due: &due},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply, _ := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "какие у меня задачи?"},
|
||||
})
|
||||
if strings.Index(reply, "оплатить интернет") > strings.Index(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want the dated task first", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "сегодня") {
|
||||
t.Errorf("reply = %q, want the reason named", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksEmptyList(t *testing.T) {
|
||||
h := taskHandler(&taskAPI{})
|
||||
reply, ok := h.queryTasks(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "что мне нужно сделать?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the task source to claim it")
|
||||
}
|
||||
if reply != "задач нет." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTasksPassesOtherQuestions(t *testing.T) {
|
||||
api := &taskAPI{}
|
||||
h := taskHandler(api)
|
||||
for _, u := range []string{"как дела?", "какая погода в москве?", "что у меня сегодня?"} {
|
||||
if _, ok := h.queryTasks(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
|
||||
t.Errorf("the task source claimed %q", u)
|
||||
}
|
||||
}
|
||||
if api.listArg != "" {
|
||||
t.Error("a non-task question must not read the task list")
|
||||
}
|
||||
}
|
||||
|
||||
// The chain must reach the task source before the recall sources, or "что мне
|
||||
// нужно сделать?" gets answered by whatever note is nearest.
|
||||
func TestQuerySourcesOrderTasksBeforeRecall(t *testing.T) {
|
||||
var tasksAt, notesAt = -1, -1
|
||||
for i, src := range querySources {
|
||||
switch src.name {
|
||||
case "tasks":
|
||||
tasksAt = i
|
||||
case "notes":
|
||||
notesAt = i
|
||||
}
|
||||
}
|
||||
if tasksAt < 0 || notesAt < 0 {
|
||||
t.Fatalf("sources missing: tasks=%d notes=%d", tasksAt, notesAt)
|
||||
}
|
||||
if tasksAt > notesAt {
|
||||
t.Errorf("tasks source at %d, after notes at %d", tasksAt, notesAt)
|
||||
}
|
||||
}
|
||||
|
||||
// Saying a task out loud that Maven had only proposed is a confirmation. She
|
||||
// used to answer "это уже в списке" and then read it back, in the same
|
||||
// conversation, as something he had not confirmed.
|
||||
func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
||||
api := &taskAPI{promoted: true}
|
||||
h := taskHandler(api)
|
||||
reply, ok := h.captureTaskFromNote(context.Background(), router.Decision{
|
||||
Intent: router.IntentNote, Utterance: "добавь в задачи продлить страховку",
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("an explicit capture must claim the turn")
|
||||
}
|
||||
if strings.Contains(reply, "уже в списке") {
|
||||
t.Errorf("reply = %q — he just confirmed it, that is not a duplicate", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "продлить страховку") {
|
||||
t.Errorf("reply = %q, want the task named back", reply)
|
||||
}
|
||||
// Persona: feminine, informal.
|
||||
for _, bad := range []string{"рад ", "вы ", "ваш"} {
|
||||
if strings.Contains(reply, bad) {
|
||||
t.Errorf("reply %q contains %q", reply, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// mavend/capture.go — core's half of the meeting recorder (Vikunja #253,
|
||||
// docs/plans/08-hearing.md).
|
||||
//
|
||||
// The split: a client that has a microphone (mavenclient, or a phone on the PWA)
|
||||
// is told to start, streams frames over ipc.MethodCaptureAppend, and is told to
|
||||
// stop. Core keeps the PCM, stores it as a WAV blob under the same media store
|
||||
// and the same retention as images, transcribes it through the ONE STT Maven has
|
||||
// (mavsttd's whisper.cpp, reused — not a second engine), and summarises the
|
||||
// transcript on the resident model in windows that fit n_ctx 4096.
|
||||
//
|
||||
// # Off unless configured, twice over
|
||||
//
|
||||
// No `media` block ⇒ nowhere to keep audio ⇒ the four capture methods do not
|
||||
// exist. No `capture` block with enabled ⇒ they still do not exist. On an
|
||||
// unconfigured box there is no wire path that starts a recording, which is the
|
||||
// only guarantee worth making about a capability like this one.
|
||||
//
|
||||
// # What this file refuses to do
|
||||
//
|
||||
// - Nothing listens. There is no VAD hook here, no wake-word branch, no
|
||||
// "start when you hear a meeting". The plan document's keyword-triggered
|
||||
// recorder is refused in internal/capture's package comment for the reason
|
||||
// that applies here too: noticing a keyword requires listening, which is
|
||||
// the behaviour this capability must not have.
|
||||
// - No transcript note by default. The summary is written where he will read
|
||||
// it; the verbatim record of what other people said takes a deliberate
|
||||
// capture.save_transcript.
|
||||
// - The transcript is never search input beyond this box, and the audio never
|
||||
// leaves it at all.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/capture"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// captureSummaryTimeout — the budget for one summary, which is a map-reduce over
|
||||
// the whole meeting: one model call per transcript window plus a reduce, each of
|
||||
// which is seconds on this box. Forty windows is the configured ceiling, so the
|
||||
// budget has to be minutes, not the 60s the reply path uses. It is spent on a
|
||||
// background goroutine, never inside the capture_stop request: a client that
|
||||
// asks Maven to stop recording gets the transcript back in seconds.
|
||||
const captureSummaryTimeout = 20 * time.Minute
|
||||
|
||||
// llmCompleter adapts *llm.Client to capture.Completer. The pure package names
|
||||
// the two strings it needs and stays free of the llm request struct; the client
|
||||
// itself is the swap-aware one from llmClientFor, so a model swap re-points it.
|
||||
type llmCompleter struct {
|
||||
c *llm.Client
|
||||
maxTokens int
|
||||
}
|
||||
|
||||
func (l llmCompleter) Complete(ctx context.Context, system, user string) (string, error) {
|
||||
return l.c.Complete(ctx, llm.Req{System: system, User: user, MaxTokens: l.maxTokens})
|
||||
}
|
||||
|
||||
// captureWiring — the recorder plus what it needs to write the result down.
|
||||
type captureWiring struct {
|
||||
rec *capture.Recorder
|
||||
st *store.Store
|
||||
emb router.Embedder
|
||||
cfg *config.CaptureConfig
|
||||
now func() time.Time
|
||||
|
||||
// ctx and wg belong to the daemon, not to the request. Summarising happens
|
||||
// after the reply has gone out, so it needs a lifetime that outlives the
|
||||
// call and a shutdown that waits for it.
|
||||
ctx context.Context
|
||||
wg *sync.WaitGroup
|
||||
}
|
||||
|
||||
// newCaptureWiring returns nil when the recorder should not exist: no media
|
||||
// store, no capture block, capture disabled, or no STT to transcribe with.
|
||||
//
|
||||
// A missing llama-server is NOT a reason to return nil. Without one the
|
||||
// recording is still made, stored and transcribed, and the summary is simply
|
||||
// absent — the honest degradation, and much better than refusing to record a
|
||||
// meeting that is happening now.
|
||||
func newCaptureWiring(ctx context.Context, wg *sync.WaitGroup, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
|
||||
if keeper == nil || !cfg.Capture.Records() {
|
||||
return nil
|
||||
}
|
||||
tr := transcriberOf(voiceW)
|
||||
if tr == nil {
|
||||
// Voice off ⇒ no STT client ⇒ nothing could turn the audio into words.
|
||||
// Storing hours of unreadable audio of other people is worse than not
|
||||
// recording, so this is a refusal, not a degradation.
|
||||
log.Printf("capture: enabled but voice/stt is not wired — meeting capture disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
cc := cfg.Capture
|
||||
var sum *capture.Summarizer
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||
client := llmClientFor(lp, captureSummaryTimeout)
|
||||
sum = capture.NewSummarizer(
|
||||
llmCompleter{c: client, maxTokens: 512},
|
||||
cc.ChunkRunes, cc.MaxChunks, contextBlockFn(cfg, time.Now),
|
||||
)
|
||||
} else {
|
||||
log.Printf("capture: no llama-server phraser — meetings are transcribed, not summarised")
|
||||
}
|
||||
|
||||
rec, err := capture.New(keeper.store, tr, sum, capture.Config{
|
||||
MaxDuration: cc.MaxDuration(),
|
||||
STTWindow: time.Duration(cc.STTWindow),
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("capture: %v — meeting capture disabled", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("capture: enabled, sessions capped at %s", rec.MaxDuration())
|
||||
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now, ctx: ctx, wg: wg}
|
||||
}
|
||||
|
||||
// start handles ipc.MethodCaptureStart.
|
||||
func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.CaptureStartResp, error) {
|
||||
s, err := c.rec.Start(req.Label)
|
||||
if err != nil {
|
||||
return ipc.CaptureStartResp{}, err
|
||||
}
|
||||
// The label is logged; nothing that was said ever is.
|
||||
log.Printf("capture: started %q", s.Label)
|
||||
return ipc.CaptureStartResp{
|
||||
Label: s.Label,
|
||||
Started: s.Started,
|
||||
Token: s.Token,
|
||||
MaxSeconds: int(c.rec.MaxDuration().Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// append handles ipc.MethodCaptureAppend. ErrExpired is reported as a successful
|
||||
// response with Expired set rather than an error: the cap firing is the designed
|
||||
// behaviour, and the client needs the flag to stop sending and call stop.
|
||||
func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc.CaptureAppendResp, error) {
|
||||
err := c.rec.Append(req.Token, req.Audio)
|
||||
st := c.rec.Status()
|
||||
if errors.Is(err, capture.ErrExpired) {
|
||||
log.Printf("capture: %q hit the %s cap — stopping", st.Label, c.rec.MaxDuration())
|
||||
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds(), Expired: true}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ipc.CaptureAppendResp{}, err
|
||||
}
|
||||
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds()}, nil
|
||||
}
|
||||
|
||||
// stop handles ipc.MethodCaptureStop.
|
||||
//
|
||||
// The error handling here mirrors vision's, and for the same reason: the audio is
|
||||
// stored first, so a transcription failure returns what exists rather than
|
||||
// nothing. A response can carry a blob id with no transcript (STT failed,
|
||||
// re-runnable) — a degraded success, not an error to the caller.
|
||||
//
|
||||
// Summarising is NOT done here. A two-hour meeting is forty model calls, which
|
||||
// on this box is minutes, and holding the IPC request open for them means the
|
||||
// client that said "стоп" sits there with no answer while its own deadline runs
|
||||
// out. Stop returns the transcript, and the summary note is written by a
|
||||
// goroutine in the daemon's WaitGroup afterwards.
|
||||
func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.CaptureStopResp, error) {
|
||||
if req.Discard {
|
||||
// "забудь, не записывай" — nothing is stored, transcribed or noted.
|
||||
if !c.rec.Abort(req.Token) {
|
||||
return ipc.CaptureStopResp{}, capture.ErrNoSession
|
||||
}
|
||||
log.Printf("capture: session discarded on request")
|
||||
return ipc.CaptureStopResp{Discarded: true}, nil
|
||||
}
|
||||
|
||||
res, err := c.rec.Stop(ctx, req.Token)
|
||||
resp := ipc.CaptureStopResp{
|
||||
BlobID: res.BlobID,
|
||||
Label: res.Label,
|
||||
Started: res.Started,
|
||||
Seconds: res.Duration.Seconds(),
|
||||
Transcript: res.Transcript,
|
||||
Summary: res.Summary,
|
||||
Chunks: res.Chunks,
|
||||
}
|
||||
if err != nil {
|
||||
if res.BlobID == "" && res.Transcript == "" {
|
||||
// Nothing survived: no session, or an empty recording. There is
|
||||
// nothing to hand back, so this is a real error.
|
||||
return ipc.CaptureStopResp{}, err
|
||||
}
|
||||
log.Printf("capture: %q partially finished: %v", res.Label, err)
|
||||
}
|
||||
|
||||
c.summarizeLater(res)
|
||||
log.Printf("capture: finished %q — %s of audio, %d bytes of transcript",
|
||||
res.Label, res.Duration.Round(time.Second), len(res.Transcript))
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// summarizeLater runs the map-reduce and writes the notes after stop replied.
|
||||
// The context is the daemon's, not the request's: the request is already
|
||||
// answered, and cancelling the summary because the client hung up would throw
|
||||
// away the only readable record of the meeting.
|
||||
func (c *captureWiring) summarizeLater(res capture.Result) {
|
||||
if res.Transcript == "" {
|
||||
return
|
||||
}
|
||||
c.wg.Add(1)
|
||||
go func() {
|
||||
defer c.wg.Done()
|
||||
ctx, cancel := context.WithTimeout(c.ctx, captureSummaryTimeout)
|
||||
defer cancel()
|
||||
if err := c.rec.Summarize(ctx, &res); err != nil {
|
||||
// Not fatal: writeNotes falls back to the transcript, so a dead
|
||||
// llama-server costs the summary and not the meeting.
|
||||
log.Printf("capture: summary for %q failed: %v", res.Label, err)
|
||||
}
|
||||
if _, err := c.writeNotes(ctx, res); err != nil {
|
||||
log.Printf("capture: note write for %q failed: %v", res.Label, err)
|
||||
return
|
||||
}
|
||||
log.Printf("capture: summarised %q in %d chunk(s)", res.Label, res.Chunks)
|
||||
}()
|
||||
}
|
||||
|
||||
// writeNotes stores the summary as a note, and the transcript too when
|
||||
// capture.save_transcript is set. Returns the id of the note that carries the
|
||||
// meeting.
|
||||
//
|
||||
// With no summary the transcript is written instead, whatever save_transcript
|
||||
// says. That flag is about keeping the verbatim record IN ADDITION to a summary,
|
||||
// not about whether the meeting is remembered at all. Without this fallback a
|
||||
// llama-server that was down at stop time meant an hour of recorded meeting left
|
||||
// no note behind and nothing recalled it later.
|
||||
//
|
||||
// The note source carries the blob id, which is the only link back to the audio.
|
||||
// When retention prunes the blob the note remains — words about a meeting are a
|
||||
// far lighter thing to keep than a recording of it.
|
||||
func (c *captureWiring) writeNotes(ctx context.Context, res capture.Result) (int64, error) {
|
||||
source := "capture:meeting"
|
||||
if res.BlobID != "" {
|
||||
source = "capture:meeting:" + res.BlobID[:12]
|
||||
}
|
||||
var id int64
|
||||
if text := res.Summary; text != "" {
|
||||
var err error
|
||||
id, err = c.writeNote(ctx, text, source)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("summary note: %w", err)
|
||||
}
|
||||
} else if res.Transcript != "" {
|
||||
var err error
|
||||
id, err = c.writeNote(ctx, res.Transcript, source+":transcript")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("transcript note: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
if c.cfg.SaveTranscript && res.Transcript != "" {
|
||||
if _, err := c.writeNote(ctx, res.Transcript, source+":transcript"); err != nil {
|
||||
return id, fmt.Errorf("transcript note: %w", err)
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (c *captureWiring) writeNote(ctx context.Context, text, source string) (int64, error) {
|
||||
var vec []float32
|
||||
if c.emb != nil {
|
||||
// EmbedPassage, not Embed: this is text being searched FOR, and the e5
|
||||
// embedder is asymmetric. Backwards here makes the meeting unfindable by
|
||||
// the question that should have matched it.
|
||||
var err error
|
||||
vec, err = router.EmbedPassage(ctx, c.emb, text)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("embed: %w", err)
|
||||
}
|
||||
}
|
||||
return c.st.WriteNote(ctx, c.now(), text, vec, source)
|
||||
}
|
||||
|
||||
// status handles ipc.MethodCaptureStatus.
|
||||
func (c *captureWiring) status(_ context.Context) (ipc.CaptureStatusResp, error) {
|
||||
st := c.rec.Status()
|
||||
return ipc.CaptureStatusResp{
|
||||
Running: st.Running,
|
||||
Label: st.Label,
|
||||
Started: st.Started,
|
||||
Seconds: st.Duration.Seconds(),
|
||||
Bytes: st.Bytes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// wireCapture installs the four IPC hooks, or leaves them nil so every capture
|
||||
// method reports ErrUnknownMethod. Takes the media keeper wireVision already
|
||||
// opened: one blob store, one retention loop, images and audio side by side.
|
||||
func wireCapture(ctx context.Context, wg *sync.WaitGroup, srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
|
||||
cw := newCaptureWiring(ctx, wg, keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
|
||||
if cw == nil {
|
||||
return
|
||||
}
|
||||
srv.CaptureStartFn = cw.start
|
||||
srv.CaptureAppendFn = cw.append
|
||||
srv.CaptureStopFn = cw.stop
|
||||
srv.CaptureStatusFn = cw.status
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/capture"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/media"
|
||||
)
|
||||
|
||||
// silentTranscriber stands in for mavsttd: one fixed phrase per window, so the
|
||||
// wiring can be tested without whisper.
|
||||
type silentTranscriber struct{}
|
||||
|
||||
func (silentTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
|
||||
return "решили купить насос", 1.0, nil
|
||||
}
|
||||
|
||||
func testCaptureWiring(t *testing.T) (*captureWiring, *sync.WaitGroup) {
|
||||
t.Helper()
|
||||
blobs, err := media.Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, err := capture.New(blobs, silentTranscriber{}, nil, capture.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
return &captureWiring{
|
||||
rec: rec,
|
||||
st: newTestStore(t),
|
||||
cfg: &config.CaptureConfig{},
|
||||
now: time.Now,
|
||||
ctx: context.Background(),
|
||||
wg: &wg,
|
||||
}, &wg
|
||||
}
|
||||
|
||||
// A frame carrying the wrong token must not land in the running session. Append
|
||||
// and stop used to address "whatever is running now", so a client whose session
|
||||
// had already ended went on recording into somebody else's meeting, and any
|
||||
// client could end a recording it never started.
|
||||
func TestCaptureRefusesAnotherClientsToken(t *testing.T) {
|
||||
c, _ := testCaptureWiring(t)
|
||||
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "встреча"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if start.Token == "" {
|
||||
t.Fatal("start handed back no session token")
|
||||
}
|
||||
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
|
||||
Token: "not-mine",
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 3200)},
|
||||
}); err == nil {
|
||||
t.Error("a frame with the wrong token was accepted")
|
||||
}
|
||||
if _, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: "not-mine"}); err == nil {
|
||||
t.Error("a stop with the wrong token ended the session")
|
||||
}
|
||||
if st, _ := c.status(context.Background()); !st.Running {
|
||||
t.Error("the session was ended by a client that does not own it")
|
||||
}
|
||||
}
|
||||
|
||||
// Stop answers with the transcript and does not wait for the summary. The
|
||||
// summary is up to forty model calls, and holding the IPC request for them meant
|
||||
// the client that said "стоп" sat with no answer for minutes.
|
||||
//
|
||||
// With no summariser wired the note still has to be written, from the transcript.
|
||||
// save_transcript is about keeping the verbatim record IN ADDITION to a summary,
|
||||
// not about whether the meeting is remembered at all — without this fallback a
|
||||
// dead llama-server meant an hour of meeting left no note behind.
|
||||
func TestStopReturnsTranscriptAndNotesItWithoutASummary(t *testing.T) {
|
||||
c, wg := testCaptureWiring(t)
|
||||
start, err := c.start(context.Background(), ipc.CaptureStartReq{Label: "планёрка"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.append(context.Background(), ipc.CaptureAppendReq{
|
||||
Token: start.Token,
|
||||
Audio: audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 32000)},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := c.stop(context.Background(), ipc.CaptureStopReq{Token: start.Token})
|
||||
if err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if resp.Transcript == "" {
|
||||
t.Fatal("stop returned no transcript")
|
||||
}
|
||||
if resp.Summary != "" {
|
||||
t.Errorf("summary = %q, want none inside the request", resp.Summary)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
notes, err := c.st.RecentNotes(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found bool
|
||||
for _, n := range notes {
|
||||
if strings.Contains(n.Text, "насос") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the meeting left no note behind: %+v", notes)
|
||||
}
|
||||
}
|
||||
+199
-21
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
@@ -15,14 +17,18 @@ import (
|
||||
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.
|
||||
// the one she asks about this turn; the rest are asked about on later turns, one
|
||||
// per turn, as each answer lands (see askRemainingGap).
|
||||
//
|
||||
// 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.
|
||||
// A reminder wants BOTH what to remind about and when. Subject first: "напомни
|
||||
// в 11" has a time and nothing to say at 11, and a reminder with no subject is
|
||||
// not worth setting. Order here is the order she asks in.
|
||||
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
router.IntentReminder: {dialogue.SlotTime},
|
||||
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
|
||||
router.IntentFact: {dialogue.SlotKey},
|
||||
router.IntentAct: {dialogue.SlotFn},
|
||||
}
|
||||
@@ -35,14 +41,94 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
// questions, so there is no gender agreement to get wrong; the feminine
|
||||
// self-reference lives in the reply she gives when she drops the request.
|
||||
var clarifyQuestions = map[dialogue.Slot]string{
|
||||
dialogue.SlotTime: "На когда напомнить?",
|
||||
dialogue.SlotTime: "Когда?",
|
||||
dialogue.SlotText: "О чём напомнить?",
|
||||
dialogue.SlotKey: "Что записать?",
|
||||
dialogue.SlotFn: "Что сделать?",
|
||||
}
|
||||
|
||||
// clarifyDropped — she asked once, the answer still did not fill the gap, so
|
||||
// the request is gone. Said plainly, once, with no second question.
|
||||
const clarifyDropped = "Не разобрала — скажи целиком, пожалуйста."
|
||||
// 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 = "Прости, я не поняла. Скажи, пожалуйста, по-другому."
|
||||
|
||||
// clarifyExpiredVariants — his answer came after the TTL, so the parked request
|
||||
// is already gone. Same tone as clarifyGaveUp, different reason: too much time
|
||||
// passed, not "I did not understand". Feminine self-reference ("ждала",
|
||||
// "отпустила"); he is addressed with a plain imperative.
|
||||
//
|
||||
// Five phrasings, not one. This is the line he hears whenever he walks off
|
||||
// mid-request, so it is the line that repeats most — and the same sentence every
|
||||
// time is what makes a house assistant sound like a kiosk. They all carry the
|
||||
// same two facts (the old request is gone; say it again if it still matters),
|
||||
// because the wording may vary and the meaning may not.
|
||||
//
|
||||
// Fixed templates rather than model output, for the same reason as
|
||||
// clarifyQuestions: this text has to be right every time, and it is not worth a
|
||||
// generation to say something this small.
|
||||
var clarifyExpiredVariants = []string{
|
||||
"Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново.",
|
||||
"Кажется, прошлая просьба уже не важна — я её отпустила. Если я ошибаюсь, повтори.",
|
||||
"Ты как-то резко замолчал, и я не стала ждать дальше. Если та просьба ещё нужна, скажи заново.",
|
||||
"Я не дождалась ответа и убрала прошлую просьбу. Повтори, если она всё ещё нужна.",
|
||||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random.
|
||||
func clarifyExpiredLine() string {
|
||||
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
|
||||
}
|
||||
|
||||
// isClarifyExpired reports whether s opens with any of the expiry lines. The
|
||||
// notice is glued in front of this turn's reply (see withNotice), so a caller
|
||||
// checking for it has to match a prefix, not the whole string.
|
||||
func isClarifyExpired(s string) bool {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
||||
// reply. "" ⇒ the notice was the whole thing.
|
||||
func trimClarifyExpired(s string) string {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||||
// out, and "" when nothing was parked. Call it right after
|
||||
// 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 clarifyExpiredLine()
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -54,7 +140,8 @@ func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
// ("", 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.
|
||||
// first only. Two questions in one breath is an interrogation. The second gap
|
||||
// is picked up on the turn after the first one is answered (askRemainingGap).
|
||||
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
missing := missingFor(dec)
|
||||
if len(missing) == 0 {
|
||||
@@ -79,13 +166,14 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
||||
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, // asked once; MaxAttempts is 1, so there is no second ask
|
||||
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
|
||||
@@ -97,8 +185,9 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
||||
// 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 the request
|
||||
// is dropped: she does not ask again.
|
||||
// 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
|
||||
@@ -107,16 +196,29 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
if q == nil {
|
||||
return "", false
|
||||
}
|
||||
// One shot either way: the question is consumed whether or not the answer
|
||||
// works, so a failed answer can't leave the question armed.
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
|
||||
intent := router.Intent(q.Intent)
|
||||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
||||
merged := q.Answer(text, toDialogueSlots(answer))
|
||||
// Fold a newly answered subject into the raw utterance. Downstream actions
|
||||
// phrase from Utterance, not from the text slot — actionReminder stores it
|
||||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||
log.Printf("voice: clarify — answer %q did not fill %v, dropping", text, q.Missing)
|
||||
return clarifyDropped, true
|
||||
return h.reaskOrGiveUp(q, merged, text), true
|
||||
}
|
||||
h.clarifyStore.Delete(voiceDialogueID)
|
||||
|
||||
// One gap filled is not the same as a complete request. askClarify parks
|
||||
// only the first gap, because one question per turn is the rule, but a
|
||||
// reminder wants both a subject and a time. "напомни" with neither used to
|
||||
// ask "О чём напомнить?", accept "позвонить маме", and then hand applyAction
|
||||
// a reminder with no time, which answered "не получилось разобрать время
|
||||
// напоминания." — an error for a request she never finished asking about.
|
||||
// Re-enter the loop instead, one question at a time as before.
|
||||
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// Rebuild the decision as if it had routed cleanly, then run it down the
|
||||
@@ -133,6 +235,78 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
return h.finishClarified(ctx, dec), true
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
// unless they already carry it. "напомни" + "позвонить маме" reads as the
|
||||
// request he would have made in one breath. Nothing is appended when the
|
||||
// subject is empty or already present, so re-asking the same question twice
|
||||
// cannot grow the utterance.
|
||||
func foldAnswerIntoUtterance(utterance, subject string) string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" || strings.Contains(utterance, subject) {
|
||||
return utterance
|
||||
}
|
||||
if strings.TrimSpace(utterance) == "" {
|
||||
return subject
|
||||
}
|
||||
return strings.TrimSpace(utterance) + " " + subject
|
||||
}
|
||||
|
||||
// askRemainingGap re-parks the request when the answer closed one gap and
|
||||
// wantedSlots still names another. Returns ("", false) when the request is
|
||||
// complete, when there is no question for what is left, or when she is out of
|
||||
// attempts — in all three the caller runs the decision as it stands, which for
|
||||
// the out-of-attempts case is the old behaviour and is the right one: she has
|
||||
// already asked enough.
|
||||
//
|
||||
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
||||
// costs a question exactly like a second try at the first one does, so the cap
|
||||
// still bounds how many times she can speak before acting or letting go.
|
||||
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
}
|
||||
question, ok := clarifyQuestions[remaining[0]]
|
||||
if !ok || !q.CanAsk() {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
Missing: []dialogue.Slot{remaining[0]},
|
||||
Utterance: q.Utterance,
|
||||
Asked: h.now(),
|
||||
TTL: clarifyTTL,
|
||||
Attempts: q.Attempts + 1,
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
})
|
||||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
|
||||
return question, 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 {
|
||||
@@ -146,6 +320,10 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
||||
if reply == "" {
|
||||
reply = h.replier.Reply(dec)
|
||||
}
|
||||
if reply == "" {
|
||||
// Belt: an empty reply here would be a silent drop.
|
||||
reply = clarifyGaveUp
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
|
||||
+243
-14
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
@@ -56,10 +57,13 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
||||
want string
|
||||
asked bool
|
||||
}{
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "На когда напомнить?", true},
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
|
||||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||||
{"reminder that already has a time", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "", false},
|
||||
// A time with nothing to say at that time is still half a reminder, so
|
||||
// the subject is what she asks about — not silence.
|
||||
{"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true},
|
||||
{"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false},
|
||||
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
||||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||||
}
|
||||
@@ -78,7 +82,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "На когда напомнить?" {
|
||||
if !asked || question != "Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
@@ -86,7 +90,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||||
}
|
||||
if reply == clarifyDropped {
|
||||
if reply == clarifyGaveUp {
|
||||
t.Fatalf("a good answer must not drop the request: %q", reply)
|
||||
}
|
||||
|
||||
@@ -111,7 +115,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.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 == clarifyDropped {
|
||||
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" {
|
||||
@@ -137,26 +141,87 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyUnclearAnswerDropsWithoutAskingAgain — MaxAttempts is 1.
|
||||
func TestClarifyUnclearAnswerDropsWithoutAskingAgain(t *testing.T) {
|
||||
// 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 question")
|
||||
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 != clarifyDropped {
|
||||
t.Fatalf("an unclear answer should drop the request, handled=%v reply=%q", handled, reply)
|
||||
if !handled || reply != clarifyGaveUp {
|
||||
t.Fatalf("the fourth try must give up out loud, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if strings.Contains(reply, "?") {
|
||||
t.Fatalf("she must not ask a second question: %q", 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 dropped request must leave no armed question")
|
||||
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 dropped request must not create anything: reminders=%v err=%v", reminders, err)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +293,36 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 !isClarifyExpired(reply) {
|
||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||
}
|
||||
if trimClarifyExpired(reply) == "" {
|
||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
// The notice is said once, not on every later utterance.
|
||||
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) {
|
||||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoPendingQuestionFallsThrough — with nothing parked, an utterance routes
|
||||
// normally.
|
||||
func TestNoPendingQuestionFallsThrough(t *testing.T) {
|
||||
@@ -236,3 +331,137 @@ func TestNoPendingQuestionFallsThrough(t *testing.T) {
|
||||
t.Fatalf("no open question ⇒ must not be treated as an answer, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyAsksAboutTheSecondGapToo — "напомни" with neither a subject nor a
|
||||
// time. She asks about the subject, he gives it, and the request is still not
|
||||
// complete. The old code handed applyAction a reminder with no time, which
|
||||
// answered with a parse error for a question she never asked.
|
||||
func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed as an answer")
|
||||
}
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
||||
}
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
t.Fatal("the second gap must leave a question armed")
|
||||
}
|
||||
if q.Slots.Text == "" {
|
||||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
||||
}
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the time answer must complete the reminder, 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)
|
||||
}
|
||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
||||
t.Fatalf("the reminder lost the subject: %q", reminders[0].Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifySecondGapRespectsTheAttemptCap — the second gap spends a question
|
||||
// out of the same budget, so it cannot turn a capped exchange into an endless
|
||||
// one. With one attempt allowed she acts on what she has instead of asking.
|
||||
func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
t.Fatal("expected the subject question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed")
|
||||
}
|
||||
if reply == "Когда?" {
|
||||
t.Fatal("out of attempts she must not ask a second question")
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("no question may stay armed past the cap")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that
|
||||
// the phrasing eval never sees, because they never go through the phraser. They
|
||||
// carry feminine self-reference ("ждала", "отпустила") and address him with a
|
||||
// plain imperative, and they are exactly the kind of string someone later edits
|
||||
// reaching for a synonym. Run the eval's own persona checks over them here.
|
||||
func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
// Only the persona checks. Length and on-topic do not apply: these are not
|
||||
// nudges, they have no rule to be on topic about, and the expiry lines are
|
||||
// deliberately longer than a nudge ceiling.
|
||||
want := map[string]bool{
|
||||
eval.CheckFeminine: true,
|
||||
eval.CheckHisGender: true,
|
||||
eval.CheckAddress: true,
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||
for _, q := range clarifyQuestions {
|
||||
lines = append(lines, q)
|
||||
}
|
||||
for _, line := range lines {
|
||||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||||
// The apology clause of the cringe check is scoped to nudges: it
|
||||
// exists because apologising for a greenlit nudge undermines it.
|
||||
// These lines are the opposite case. She did not understand him, or
|
||||
// she let his request go, and "прости" there is ordinary speech
|
||||
// rather than grovelling. Every other cringe rule still applies:
|
||||
// pet names, emoji, exclamations, fake concern, praise.
|
||||
// checkCringe returns the first break it finds, so this skip also
|
||||
// hides a later one in the same line. Kept narrow on purpose: it
|
||||
// only fires on a leading "apology (…)" detail.
|
||||
if r.Name == eval.CheckCringe && strings.HasPrefix(r.Detail, "apology") {
|
||||
continue
|
||||
}
|
||||
if want[r.Name] && !r.Pass {
|
||||
t.Errorf("%q fails %s: %s", line, r.Name, r.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestExpiryNoticeSurvivesAConfirmTurn — she asks a question, he walks off, the
|
||||
// question expires, he comes back and answers a confirm that is still parked.
|
||||
// The confirm turn used to return before the notice was even computed, so he
|
||||
// answered the confirm and never heard that the older request was let go.
|
||||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, now := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
// A confirm parked with a longer life than the question, so only the
|
||||
// question is stale when he speaks.
|
||||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "нет")
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||||
}
|
||||
if trimClarifyExpired(reply) == "" {
|
||||
t.Fatalf("the confirm answer must survive the notice, got only the notice: %q", reply)
|
||||
}
|
||||
if h.pending != nil {
|
||||
t.Fatal("the confirm must still have been consumed")
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
func randBytes(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
b[0] |= 1
|
||||
return b
|
||||
}
|
||||
|
||||
func TestDaemonLockStartsLockedAndFlips(t *testing.T) {
|
||||
dl := newDaemonLock(true)
|
||||
if !dl.isLocked() {
|
||||
t.Fatal("newDaemonLock(true) is not locked")
|
||||
}
|
||||
dl.unlock(nil)
|
||||
if dl.isLocked() {
|
||||
t.Fatal("still locked after unlock")
|
||||
}
|
||||
if newDaemonLock(false).isLocked() {
|
||||
t.Fatal("newDaemonLock(false) reports locked")
|
||||
}
|
||||
}
|
||||
|
||||
// closeStore must be safe on a daemon that never unlocked and safe twice —
|
||||
// shutdown runs it unconditionally.
|
||||
func TestDaemonLockCloseStoreIsSafeWhenNeverUnlocked(t *testing.T) {
|
||||
dl := newDaemonLock(true)
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("closeStore with no store: %v", err)
|
||||
}
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("second closeStore: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The data-loss bug: in locked mode the store is opened on an IPC goroutine
|
||||
// inside UnlockFn, and shutdown runs on main. Without the handoff nothing
|
||||
// calls Close, and Close is what re-encrypts the tmpfs working copy back over
|
||||
// the ciphertext file — so every write of a cold-started session vanished.
|
||||
func TestDaemonLockSealsTheStoreOpenedAfterUnlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "maven.db")
|
||||
tmpfs := filepath.Join(dir, "work")
|
||||
key := randBytes(t, 32)
|
||||
// Store.Close zeroes the key slice it was handed (encState.key is the
|
||||
// caller's backing array), so the next boot needs its own copy — exactly
|
||||
// as mavend keeps envKeyBytes separate from the config's key.
|
||||
nextBoot := bytes.Clone(key)
|
||||
ctx := context.Background()
|
||||
|
||||
// Cold start: locked, no store.
|
||||
dl := newDaemonLock(true)
|
||||
|
||||
// ... unlock arrives, opens the store and hands it over.
|
||||
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenEncrypted: %v", err)
|
||||
}
|
||||
dl.unlock(st)
|
||||
if _, err := st.WriteNote(ctx, time.Now(), "заметка после холодного старта", nil, "test"); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
|
||||
// Shutdown.
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("closeStore: %v", err)
|
||||
}
|
||||
if err := dl.closeStore(); err != nil {
|
||||
t.Fatalf("second closeStore after a real store: %v", err)
|
||||
}
|
||||
|
||||
// Next boot with the same key must see the write.
|
||||
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, nextBoot)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer st2.Close()
|
||||
notes, err := st2.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("got %d notes after a cold-started session, want 1 — the session was lost", len(notes))
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the wrapped blob: what sits in the state dir must not let
|
||||
// anyone open the database. Nothing written there may contain the key, and the
|
||||
// ciphertext must not be readable with a wrong one.
|
||||
func TestColdStartLeavesNoPlaintextKeyOnDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "maven.db")
|
||||
tmpfs := filepath.Join(dir, "work")
|
||||
wrappedPath := filepath.Join(dir, "db_key.wrapped")
|
||||
key := randBytes(t, 32)
|
||||
secret := randBytes(t, 32)
|
||||
ctx := context.Background()
|
||||
|
||||
blob, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(wrappedPath, blob, 0o600); err != nil {
|
||||
t.Fatalf("write wrapped key: %v", err)
|
||||
}
|
||||
|
||||
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenEncrypted: %v", err)
|
||||
}
|
||||
if _, err := st.WriteNote(ctx, time.Now(), "секрет", nil, "test"); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
// Walk everything in the state dir; none of it may contain the key.
|
||||
err = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return err
|
||||
}
|
||||
b, rerr := os.ReadFile(p)
|
||||
if rerr != nil {
|
||||
return nil // unreadable is not a leak
|
||||
}
|
||||
if bytes.Contains(b, key) {
|
||||
t.Errorf("%s contains the plaintext encryption key", p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walk: %v", err)
|
||||
}
|
||||
|
||||
// The wrapped file must have owner-only permissions.
|
||||
fi, err := os.Stat(wrappedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("wrapped key file mode = %o, want 600", perm)
|
||||
}
|
||||
|
||||
// A wrong passkey must not open the store.
|
||||
if _, _, err := webauthn.UnwrapKey(blob, randBytes(t, 32)); err == nil {
|
||||
t.Fatal("a wrong PRF secret unwrapped the key")
|
||||
}
|
||||
if _, err := store.OpenEncrypted(ctx, dbPath, filepath.Join(dir, "work2"), randBytes(t, 32)); err == nil {
|
||||
t.Fatal("the encrypted store opened under a wrong key")
|
||||
}
|
||||
|
||||
// And the right one round-trips back to a readable database.
|
||||
got, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("UnwrapKey: %v", err)
|
||||
}
|
||||
if version != webauthn.BlobV2 {
|
||||
t.Errorf("blob version = %v, want v2", version)
|
||||
}
|
||||
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, got)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen with the unwrapped key: %v", err)
|
||||
}
|
||||
defer st2.Close()
|
||||
notes, err := st2.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 {
|
||||
t.Fatalf("got %d notes, want 1", len(notes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// pendingHexisExec — a mutating Hexis capability parked awaiting a spoken
|
||||
// confirm. The confirmation is bound to the resolved capability + canonical
|
||||
// target entity so a later "да" can only execute exactly what was proposed
|
||||
// (ecosystem invariant: protected actions require bound confirmation).
|
||||
type pendingHexisExec struct {
|
||||
capabilityID string
|
||||
capName string
|
||||
entityID string
|
||||
displayName string
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
|
||||
// a recurring reminder. Set by detectPattern after creating a proposal.
|
||||
type pendingRoutineConfirm struct {
|
||||
routineID int64
|
||||
action string
|
||||
object string
|
||||
interval float64
|
||||
phrase string
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// pendingAct — a destructive act awaiting a spoken confirm.
|
||||
type pendingAct struct {
|
||||
fn string
|
||||
args []string
|
||||
phrase string
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// confirmTTL — how long a parked destructive confirm stays answerable. Short:
|
||||
// a confirm is a same-breath gesture; a stale prompt shouldn't fire on an
|
||||
// unrelated later "да".
|
||||
const confirmTTL = 90 * time.Second
|
||||
|
||||
// park stores a destructive act awaiting confirmation. Overwrites any prior
|
||||
// pending (last-asked wins — single-user box).
|
||||
func (h *reactiveHandler) park(fn string, args []string, phrase string) {
|
||||
h.mu.Lock()
|
||||
h.pending = &pendingAct{fn: fn, args: args, phrase: phrase, expiry: h.now().Add(confirmTTL)}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// resolveConfirm interprets an utterance as the answer to a parked destructive
|
||||
// act OR a parked routine proposal. Returns (reply, true) when it consumed the
|
||||
// utterance as a y/n answer; ("", false) when there's nothing pending (or the
|
||||
// parked act expired), so the caller routes the utterance normally. An
|
||||
// unrecognised answer cancels the pending and routes normally — a confirm that
|
||||
// can't be answered clearly is safer abandoned than left armed.
|
||||
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
for _, r := range h.confirmResolvers(ctx) {
|
||||
if !r.claim() {
|
||||
continue
|
||||
}
|
||||
// The slot is already cleared by claim(): every branch below drops the
|
||||
// pending, including the unclear one — a confirm that can't be
|
||||
// answered clearly is safer abandoned than left armed.
|
||||
switch classifyConfirm(text) {
|
||||
case confirmYes:
|
||||
return r.yes(), true
|
||||
case confirmNo:
|
||||
return r.no(), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// confirmResolver — one parked-confirm slot in the chain. claim() reports
|
||||
// whether this slot holds a live pending, taking it (and dropping an expired
|
||||
// one) as it goes; yes/no then run the answer. Only ever called with h.mu held.
|
||||
type confirmResolver struct {
|
||||
claim func() bool
|
||||
yes func() string
|
||||
no func() string
|
||||
}
|
||||
|
||||
// confirmResolvers builds the ordered chain resolveConfirm walks. Order is
|
||||
// deliberate: the routine proposal is checked before the tool confirm so a
|
||||
// routine confirm doesn't get eaten by a stale tool pending.
|
||||
func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolver {
|
||||
var pr *pendingRoutineConfirm
|
||||
var hx *pendingHexisExec
|
||||
var p *pendingAct
|
||||
|
||||
return []confirmResolver{
|
||||
// Routine proposal.
|
||||
{
|
||||
claim: func() bool {
|
||||
pr, h.pendingRoutine = h.pendingRoutine, nil
|
||||
return pr != nil && !h.now().After(pr.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
// 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 "не получилось запомнить рутину."
|
||||
}
|
||||
return "буду напоминать."
|
||||
},
|
||||
no: func() string {
|
||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||||
log.Printf("voice: dismiss proposed routine: %v", err)
|
||||
}
|
||||
return "хорошо, не буду."
|
||||
},
|
||||
},
|
||||
// Hexis execution confirm. Bound to the exact capability + target that
|
||||
// was proposed; a stray "да" can only run that, nothing else.
|
||||
{
|
||||
claim: func() bool {
|
||||
hx, h.pendingHexis = h.pendingHexis, nil
|
||||
return hx != nil && !h.now().After(hx.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
},
|
||||
no: func() string { return "отменила." },
|
||||
},
|
||||
// Tool confirm.
|
||||
{
|
||||
claim: func() bool {
|
||||
p, h.pending = h.pending, nil
|
||||
return p != nil && !h.now().After(p.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
out, err := h.tools.Exec(ctx, p.fn, p.args, true) // confirmed
|
||||
if err != nil {
|
||||
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
||||
if out != "" {
|
||||
return "не получилось выполнить команду: " + firstLine(out)
|
||||
}
|
||||
return "не получилось выполнить команду."
|
||||
}
|
||||
if out != "" {
|
||||
return "готово: " + firstLine(out)
|
||||
}
|
||||
return "готово."
|
||||
},
|
||||
no: func() string { return "отменила." },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// proposeGap scaffolds a 'proposed' tool for an act whose verb isn't enabled.
|
||||
// maven drafts the registration (name = the verb, provenance = the utterance);
|
||||
// a human enables it on the authed surface. She suggests, never enables.
|
||||
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
|
||||
name := firstWord(stripWake(dec.Utterance))
|
||||
if name == "" {
|
||||
return "не разобрала команду — попробуй иначе."
|
||||
}
|
||||
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
|
||||
if err != nil {
|
||||
log.Printf("voice: propose tool %q: %v", name, err)
|
||||
return "команды «" + name + "» нет в списке разрешённых."
|
||||
}
|
||||
if newly {
|
||||
return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент."
|
||||
}
|
||||
return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент."
|
||||
}
|
||||
|
||||
// confirmVerdict — the parse of a y/n confirm answer.
|
||||
type confirmVerdict int
|
||||
|
||||
const (
|
||||
confirmUnknown confirmVerdict = iota
|
||||
confirmYes
|
||||
confirmNo
|
||||
)
|
||||
|
||||
// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the
|
||||
// stems so inflections/fillers ("да, давай", "нет, отмени") still land.
|
||||
func classifyConfirm(text string) confirmVerdict {
|
||||
t := strings.ToLower(strings.TrimSpace(text))
|
||||
// negatives first — "не надо" contains no "да", but check no-stems before
|
||||
// yes so a leading "нет" isn't shadowed.
|
||||
for _, no := range []string{"нет", "не надо", "отмен", "стоп", "no", "cancel", "stop", "don't"} {
|
||||
if strings.Contains(t, no) {
|
||||
return confirmNo
|
||||
}
|
||||
}
|
||||
for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} {
|
||||
if strings.Contains(t, yes) {
|
||||
return confirmYes
|
||||
}
|
||||
}
|
||||
return confirmUnknown
|
||||
}
|
||||
|
||||
// actPhrase renders "fn arg1 arg2" for the confirm prompt.
|
||||
func actPhrase(fn string, args []string) string {
|
||||
if len(args) == 0 {
|
||||
return fn
|
||||
}
|
||||
return fn + " " + strings.Join(args, " ")
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// mavend/crawls.go — the driver for reading web pages (Vikunja #259,
|
||||
// docs/plans/14-web-crawler.md). The crawler is pure and lives in
|
||||
// internal/crawl; this is the impure half: the guarded fetcher, a ticker for the
|
||||
// scheduled watches, and the fact-backed dedup hashes.
|
||||
//
|
||||
// Two paths, one config block, both off unless configured:
|
||||
//
|
||||
// - ON DEMAND — he names a URL out loud and she reads it. That is the
|
||||
// `queryWeb` source in actions_query.go, LAST in the chain: after his
|
||||
// memory, after the notes, and (once Kiwix is wired into the chain) after
|
||||
// the local ZIMs. A local read costs nothing and leaks nothing; a fetch puts
|
||||
// a URL in someone's log, so it goes last.
|
||||
// - SCHEDULED — a watched page is re-read on its interval, and a page whose
|
||||
// text changed is written as a note. It does NOT announce itself. Same rule
|
||||
// as the feed poller: notes, never nudges.
|
||||
//
|
||||
// Only the URL goes out. Nothing here reads a note, a fact, the persona block or
|
||||
// the history, and internal/crawl has no access to the store at all.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// newCrawler builds the crawler from the `crawl` block, or returns nil when
|
||||
// there is none. Every caller checks for nil, and nil means no page is ever
|
||||
// fetched.
|
||||
func newCrawler(cfg *config.Config) *crawl.Crawler {
|
||||
if cfg.Crawl == nil {
|
||||
return nil
|
||||
}
|
||||
cc := cfg.Crawl
|
||||
// The WATCH crawler, and only it, reaches the watched hosts. webfetch reads
|
||||
// a non-empty allow list as "these and nothing else", so folding the watch
|
||||
// hosts in turned a single watch into an allowlist for everything: a config
|
||||
// with one watch and on_demand true silently refused every other page he
|
||||
// pasted, with "не получилось прочитать страницу." and no clue why.
|
||||
return crawlerWithHosts(cc, crawlHosts(cc, true))
|
||||
}
|
||||
|
||||
// crawlHosts — the allowlist for one of the two crawlers. forWatches adds the
|
||||
// watched pages' own hosts, so a watch does not have to be allowlisted by hand.
|
||||
//
|
||||
// The on-demand crawler gets his allow_hosts and nothing else. webfetch reads a
|
||||
// non-empty list as "these and nothing else", so adding the watch hosts there
|
||||
// would silently narrow on-demand reading to the watched sites.
|
||||
func crawlHosts(cc *config.CrawlConfig, forWatches bool) []string {
|
||||
hosts := append([]string(nil), cc.AllowHosts...)
|
||||
if !forWatches {
|
||||
return hosts
|
||||
}
|
||||
for _, w := range cc.Watches {
|
||||
if u, err := url.Parse(w.URL); err == nil && u.Hostname() != "" {
|
||||
hosts = append(hosts, u.Hostname())
|
||||
}
|
||||
}
|
||||
return hosts
|
||||
}
|
||||
|
||||
// crawlerWithHosts builds a crawler over one allowlist. Two callers, two lists:
|
||||
// see newCrawler and onDemandCrawler.
|
||||
func crawlerWithHosts(cc *config.CrawlConfig, hosts []string) *crawl.Crawler {
|
||||
ua := cc.UserAgent
|
||||
if ua == "" {
|
||||
ua = webfetch.DefaultUserAgent
|
||||
}
|
||||
fetcher := webfetch.New(webfetch.Config{
|
||||
AllowHosts: hosts,
|
||||
DenyHosts: cc.DenyHosts,
|
||||
Timeout: time.Duration(cc.Timeout),
|
||||
MaxBytes: cc.MaxBytes,
|
||||
UserAgent: ua,
|
||||
})
|
||||
// The user-agent handed to the crawler is the one the fetcher sends: obeying
|
||||
// robots rules written for a different name would be a lie.
|
||||
return crawl.New(&crawlFetcher{f: fetcher}, crawl.Config{
|
||||
UserAgent: ua,
|
||||
MaxRunes: cc.MaxRunes,
|
||||
})
|
||||
}
|
||||
|
||||
// onDemandCrawler returns a crawler for the answer path, or nil when on-demand
|
||||
// reading is off. The scheduled watches can be on while this is off: reading a
|
||||
// fixed list of pages on a timer and reading whatever URL is in an utterance are
|
||||
// different permissions, and the config keeps them separate.
|
||||
func onDemandCrawler(cfg *config.Config) *crawl.Crawler {
|
||||
if cfg.Crawl == nil || !cfg.Crawl.OnDemand {
|
||||
return nil
|
||||
}
|
||||
cc := cfg.Crawl
|
||||
// His own allow_hosts, and nothing added behind his back. Empty means "any
|
||||
// host that is not denied and not private", which is what on-demand reading
|
||||
// of a URL he just said out loud has to mean.
|
||||
if len(cc.AllowHosts) > 0 {
|
||||
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those %d host(s)", len(cc.AllowHosts))
|
||||
}
|
||||
return crawlerWithHosts(cc, crawlHosts(cc, false))
|
||||
}
|
||||
|
||||
// crawlWorker — ticker + watcher for the scheduled half.
|
||||
type crawlWorker struct {
|
||||
watcher *crawl.Watcher
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// crawlTickInterval — how often the worker asks what is due. Per-watch cadence
|
||||
// is the watcher's business.
|
||||
const crawlTickInterval = 15 * time.Minute
|
||||
|
||||
// newCrawlWorker wires the scheduled crawls, or nil when nothing is watched.
|
||||
func newCrawlWorker(c *crawl.Crawler, api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *crawlWorker {
|
||||
if c == nil || cfg.Crawl == nil || len(cfg.Crawl.Watches) == 0 {
|
||||
return nil
|
||||
}
|
||||
watches := make([]crawl.WatchConfig, 0, len(cfg.Crawl.Watches))
|
||||
for _, w := range cfg.Crawl.Watches {
|
||||
watches = append(watches, crawl.WatchConfig{
|
||||
Name: w.Name,
|
||||
URL: w.URL,
|
||||
Interval: time.Duration(w.Interval),
|
||||
})
|
||||
}
|
||||
watcher := crawl.NewWatcher(c, watches, api, &factHashes{api: api},
|
||||
crawlEmbedder(emb), time.Duration(cfg.Crawl.Interval))
|
||||
if watcher == nil {
|
||||
log.Printf("crawl: configured but nothing watchable — scheduled crawls disabled")
|
||||
return nil
|
||||
}
|
||||
log.Printf("crawl: watching %d page(s), checking what is due every %s", len(watches), crawlTickInterval)
|
||||
return &crawlWorker{watcher: watcher, interval: crawlTickInterval}
|
||||
}
|
||||
|
||||
// run checks what is due until ctx is canceled. The first round runs
|
||||
// immediately; it writes notes only, so an early round startles nobody.
|
||||
func (w *crawlWorker) run(ctx context.Context) {
|
||||
w.watcher.CheckDue(ctx, time.Now())
|
||||
t := time.NewTicker(w.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
w.watcher.CheckDue(ctx, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// crawlFetcher adapts webfetch to crawl.Fetcher, which is the seam that keeps
|
||||
// net/http out of the crawler package.
|
||||
type crawlFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
// Get maps webfetch's sentinels onto crawl's. This adapter is the one place
|
||||
// that imports both packages, so the mapping belongs here; the crawler used to
|
||||
// match on three substrings of a message it could not see the definition of,
|
||||
// and a reworded error would have quietly turned a blocked host into "there is
|
||||
// no robots.txt here".
|
||||
func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, error) {
|
||||
resp, err := a.f.Get(ctx, u)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
|
||||
case errors.Is(err, webfetch.ErrStatus):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &crawl.Response{URL: resp.URL, ContentType: resp.ContentType, Body: resp.Body}, nil
|
||||
}
|
||||
|
||||
// factHashes stores each watch's last content hash as a config fact, so a
|
||||
// restart does not re-note an unchanged page. Same mechanism the feed reader
|
||||
// uses for its marks, and inspectable on /dash.
|
||||
type factHashes struct{ api ipc.CoreAPI }
|
||||
|
||||
func hashKey(name string) string { return "crawl:hash:" + name }
|
||||
|
||||
func (h *factHashes) LastHash(ctx context.Context, name string) (string, error) {
|
||||
f, err := h.api.LatestFact(ctx, hashKey(name))
|
||||
if err != nil {
|
||||
// No hash yet is not an error: the watcher treats "" as "never read".
|
||||
return "", nil
|
||||
}
|
||||
return f.Value, nil
|
||||
}
|
||||
|
||||
func (h *factHashes) SetHash(ctx context.Context, name, hash string) error {
|
||||
_, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: time.Now(),
|
||||
Kind: "config",
|
||||
Key: hashKey(name),
|
||||
Value: hash,
|
||||
Source: "poll:crawl",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// crawlEmbedder adapts router.Embedder for the watcher, embedding with
|
||||
// EmbedPassage (a page is text being searched FOR, and the e5 embedder is
|
||||
// asymmetric).
|
||||
func crawlEmbedder(emb router.Embedder) crawl.Embedder {
|
||||
if emb == nil {
|
||||
return nil
|
||||
}
|
||||
return passageEmbedder{emb}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// The default config reads nothing. This is the whole "off unless configured"
|
||||
// contract for the crawler, asserted at the wiring level rather than trusted.
|
||||
func TestCrawlOffByDefault(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
if c := newCrawler(cfg); c != nil {
|
||||
t.Error("newCrawler with no crawl block returned a crawler")
|
||||
}
|
||||
if c := onDemandCrawler(cfg); c != nil {
|
||||
t.Error("onDemandCrawler with no crawl block returned a crawler")
|
||||
}
|
||||
if w := newCrawlWorker(nil, nil, nil, cfg); w != nil {
|
||||
t.Error("newCrawlWorker with no crawl block returned a worker")
|
||||
}
|
||||
// Watches configured but on_demand off ⇒ the answer path still reads
|
||||
// nothing: a timer over a fixed list is not permission for arbitrary URLs.
|
||||
withWatch := &config.Config{Crawl: &config.CrawlConfig{
|
||||
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://example.org/p"}},
|
||||
}}
|
||||
if c := onDemandCrawler(withWatch); c != nil {
|
||||
t.Error("onDemandCrawler honoured a watch list as on-demand permission")
|
||||
}
|
||||
if c := newCrawler(withWatch); c == nil {
|
||||
t.Error("newCrawler returned nil for a configured watch")
|
||||
}
|
||||
}
|
||||
|
||||
// The wired fetcher must refuse a private address, because the crawler on this
|
||||
// box sits one hop from the whole homelab. Same guard the webfetch tests cover;
|
||||
// this asserts the daemon actually wires it.
|
||||
func TestCrawlerRefusesPrivateAddress(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte("<html><body>secret</body></html>"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newCrawler(&config.Config{Crawl: &config.CrawlConfig{OnDemand: true}})
|
||||
if c == nil {
|
||||
t.Fatal("newCrawler returned nil for an on-demand config")
|
||||
}
|
||||
if _, err := c.Page(context.Background(), srv.URL); err == nil {
|
||||
t.Fatalf("reading %s succeeded; a loopback address must be refused", srv.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactHashesRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
h := &factHashes{api: ipc.NewStoreAPI(st)}
|
||||
|
||||
got, err := h.LastHash(ctx, "page")
|
||||
if err != nil {
|
||||
t.Fatalf("LastHash on a fresh store: %v", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("LastHash = %q, want empty for a never-read page", got)
|
||||
}
|
||||
if err := h.SetHash(ctx, "page", "deadbeef"); err != nil {
|
||||
t.Fatalf("SetHash: %v", err)
|
||||
}
|
||||
got, err = h.LastHash(ctx, "page")
|
||||
if err != nil {
|
||||
t.Fatalf("LastHash: %v", err)
|
||||
}
|
||||
if got != "deadbeef" {
|
||||
t.Errorf("LastHash = %q, want deadbeef", got)
|
||||
}
|
||||
if key := hashKey("page"); key != "crawl:hash:page" {
|
||||
t.Errorf("hashKey = %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
// stubCrawlFetcher serves one fixed page to every URL, so queryWeb can be
|
||||
// exercised without a network or an allowlist.
|
||||
type stubCrawlFetcher struct{ body, ctype string }
|
||||
|
||||
func (s *stubCrawlFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||
ct := s.ctype
|
||||
if ct == "" {
|
||||
ct = "text/html"
|
||||
}
|
||||
if strings.HasSuffix(u, "/robots.txt") {
|
||||
return &crawl.Response{URL: u, ContentType: "text/plain", Body: []byte("")}, nil
|
||||
}
|
||||
return &crawl.Response{URL: u, ContentType: ct, Body: []byte(s.body)}, nil
|
||||
}
|
||||
|
||||
func buildWebHandler(c *crawl.Crawler) *reactiveHandler {
|
||||
return &reactiveHandler{
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
crawler: c,
|
||||
}
|
||||
}
|
||||
|
||||
func askWeb(h *reactiveHandler, q string) (string, bool) {
|
||||
return h.queryWeb(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&stubCrawlFetcher{body: "<html><body>x</body></html>"}, crawl.Config{}))
|
||||
if reply, ok := askWeb(h, "почему небо синее?"); ok {
|
||||
t.Errorf("the web source claimed a question with no URL: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A daemon where page reading was never turned on — the default — answers the
|
||||
// question the way it did before the capability existed. Claiming the turn to
|
||||
// report a configuration status is for something that exists and failed.
|
||||
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
||||
h := buildWebHandler(nil)
|
||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryWebReadsThePage(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||
body: "<html><head><title>Заголовок</title></head><body><p>текст страницы</p></body></html>",
|
||||
}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/page — что там?")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "текст страницы") {
|
||||
t.Errorf("reply = %q, want the page text read back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
|
||||
body: "\x00\x01binary", ctype: "application/octet-stream",
|
||||
}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "почитай https://example.org/blob.bin")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// robots.txt is honoured on the answer path too, and she says so instead of
|
||||
// reporting a generic failure.
|
||||
func TestQueryWebObeysRobots(t *testing.T) {
|
||||
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "robots.txt") {
|
||||
t.Errorf("reply = %q, want the robots answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
type robotsDenyFetcher struct{}
|
||||
|
||||
func (robotsDenyFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
|
||||
if strings.HasSuffix(u, "/robots.txt") {
|
||||
return &crawl.Response{URL: u, ContentType: "text/plain",
|
||||
Body: []byte("User-agent: *\nDisallow: /private\n")}, nil
|
||||
}
|
||||
return &crawl.Response{URL: u, ContentType: "text/html", Body: []byte("<html>nope</html>")}, nil
|
||||
}
|
||||
|
||||
// TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist — the on-demand crawler
|
||||
// used to be built over allow_hosts PLUS every watched host. webfetch reads a
|
||||
// non-empty allow list as "these and nothing else", so one watch on a config
|
||||
// with no allow_hosts at all turned unrestricted on-demand reading into
|
||||
// "the watched site only", and every other URL he pasted came back as
|
||||
// "не получилось прочитать страницу." with nothing in the log to explain it.
|
||||
func TestCrawlHostsKeepsAWatchOutOfTheOnDemandAllowlist(t *testing.T) {
|
||||
cc := &config.CrawlConfig{
|
||||
OnDemand: true,
|
||||
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://watched.example/p"}},
|
||||
}
|
||||
if got := crawlHosts(cc, false); len(got) != 0 {
|
||||
t.Errorf("on-demand allowlist = %v; a watch is not an allowlist entry, and an empty list is what means \"anything public\"", got)
|
||||
}
|
||||
if got := crawlHosts(cc, true); len(got) != 1 || got[0] != "watched.example" {
|
||||
t.Errorf("watch allowlist = %v; want the watched host so a watch needs no hand-written entry", got)
|
||||
}
|
||||
|
||||
// With allow_hosts set, his list is what on-demand gets, unchanged.
|
||||
cc.AllowHosts = []string{"wiki.example"}
|
||||
on := crawlHosts(cc, false)
|
||||
if len(on) != 1 || on[0] != "wiki.example" {
|
||||
t.Errorf("on-demand allowlist = %v; want exactly his allow_hosts", on)
|
||||
}
|
||||
if got := crawlHosts(cc, true); len(got) != 2 {
|
||||
t.Errorf("watch allowlist = %v; want his hosts plus the watched one", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrawlFetcherReportsARefusalAsARefusal — internal/crawl cannot import
|
||||
// webfetch, so it used to recognise a guard refusal by matching substrings of
|
||||
// webfetch's message text. This adapter owns both packages and is where the
|
||||
// translation belongs.
|
||||
func TestCrawlFetcherReportsARefusalAsARefusal(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusBadGateway)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
blocked := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"wiki.example"}})}
|
||||
if _, err := blocked.Get(context.Background(), "https://other.example/a"); !errors.Is(err, crawl.ErrFetchRefused) {
|
||||
t.Errorf("a host outside allow_hosts = %v; want crawl.ErrFetchRefused", err)
|
||||
}
|
||||
if _, err := blocked.Get(context.Background(), "file:///etc/passwd"); !errors.Is(err, crawl.ErrFetchRefused) {
|
||||
t.Errorf("a non-http scheme = %v; want crawl.ErrFetchRefused", err)
|
||||
}
|
||||
|
||||
// A 5xx is a different thing: the server answered, badly. robots.txt over
|
||||
// this must refuse the crawl rather than read it as "no rules".
|
||||
open := &crawlFetcher{f: webfetch.New(webfetch.Config{AllowHosts: []string{"127.0.0.1"}, AllowPrivate: true})}
|
||||
if _, err := open.Get(context.Background(), srv.URL+"/robots.txt"); !errors.Is(err, crawl.ErrFetchStatus) {
|
||||
t.Errorf("a 502 = %v; want crawl.ErrFetchStatus", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// planAPI answers only DayPlan; every other call is unimplemented, which is
|
||||
// exactly the assertion that the plan source needs nothing else.
|
||||
type planAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
plan ipc.DayPlan
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *planAPI) DayPlan(context.Context) (ipc.DayPlan, error) {
|
||||
a.calls++
|
||||
if a.err != nil {
|
||||
return ipc.DayPlan{}, a.err
|
||||
}
|
||||
return a.plan, nil
|
||||
}
|
||||
|
||||
func planDay() time.Time { return time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) }
|
||||
|
||||
func samplePlan() ipc.DayPlan {
|
||||
day := planDay()
|
||||
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
||||
return ipc.DayPlan{
|
||||
Date: mid,
|
||||
Items: []ipc.DayPlanItem{
|
||||
{At: day.Add(-2 * time.Hour), Text: "Standup @ 10:00-10:30", Kind: "event"},
|
||||
{At: day.Add(2 * time.Hour), Text: "Планёрка @ 14:00-14:30", Kind: "event", Uncertain: true},
|
||||
{At: day.Add(6 * time.Hour), Text: "позвонить маме", Kind: "reminder"},
|
||||
},
|
||||
Spoken: "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||
"похоже, 14:00 — Планёрка @ 14:00-14:30; 18:00 — позвонить маме.",
|
||||
}
|
||||
}
|
||||
|
||||
func planHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||
return &reactiveHandler{api: api, now: planDay}
|
||||
}
|
||||
|
||||
func TestQueryDayPlanRecitesTheDay(t *testing.T) {
|
||||
api := &planAPI{plan: samplePlan()}
|
||||
h := planHandler(api)
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие планы на сегодня?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the plan source must claim a plan question")
|
||||
}
|
||||
if reply != api.plan.Spoken {
|
||||
t.Errorf("reply = %q, want the core's spoken plan %q", reply, api.plan.Spoken)
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" is the rest of the day, not the whole day: what has already
|
||||
// happened is not a plan.
|
||||
func TestQueryDayPlanTrimsToRestOfDay(t *testing.T) {
|
||||
h := planHandler(&planAPI{plan: samplePlan()})
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if strings.Contains(reply, "Standup") {
|
||||
t.Errorf("a passed item must not be read back: %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "Планёрка") || !strings.Contains(reply, "позвонить маме") {
|
||||
t.Errorf("the rest of the day is missing: %q", reply)
|
||||
}
|
||||
// Provenance survives the trim.
|
||||
if !strings.Contains(reply, "похоже,") {
|
||||
t.Errorf("a relayed event must stay hedged: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" after the last item of the day. The day was not empty, it is
|
||||
// over, and the whole-day empty line says something false about a day he just
|
||||
// lived through.
|
||||
func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
plan := samplePlan()
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 23, 0, 0, 0, time.UTC)
|
||||
}}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if strings.Contains(reply, plan.Date.Format("02.01.2006")) {
|
||||
t.Errorf("the day had things on it and they are done, not empty: %q", reply)
|
||||
}
|
||||
if reply != "на сегодня больше ничего не запланировано." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A question that is not about the plan must fall through, or the plan buries
|
||||
// the calendar listing and the weather behind it.
|
||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||
for _, q := range []string{
|
||||
"что у меня сегодня?",
|
||||
"какие планы на завтра?",
|
||||
// The plan can only be built for the clock's own day. Naming another
|
||||
// one has to fall through, not get answered with today.
|
||||
"какие планы на понедельник?",
|
||||
"какие планы на неделю?",
|
||||
"какие планы на выходные?",
|
||||
"what are my plans for friday?",
|
||||
"когда планёрка?",
|
||||
"какая погода?",
|
||||
"",
|
||||
} {
|
||||
api := &planAPI{plan: samplePlan()}
|
||||
reply, ok := planHandler(api).queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
if ok {
|
||||
t.Errorf("%q was claimed by the plan source (reply %q)", q, reply)
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Errorf("%q hit the core for a plan it does not want", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryDayPlanCoreFailure(t *testing.T) {
|
||||
h := planHandler(&planAPI{err: errors.New("socket closed")})
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "план на сегодня"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||
}
|
||||
if reply != "не получилось собрать план." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The day plan must sit before the calendar listing: both match "…на сегодня",
|
||||
// and the more specific matcher has to get first refusal (see #373 for what
|
||||
// happens when the order is wrong).
|
||||
func TestDayPlanSourcePrecedesCalendar(t *testing.T) {
|
||||
plan, cal := -1, -1
|
||||
for i, s := range querySources {
|
||||
switch s.name {
|
||||
case "day-plan":
|
||||
plan = i
|
||||
case "calendar":
|
||||
cal = i
|
||||
}
|
||||
}
|
||||
if plan < 0 || cal < 0 {
|
||||
t.Fatalf("sources missing: day-plan=%d calendar=%d", plan, cal)
|
||||
}
|
||||
if plan > cal {
|
||||
t.Errorf("day-plan at %d must come before calendar at %d", plan, cal)
|
||||
}
|
||||
}
|
||||
|
||||
// habitAPI answers only the kind-filtered fact read — the whole input the
|
||||
// behaviour profile needs (Vikunja #254). Nothing is asked of the LLM, so
|
||||
// nothing else is wired. RecentFacts is left unimplemented on purpose: the
|
||||
// profile must not read the mixed window, and a caller that does fails here.
|
||||
type habitAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts []ipc.Fact
|
||||
err error
|
||||
calls int
|
||||
kind string
|
||||
}
|
||||
|
||||
func (a *habitAPI) RecentActiveFactsByKind(_ context.Context, kind string, _ int) ([]ipc.Fact, error) {
|
||||
a.calls++
|
||||
a.kind = kind
|
||||
return a.facts, a.err
|
||||
}
|
||||
|
||||
// tuesdayFacts — n weekly Tuesday rows for key, ending before now.
|
||||
func tuesdayFacts(key string, hh, weeks int, now time.Time) []ipc.Fact {
|
||||
d := now
|
||||
for d.Weekday() != time.Tuesday {
|
||||
d = d.AddDate(0, 0, -1)
|
||||
}
|
||||
var out []ipc.Fact
|
||||
for i := 0; i < weeks; i++ {
|
||||
day := d.AddDate(0, 0, -7*i)
|
||||
out = append(out, ipc.Fact{
|
||||
Ts: time.Date(day.Year(), day.Month(), day.Day(), hh, 0, 0, 0, now.Location()),
|
||||
Kind: "self",
|
||||
Key: key,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestQueryHabitsAnswersFromCountedFacts(t *testing.T) {
|
||||
now := planDay() // a Monday
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("the habit source must claim a habit question")
|
||||
}
|
||||
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryHabitsPassesOnEverythingElse(t *testing.T) {
|
||||
now := planDay()
|
||||
for _, q := range []string{"что я делаю в среду?", "что у меня сегодня?", "какие планы на сегодня?", ""} {
|
||||
api := &habitAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
if reply, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
}); ok {
|
||||
t.Errorf("%q was claimed by the habit source (reply %q)", q, reply)
|
||||
}
|
||||
if api.calls != 0 {
|
||||
t.Errorf("%q scanned the fact log for a profile it does not want", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both specific sources must precede the calendar listing, which matches any
|
||||
// utterance naming a day.
|
||||
func TestHabitSourcePrecedesCalendar(t *testing.T) {
|
||||
habits, cal := -1, -1
|
||||
for i, s := range querySources {
|
||||
switch s.name {
|
||||
case "habits":
|
||||
habits = i
|
||||
case "calendar":
|
||||
cal = i
|
||||
}
|
||||
}
|
||||
if habits < 0 || cal < 0 {
|
||||
t.Fatalf("sources missing: habits=%d calendar=%d", habits, cal)
|
||||
}
|
||||
if habits > cal {
|
||||
t.Errorf("habits at %d must come before calendar at %d", habits, cal)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryHabitsReadsSelfFactsOnly — the profile window is a budget over rows,
|
||||
// so it must be spent on the rows the profile can use. Reading the mixed table
|
||||
// let one chatty poller (wg_handshake, roughly every two minutes per peer) push
|
||||
// every tap out of the window, and she then reported no habits on a store that
|
||||
// held them.
|
||||
func TestQueryHabitsReadsSelfFactsOnly(t *testing.T) {
|
||||
now := planDay()
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
if _, ok := h.queryHabits(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что я обычно делаю по вторникам?"},
|
||||
}); !ok {
|
||||
t.Fatal("the habit source must claim a habit question")
|
||||
}
|
||||
if api.kind != string(store.KindSelf) {
|
||||
t.Errorf("profile read kind %q, want %q", api.kind, store.KindSelf)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHabitQueryWithPlanWordReachesHabits — the whole chain, not just the
|
||||
// matchers: a habit question carrying "планы" used to be answered by the day
|
||||
// plan with today's calendar, because day-plan sits above habits.
|
||||
func TestHabitQueryWithPlanWordReachesHabits(t *testing.T) {
|
||||
now := planDay()
|
||||
api := &habitAPI{facts: tuesdayFacts("workout", 19, 4, now)}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return now }}
|
||||
|
||||
reply := h.actionQuery(context.Background(), router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие у меня обычно планы по вторникам?",
|
||||
})
|
||||
if want := "по вторникам ты обычно тренируешься около 19:00."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
}
|
||||
|
||||
// The plan reads the store on the owner's clock: one line per event, the hour
|
||||
// printed once, and reminders selected by fire time rather than by how
|
||||
// recently they were stated.
|
||||
func TestTickDayPlanReadsTheStore(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
|
||||
now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.Local)
|
||||
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.Local)
|
||||
ev := calendar.Event{
|
||||
Summary: "Standup",
|
||||
Start: day.Add(14 * time.Hour),
|
||||
End: day.Add(14*time.Hour + 30*time.Minute),
|
||||
}
|
||||
// Rescheduled: same key, a second row.
|
||||
if _, err := st.WriteFact(ctx, ev.Start, store.KindEnv, calendar.FactKey(ev),
|
||||
calendar.FactValue(ev), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
moved := ev
|
||||
moved.Start, moved.End = day.Add(16*time.Hour), day.Add(16*time.Hour+30*time.Minute)
|
||||
if _, err := st.WriteFact(ctx, moved.Start, store.KindEnv, calendar.FactKey(moved),
|
||||
calendar.FactValue(moved), calendar.SourcePersonal, 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
// One reminder today, one next year. Both are pending; only today's is a
|
||||
// plan for today.
|
||||
if _, err := st.CreateReminder(ctx, day.Add(18*time.Hour), "позвонить маме", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
if _, err := st.CreateReminder(ctx, day.AddDate(1, 0, 0), "продлить страховку", ""); err != nil {
|
||||
t.Fatalf("CreateReminder: %v", err)
|
||||
}
|
||||
|
||||
plan := tl.dayPlan(ctx, now)
|
||||
if len(plan.Items) != 2 {
|
||||
t.Fatalf("got %d items, want the moved standup and today's reminder: %+v", len(plan.Items), plan.Items)
|
||||
}
|
||||
ev0 := plan.Items[0]
|
||||
if ev0.Kind != "event" || ev0.At.In(time.Local).Format("15:04") != "16:00" {
|
||||
t.Errorf("event = %+v, want the 16:00 one", ev0)
|
||||
}
|
||||
if ev0.Text != "Standup" {
|
||||
t.Errorf("text = %q — the plan prints the hour itself", ev0.Text)
|
||||
}
|
||||
if plan.Items[1].Text != "позвонить маме" {
|
||||
t.Errorf("second item = %+v", plan.Items[1])
|
||||
}
|
||||
if strings.Contains(plan.Spoken, "страховку") {
|
||||
t.Errorf("a reminder for next year is not today's plan: %q", plan.Spoken)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Vikunja #281 — the fourth delivery outcome: a care candidate the restraint
|
||||
// gate suppresses (quiet hours / away / calendar-busy) is not necessarily
|
||||
// lost. If it's worth resurfacing (loop.DigestEligible), it's durably held
|
||||
// (internal/store's digest_entries) and spoken as one bundle once speaking
|
||||
// is appropriate again — never while the suppression reason still holds.
|
||||
|
||||
func breakTrace(blockedBy string) *loop.TickTrace {
|
||||
return &loop.TickTrace{
|
||||
RuleTraces: []loop.RuleTrace{{
|
||||
RuleName: "break",
|
||||
Severity: loop.Sev2,
|
||||
PredicateResult: true,
|
||||
GateResult: false,
|
||||
GateBlockedBy: blockedBy,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuppressedCareDigestsAcrossQuietHours — a Sev2 care candidate blocked
|
||||
// by quiet hours is enqueued into the durable digest, and is spoken as a
|
||||
// "digest" nudge only once quiet hours actually end — never while still
|
||||
// suppressed (that would just be a second way to nag through quiet hours).
|
||||
func TestSuppressedCareDigestsAcrossQuietHours(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
|
||||
entries, err := st.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Rule != "break" {
|
||||
t.Fatalf("want 1 pending digest entry for break, got %+v", entries)
|
||||
}
|
||||
|
||||
// still quiet hours: draining now must not speak — the same restraint
|
||||
// that suppressed the live nudge must suppress the bundle too.
|
||||
tl.maybeDrainDigest(ctx, quiet, now)
|
||||
if len(sink.sends) != 0 {
|
||||
t.Fatalf("digest must not drain while quiet hours holds, got %+v", sink.sends)
|
||||
}
|
||||
|
||||
// quiet hours end: this is the moment speaking is appropriate again.
|
||||
after := now.Add(time.Hour)
|
||||
clear := loop.State{Now: after, QuietHours: false, Presence: store.Present}
|
||||
tl.maybeDrainDigest(ctx, clear, after)
|
||||
|
||||
if len(sink.sends) != 1 {
|
||||
t.Fatalf("want exactly 1 dispatched digest bundle, got %d: %+v", len(sink.sends), sink.sends)
|
||||
}
|
||||
if sink.sends[0].RuleName != "digest" {
|
||||
t.Fatalf("want RuleName digest, got %q", sink.sends[0].RuleName)
|
||||
}
|
||||
|
||||
remaining, err := st.PendingDigestEntries(ctx, after)
|
||||
if err != nil {
|
||||
t.Fatalf("pending after drain: %v", err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("drained entry must no longer be pending, got %+v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuppressedCareDigestDedupesAcrossTicks — quiet hours holding for
|
||||
// several ticks must not enqueue several copies of the same suppressed
|
||||
// nudge; he hears it once when the bundle finally drains.
|
||||
func TestSuppressedCareDigestDedupesAcrossTicks(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
for i := 0; i < 3; i++ {
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now.Add(time.Duration(i)*time.Minute))
|
||||
}
|
||||
|
||||
entries, err := st.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("3 suppressions of the same nudge must collapse to 1 pending entry, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuppressedCareDigestExpiresRatherThanDeliveringLate — an entry that
|
||||
// aged out before the suppression cleared is dropped, not spoken late: a
|
||||
// two-day-old "you skipped a break" is noise, not news.
|
||||
func TestSuppressedCareDigestExpiresRatherThanDeliveringLate(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
|
||||
// well past digestExpiry (24h) before the suppression ever clears.
|
||||
stale := now.Add(48 * time.Hour)
|
||||
tl.expireStaleDigest(ctx, stale)
|
||||
|
||||
clear := loop.State{Now: stale, QuietHours: false, Presence: store.Present}
|
||||
tl.maybeDrainDigest(ctx, clear, stale)
|
||||
|
||||
if len(sink.sends) != 0 {
|
||||
t.Fatalf("a stale digest entry must be dropped, not delivered late; got %+v", sink.sends)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuppressedCareDigestIgnoresHighSeverity — defense in depth at the
|
||||
// wiring layer: even if a RuleTrace somehow showed a high-severity rule
|
||||
// blocked by a care-only gate reason, the tick driver must not durably
|
||||
// digest it. Alarms bypass the gate and deliver now, unchanged; they must
|
||||
// never be silently delayed into a bundle.
|
||||
func TestSuppressedCareDigestIgnoresHighSeverity(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
trace := &loop.TickTrace{RuleTraces: []loop.RuleTrace{{
|
||||
RuleName: "service_down",
|
||||
Severity: loop.Sev4,
|
||||
PredicateResult: true,
|
||||
GateResult: false,
|
||||
GateBlockedBy: "quiet_hours",
|
||||
}}}
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
tl.enqueueSuppressedDigest(ctx, trace, quiet, now)
|
||||
|
||||
entries, err := st.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("high severity must never be digested, got %+v", entries)
|
||||
}
|
||||
}
|
||||
+152
-57
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -31,18 +32,84 @@ func correlationIDFromCtx(ctx context.Context) string {
|
||||
return id
|
||||
}
|
||||
|
||||
// setEcosystemHeaders stamps the version and correlation headers common to
|
||||
// every outgoing ecosystem request.
|
||||
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader string) {
|
||||
// ecosystemAPIVersion is the contract version Maven speaks to Nexus and
|
||||
// Praxis. It is sent on every request so a service that has moved on can
|
||||
// refuse or adapt explicitly instead of misreading an older payload.
|
||||
const ecosystemAPIVersion = "v1"
|
||||
|
||||
// mavenRequester identifies the calling system on every ecosystem request, so
|
||||
// a trace on the far side can attribute a call to Maven rather than to an
|
||||
// anonymous HTTP client.
|
||||
const mavenRequester = "maven"
|
||||
|
||||
// setEcosystemHeaders stamps the version, requester, auth and correlation
|
||||
// headers common to every outgoing ecosystem request. token may be empty,
|
||||
// which means the transport itself is trusted (loopback or unix socket).
|
||||
//
|
||||
// The correlation ID is read from the context and never minted here. Minting
|
||||
// one per request sent the far side an ID that existed nowhere on this side,
|
||||
// and gave a single multi-hop action as many unrelated IDs as it made calls.
|
||||
// Callers that start an action assign the ID once (handleHexisAct,
|
||||
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
|
||||
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(versionHeader, "v1")
|
||||
req.Header.Set(versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Requested-By", mavenRequester)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
if id := correlationIDFromCtx(ctx); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
}
|
||||
|
||||
// ecosystemError is the typed failure every ecosystem client returns, so
|
||||
// callers can tell a transport failure from a refusal from a contract
|
||||
// mismatch without matching on message text. The distinction matters:
|
||||
// "the service is down" and "the service rejected my version" degrade the
|
||||
// same way to the user but not to whoever reads the trace.
|
||||
type ecosystemError struct {
|
||||
Service string // "nexus", "praxis", "hexis"
|
||||
Op string // logical operation, e.g. "resolve"
|
||||
Status int // HTTP status, 0 when the call never got an answer
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ecosystemError) Error() string {
|
||||
if e.Status != 0 {
|
||||
return fmt.Sprintf("%s %s: http %d: %v", e.Service, e.Op, e.Status, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s %s: %v", e.Service, e.Op, e.Err)
|
||||
}
|
||||
|
||||
func (e *ecosystemError) Unwrap() error { return e.Err }
|
||||
|
||||
// Unauthorized reports a rejected or missing credential.
|
||||
func (e *ecosystemError) Unauthorized() bool {
|
||||
return e.Status == http.StatusUnauthorized || e.Status == http.StatusForbidden
|
||||
}
|
||||
|
||||
// ContractMismatch reports that the far side refused the version Maven speaks.
|
||||
func (e *ecosystemError) ContractMismatch() bool {
|
||||
return e.Status == http.StatusNotAcceptable || e.Status == http.StatusUpgradeRequired
|
||||
}
|
||||
|
||||
// Unreachable reports a call that never produced an HTTP answer at all
|
||||
// (connection refused, timeout, cancelled).
|
||||
func (e *ecosystemError) Unreachable() bool { return e.Status == 0 }
|
||||
|
||||
// httpError builds an ecosystemError from a response status.
|
||||
func httpError(service, op string, status int) *ecosystemError {
|
||||
return &ecosystemError{
|
||||
Service: service, Op: op, Status: status,
|
||||
Err: errors.New(http.StatusText(status)),
|
||||
}
|
||||
}
|
||||
|
||||
type nexusClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
@@ -53,6 +120,13 @@ func newNexusClient(url string) *nexusClient {
|
||||
}
|
||||
}
|
||||
|
||||
// withToken sets the bearer token sent on every request. Returns the client so
|
||||
// wiring reads as one expression.
|
||||
func (c *nexusClient) withToken(token string) *nexusClient {
|
||||
c.token = token
|
||||
return c
|
||||
}
|
||||
|
||||
type nexusEntity struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
@@ -107,22 +181,22 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request: %w", err)
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("nexus: %s", http.StatusText(resp.StatusCode))
|
||||
return nil, httpError("nexus", "resolve", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result nexusResolveResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
@@ -130,16 +204,16 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
func (c *nexusClient) Health(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("nexus health: %s", http.StatusText(resp.StatusCode))
|
||||
return httpError("nexus", "health", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -149,6 +223,7 @@ func (c *nexusClient) Health(ctx context.Context) error {
|
||||
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
|
||||
type praxisClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
@@ -159,27 +234,38 @@ func newPraxisClient(url string) *praxisClient {
|
||||
}
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, path string, out any) error {
|
||||
func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
c.token = token
|
||||
return c
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out. op is the logical
|
||||
// operation name for errors and traces: the path carries the query string, and
|
||||
// after entity scoping that means an entity id in every log line built from the
|
||||
// error, next to a trace that redacts far less than that.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
return &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("praxis: %s", http.StatusText(resp.StatusCode))
|
||||
return httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -189,13 +275,14 @@ func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[stri
|
||||
// instead of filtering the unscoped list client-side.
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
||||
err := c.getJSON(ctx, "attention_for_entity",
|
||||
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListChanges(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
|
||||
err := c.getJSON(ctx, "changes", fmt.Sprintf("/api/v1/tools/changes?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -221,24 +308,32 @@ type praxisItem struct {
|
||||
|
||||
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
|
||||
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(map[string]any{"item_id": itemID})
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
|
||||
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
}
|
||||
|
||||
// postJSON posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
// Every failure is a *ecosystemError, including the transport and decode ones:
|
||||
// these are the paths that mutate remote state, and the question worth
|
||||
// answering afterwards is whether the call never left or was refused.
|
||||
func (c *praxisClient) postJSON(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("praxis %s: %s", path, http.StatusText(resp.StatusCode))
|
||||
return nil, httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -247,46 +342,28 @@ func (c *praxisClient) postItemAction(ctx context.Context, path, itemID string)
|
||||
// ECOSYSTEM-SPEC.md §2.3). Callers that read attention aloud must call this, never
|
||||
// Acknowledge, so "I mentioned it" stays distinguishable from "you told me you saw it".
|
||||
func (c *praxisClient) Surface(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/surface", itemID)
|
||||
return c.postItemAction(ctx, "surface", "/api/v1/tools/surface", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Acknowledge(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/acknowledge", itemID)
|
||||
return c.postItemAction(ctx, "acknowledge", "/api/v1/tools/acknowledge", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Resolve(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/resolve", itemID)
|
||||
return c.postItemAction(ctx, "resolve", "/api/v1/tools/resolve", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
return c.postItemAction(ctx, "/api/v1/tools/ignore", itemID)
|
||||
return c.postItemAction(ctx, "ignore", "/api/v1/tools/ignore", itemID)
|
||||
}
|
||||
|
||||
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/tools/pin", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("praxis pin: %s", http.StatusText(resp.StatusCode))
|
||||
}
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
return c.postJSON(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
}
|
||||
|
||||
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
var out praxisItem
|
||||
err := c.getJSON(ctx, "/api/v1/tools/items/"+itemID, &out)
|
||||
err := c.getJSON(ctx, "get_item", "/api/v1/tools/items/"+itemID, &out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -295,7 +372,7 @@ func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem,
|
||||
|
||||
func (c *praxisClient) Search(ctx context.Context, query string, limit int) ([]praxisItem, error) {
|
||||
var out []praxisItem
|
||||
err := c.getJSON(ctx, fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
|
||||
err := c.getJSON(ctx, "search", fmt.Sprintf("/api/v1/tools/search?q=%s&limit=%d", url.QueryEscape(query), limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -311,7 +388,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// Nexus identity service
|
||||
if cfg.Nexus != nil && cfg.Nexus.URL != "" {
|
||||
w.nexus = newNexusClient(cfg.Nexus.URL)
|
||||
w.nexus = newNexusClient(cfg.Nexus.URL).withToken(cfg.Nexus.Token)
|
||||
log.Printf("ecosystem: nexus at %s", cfg.Nexus.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: nexus not configured")
|
||||
@@ -319,7 +396,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// Hexis capability service
|
||||
if cfg.Hexis != nil && cfg.Hexis.URL != "" {
|
||||
w.hexis = hexisclient.New(cfg.Hexis.URL)
|
||||
w.hexis = hexisclient.New(cfg.Hexis.URL).WithToken(cfg.Hexis.Token)
|
||||
log.Printf("ecosystem: hexis at %s", cfg.Hexis.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: hexis not configured")
|
||||
@@ -327,7 +404,7 @@ func wireEcosystem(cfg *config.Config) *ecosystemWiring {
|
||||
|
||||
// Praxis attention service (HTTP tools API — never the DB directly)
|
||||
if cfg.Praxis != nil && cfg.Praxis.URL != "" {
|
||||
w.praxis = newPraxisClient(cfg.Praxis.URL)
|
||||
w.praxis = newPraxisClient(cfg.Praxis.URL).withToken(cfg.Praxis.Token)
|
||||
log.Printf("ecosystem: praxis at %s", cfg.Praxis.URL)
|
||||
} else {
|
||||
log.Printf("ecosystem: praxis not configured")
|
||||
@@ -352,7 +429,19 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
|
||||
log.Printf("ecosystem: nexus resolve error: %v", err)
|
||||
return "", "", nil, err
|
||||
}
|
||||
if result.Status == "resolved" && result.Entity != nil {
|
||||
if result.Status == "resolved" {
|
||||
// "resolved" with nothing to resolve to is a contract violation, not a
|
||||
// miss. Treating it as "no such entity" let the caller fall straight
|
||||
// through to the local executor with his verb intact, which is a
|
||||
// dependency failure reaching execution.
|
||||
if result.Entity == nil || result.Entity.ID == "" {
|
||||
err := &ecosystemError{
|
||||
Service: "nexus", Op: "resolve", Status: 200,
|
||||
Err: errors.New("resolved status with no entity"),
|
||||
}
|
||||
log.Printf("ecosystem: %v", err)
|
||||
return "", "", nil, err
|
||||
}
|
||||
return result.Entity.ID, result.Entity.DisplayName, nil, nil
|
||||
}
|
||||
if result.Status == "ambiguous" {
|
||||
@@ -372,6 +461,12 @@ func (w *ecosystemWiring) resolveEntityReference(ctx context.Context, text strin
|
||||
// healthy and genuinely has nothing registered for this entity. Callers must
|
||||
// not conflate the two: a dependency failure must not silently read as "no
|
||||
// capabilities" and fall through to unrelated local execution.
|
||||
//
|
||||
// The correlation header is stamped in the client's do(), so discovery and
|
||||
// execution can be joined on the Hexis side as long as both hops carry the
|
||||
// same ID through ctx. (This used to say the header went out on Execute only;
|
||||
// that was never true of the vendored code and is not true after the 2026-08-01
|
||||
// re-vendor.)
|
||||
func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID string) ([]hexisclient.Capability, error) {
|
||||
if w == nil || w.hexis == nil || entityID == "" {
|
||||
return nil, nil
|
||||
|
||||
@@ -0,0 +1,634 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
||||
// rather than a map[string]func because each arm carries its own state: the
|
||||
// verb aliases it answers to, the trace name it records, and its own reply
|
||||
// formatting. The dispatch grows an arm per Praxis capability, so a new one is
|
||||
// added to praxisCapabilities below and nothing else changes.
|
||||
type praxisCapability interface {
|
||||
// aliases are the verbs (router fn slots, EN and RU) this capability answers to.
|
||||
aliases() []string
|
||||
// handle runs the capability and returns the user-facing reply.
|
||||
handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string
|
||||
}
|
||||
|
||||
// praxisCapabilities is the registry handlePraxisAct consults, in order.
|
||||
var praxisCapabilities = []praxisCapability{
|
||||
listAttentionCapability{},
|
||||
praxisItemAction{
|
||||
verbs: []string{"acknowledge_item", "принято", "понял", "поняла"},
|
||||
ask: "какой пункт отметить принятым?",
|
||||
op: "acknowledge",
|
||||
failure: "не получилось отметить принятым.",
|
||||
success: "принято.",
|
||||
call: func(ctx context.Context, px *praxisClient, id string) error {
|
||||
_, err := px.Acknowledge(ctx, id)
|
||||
return err
|
||||
},
|
||||
},
|
||||
praxisItemAction{
|
||||
verbs: []string{"resolve_item", "сделано", "готово", "решено"},
|
||||
ask: "какой пункт отметить сделанным?",
|
||||
op: "resolve",
|
||||
failure: "не получилось отметить сделанным.",
|
||||
success: "отмечено как сделано.",
|
||||
call: func(ctx context.Context, px *praxisClient, id string) error {
|
||||
_, err := px.Resolve(ctx, id)
|
||||
return err
|
||||
},
|
||||
},
|
||||
praxisItemAction{
|
||||
verbs: []string{"ignore_item", "игнорировать", "неважно"},
|
||||
ask: "какой пункт игнорировать?",
|
||||
op: "ignore",
|
||||
failure: "не получилось проигнорировать.",
|
||||
success: "проигнорировано.",
|
||||
call: func(ctx context.Context, px *praxisClient, id string) error {
|
||||
_, err := px.Ignore(ctx, id)
|
||||
return err
|
||||
},
|
||||
},
|
||||
praxisItemAction{
|
||||
verbs: []string{"pin_item", "закрепить"},
|
||||
ask: "какой пункт закрепить?",
|
||||
op: "pin",
|
||||
failure: "не получилось закрепить.",
|
||||
success: "закреплено.",
|
||||
call: func(ctx context.Context, px *praxisClient, id string) error {
|
||||
_, err := px.Pin(ctx, id, true)
|
||||
return err
|
||||
},
|
||||
},
|
||||
listChangesCapability{},
|
||||
entityAttentionCapability{},
|
||||
}
|
||||
|
||||
// handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API.
|
||||
// Returns "" when the act is not a Praxis verb (the caller falls through to the
|
||||
// system command executor). Returns a reply string otherwise.
|
||||
func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision) string {
|
||||
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||
return ""
|
||||
}
|
||||
// Every hop of this action shares one correlation ID, assigned here, so a
|
||||
// digest that calls attention once and surface N times reads as one turn
|
||||
// on the Praxis side instead of N+1 unrelated request ids.
|
||||
if correlationIDFromCtx(ctx) == "" {
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
px := h.ecosystem.praxis
|
||||
for _, capability := range praxisCapabilities {
|
||||
for _, alias := range capability.aliases() {
|
||||
if alias == dec.Slots.Fn {
|
||||
return capability.handle(ctx, h, px, dec)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Not a Praxis verb — let the caller fall through.
|
||||
return ""
|
||||
}
|
||||
|
||||
// praxisItemAction is the shared shape of the item-lifecycle capabilities: take
|
||||
// an item id from the value slot, call one Praxis endpoint, trace the result.
|
||||
type praxisItemAction struct {
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
failure string // reply when the Praxis call errors
|
||||
success string
|
||||
call func(ctx context.Context, px *praxisClient, id string) error
|
||||
}
|
||||
|
||||
func (a praxisItemAction) aliases() []string { return a.verbs }
|
||||
|
||||
func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string {
|
||||
id := dec.Slots.Value
|
||||
if id == "" {
|
||||
return a.ask
|
||||
}
|
||||
started := h.now()
|
||||
if err := a.call(ctx, px, id); err != nil {
|
||||
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
|
||||
return a.failure
|
||||
}
|
||||
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
|
||||
return a.success
|
||||
}
|
||||
|
||||
// listAttentionCapability reads the attention digest and surfaces every item it speaks.
|
||||
type listAttentionCapability struct{}
|
||||
|
||||
func (listAttentionCapability) aliases() []string {
|
||||
return []string{"list_attention", "attention", "внимание", "что требует внимания", "что нового"}
|
||||
}
|
||||
|
||||
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||
started := h.now()
|
||||
items, err := px.ListAttention(ctx, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return "не могу сейчас узнать, что требует внимания."
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "ничего не требует внимания."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
|
||||
importance, _ := item["importance"].(float64)
|
||||
rule, _ := item["rule"].(string)
|
||||
s := title
|
||||
if importance > 0 {
|
||||
s += fmt.Sprintf(" (важность %d", int(importance))
|
||||
if rule != "" {
|
||||
s += ": " + rule
|
||||
}
|
||||
s += ")"
|
||||
}
|
||||
parts = append(parts, s)
|
||||
|
||||
// Speaking an item surfaces it, it does not acknowledge it
|
||||
// (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort:
|
||||
// a failed surface call must not block delivering the digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return "требует внимания: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// listChangesCapability reads the recent-changes feed.
|
||||
type listChangesCapability struct{}
|
||||
|
||||
func (listChangesCapability) aliases() []string {
|
||||
return []string{"list_changes", "changes", "изменения", "что изменилось"}
|
||||
}
|
||||
|
||||
func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||
started := h.now()
|
||||
changes, err := px.ListChanges(ctx, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis changes: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return "не могу сейчас узнать об изменениях."
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return "нет изменений."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||
var parts []string
|
||||
for _, c := range changes {
|
||||
title, _ := c["title"].(string)
|
||||
typ, _ := c["change_type"].(string)
|
||||
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
||||
}
|
||||
return "изменения: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||
// a canonical Nexus entity and asking Praxis for that entity's attention items
|
||||
// (Vikunja #272). Unlike listAttentionCapability it is scoped: the entity_id
|
||||
// travels to Praxis as a query parameter instead of Maven filtering an unscoped
|
||||
// list client-side, which is what makes the ref canonical end to end.
|
||||
//
|
||||
// It also folds in what Maven herself knows about the same entity — facts the
|
||||
// enrichment worker has already resolved to that entity_id — so one question
|
||||
// gets one answer across both stores.
|
||||
type entityAttentionCapability struct{}
|
||||
|
||||
// aliases are matched against Slots.Fn, which carries a function slot from the
|
||||
// act grammar and never free Russian, so only grammar names belong here.
|
||||
func (entityAttentionCapability) aliases() []string {
|
||||
return []string{"entity_attention", "entity_status"}
|
||||
}
|
||||
|
||||
func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, dec router.Decision) string {
|
||||
subject := dec.Slots.Value
|
||||
if subject == "" {
|
||||
subject = dec.Slots.Text
|
||||
}
|
||||
if subject == "" {
|
||||
return "про что именно спросить?"
|
||||
}
|
||||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||||
// than quietly answering about something else.
|
||||
return "не могу связать это с сущностью — Nexus не настроен."
|
||||
}
|
||||
|
||||
started := h.now()
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, subject, nil)
|
||||
if err != nil {
|
||||
// The subject is his words, so the log gets the same redaction the
|
||||
// trace gets. A trace that stores a rune count next to a log line
|
||||
// storing the runes is not redacted at all.
|
||||
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
return "не знаю такой сущности."
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = subject
|
||||
}
|
||||
|
||||
queried := h.now()
|
||||
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
items, scoped := scopedToEntity(items, entityID)
|
||||
if !scoped {
|
||||
// A Praxis old enough to ignore an unknown query parameter answers the
|
||||
// scoped question with the unscoped list. Reading that back as "по
|
||||
// «X»: ..." is the exact fabrication the entity ref exists to prevent,
|
||||
// so refuse the answer instead of relabelling someone else's items.
|
||||
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
||||
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||
"entity_id": entityID, "count": len(items),
|
||||
})
|
||||
|
||||
var parts []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, title)
|
||||
// Same surfaced != acknowledged rule as the unscoped digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "по «" + displayName + "» ничего нет."
|
||||
}
|
||||
return "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
// about, and reports whether the response can be trusted as scoped at all. An
|
||||
// item without an entity_id is kept only when at least one sibling carries the
|
||||
// matching id: a whole page with no entity_id is a Praxis that ignored the
|
||||
// scope, not a page of untagged items.
|
||||
func scopedToEntity(items []map[string]any, entityID string) ([]map[string]any, bool) {
|
||||
if len(items) == 0 {
|
||||
return items, true
|
||||
}
|
||||
var kept []map[string]any
|
||||
var sawMatch, sawMismatch bool
|
||||
for _, item := range items {
|
||||
id, _ := item["entity_id"].(string)
|
||||
switch {
|
||||
case id == entityID:
|
||||
sawMatch = true
|
||||
kept = append(kept, item)
|
||||
case id != "":
|
||||
sawMismatch = true
|
||||
default:
|
||||
kept = append(kept, item)
|
||||
}
|
||||
}
|
||||
if sawMatch {
|
||||
return kept, true
|
||||
}
|
||||
if sawMismatch {
|
||||
// Some items were tagged and none matched: the far side answered about
|
||||
// other entities, so nothing here belongs to this one.
|
||||
return nil, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// localFactsForEntity summarises Maven's own facts already resolved to this
|
||||
// canonical entity. Empty when the store is unavailable or nothing matched —
|
||||
// entity-scoped memory is an enrichment of the answer, never a precondition.
|
||||
func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID string) string {
|
||||
if h.dataStore == nil || entityID == "" {
|
||||
return ""
|
||||
}
|
||||
const spoken = 3
|
||||
// One over the spoken limit, so a truncation can be named rather than
|
||||
// passed off as everything she knows.
|
||||
facts, err := h.dataStore.FactsByEntity(ctx, entityID, spoken+1)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: facts by entity %s: %v", entityID, err)
|
||||
return ""
|
||||
}
|
||||
more := false
|
||||
if len(facts) > spoken {
|
||||
facts, more = facts[:spoken], true
|
||||
}
|
||||
var parts []string
|
||||
for _, f := range facts {
|
||||
if f.Value != "" {
|
||||
parts = append(parts, f.Value)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := "я помню: " + strings.Join(parts, ", ")
|
||||
if more {
|
||||
out += ", и это не всё"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeFields overlays b onto a and returns a.
|
||||
func mergeFields(a, b map[string]any) map[string]any {
|
||||
for k, v := range b {
|
||||
a[k] = v
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// recordPraxisTrace — records a completed Praxis call. Thin wrapper over
|
||||
// recordEcosystemTrace so every ecosystem hop lands in one table with one
|
||||
// shape.
|
||||
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, started time.Time, details map[string]any) {
|
||||
h.recordEcosystemTrace(ctx, "praxis", operation, traceOK, started, details)
|
||||
}
|
||||
|
||||
// traceStatus classifies an ecosystem call for the trace record. Kept coarse
|
||||
// on purpose: a trace is read to answer "did this hop work, and how long did
|
||||
// it take", not to re-derive the error.
|
||||
const (
|
||||
traceOK = "ok"
|
||||
traceFailed = "failed" // the call never got an answer
|
||||
traceRefused = "refused" // the far side answered, and said no
|
||||
traceAmbig = "ambiguous"
|
||||
traceNotFound = "not_found"
|
||||
tracePending = "pending" // deliberately not done yet, awaiting a confirm
|
||||
)
|
||||
|
||||
// traceStatusForError distinguishes "I could not reach it" from "it answered
|
||||
// and refused". Both degrade the same way for him and not at all the same way
|
||||
// for whoever reads the trace: one is a network or a dead service, the other
|
||||
// is a token, a version or a rejected argument.
|
||||
func traceStatusForError(err error) string {
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) && !ee.Unreachable() {
|
||||
return traceRefused
|
||||
}
|
||||
return traceFailed
|
||||
}
|
||||
|
||||
// redactSubject reduces a user utterance to something safe to persist in a
|
||||
// trace: its length only. Traces are diagnostics, and his words are not
|
||||
// diagnostics — the correlation ID is what ties a trace to the turn.
|
||||
func redactSubject(s string) string {
|
||||
return fmt.Sprintf("<%d chars>", len([]rune(s)))
|
||||
}
|
||||
|
||||
// recordEcosystemTrace writes one hop of a cross-service call: which service,
|
||||
// which operation, the outcome, how long it took, and the correlation ID that
|
||||
// stitches the hops together. It is written for every outcome, not only
|
||||
// success — an unrecorded failure is exactly the hop you need when something
|
||||
// went wrong at 3am.
|
||||
//
|
||||
// Traces go to their own store table, never to facts. One act turn produces
|
||||
// three or four of them, at machine rate, while facts arrive at human rate:
|
||||
// sharing the table meant the habit profile's 2000-row window, memeval's
|
||||
// prompt snapshot and the /dash and /history pages all filled with traces and
|
||||
// stopped seeing his actual facts.
|
||||
func (h *reactiveHandler) recordEcosystemTrace(ctx context.Context, service, op, status string, started time.Time, fields map[string]any) {
|
||||
if h.dataStore == nil {
|
||||
return
|
||||
}
|
||||
tr := store.EcosystemTrace{
|
||||
Ts: h.now(),
|
||||
Service: service,
|
||||
Operation: op,
|
||||
Status: status,
|
||||
DurationMs: h.now().Sub(started).Milliseconds(),
|
||||
CorrelationID: correlationIDFromCtx(ctx),
|
||||
Fields: map[string]any{},
|
||||
}
|
||||
for k, v := range fields {
|
||||
switch k {
|
||||
case "causation_id":
|
||||
tr.CausationID, _ = v.(string)
|
||||
case "http_status":
|
||||
if n, ok := v.(int); ok {
|
||||
tr.HTTPStatus = n
|
||||
continue
|
||||
}
|
||||
tr.Fields[k] = v
|
||||
default:
|
||||
tr.Fields[k] = v
|
||||
}
|
||||
}
|
||||
if _, err := h.dataStore.WriteEcosystemTrace(ctx, tr); err != nil {
|
||||
log.Printf("ecosystem: record trace %s:%s: %v", service, op, err)
|
||||
}
|
||||
}
|
||||
|
||||
// unauthorizedEcosystemError reports a credential the far side rejected. It
|
||||
// gets its own reply: a missing or wrong token looks exactly like an outage to
|
||||
// him, and "try again" is advice that will never work.
|
||||
func unauthorizedEcosystemError(err error) bool {
|
||||
var ee *ecosystemError
|
||||
return errors.As(err, &ee) && ee.Unauthorized()
|
||||
}
|
||||
|
||||
// traceErrorFields describes an ecosystemError for a trace without leaking the
|
||||
// payload: the HTTP status and the failure class, nothing else.
|
||||
func traceErrorFields(err error) map[string]any {
|
||||
fields := map[string]any{}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
default:
|
||||
fields["class"] = "error"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
fields["class"] = "error"
|
||||
return fields
|
||||
}
|
||||
|
||||
// handleHexisAct — resolves entity references through Nexus and executes
|
||||
// matching capabilities through Hexis. Returns a reply string when handled,
|
||||
// or "" to fall through to the system command executor.
|
||||
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string {
|
||||
if h.ecosystem == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Every hop of this action shares one correlation ID, assigned here so
|
||||
// resolution and discovery are traceable even when execution never
|
||||
// happens.
|
||||
if correlationIDFromCtx(ctx) == "" {
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
|
||||
// Resolve the utterance text as an entity reference through Nexus. An
|
||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||
started := h.now()
|
||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||
// and report degradation rather than silently falling through to the
|
||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||
// independently, never a silent all-clear).
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
map[string]any{"candidates": len(ambiguous)})
|
||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||
}
|
||||
if entityID == "" {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||
map[string]any{"subject": redactSubject(dec.Slots.Text)})
|
||||
return ""
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||
map[string]any{"entity_id": entityID})
|
||||
|
||||
// Discover Hexis capabilities for this entity. A resolved entity with a
|
||||
// genuine Hexis failure must not be treated as "no capabilities" and
|
||||
// fall through to unrelated local execution.
|
||||
discovered := h.now()
|
||||
caps, err := h.ecosystem.discoverCapabilities(ctx, entityID)
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return "экосистема отклоняет доступ, проверь токен."
|
||||
}
|
||||
return "экосистема недоступна, попробуй ещё раз."
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
if len(caps) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Match the user's verb to a capability by name/description. Collect all
|
||||
// matches: more than one is itself ambiguous, so we ask rather than pick
|
||||
// the first (ecosystem invariant: no arbitrary target for mutation).
|
||||
verb := dec.Slots.Fn
|
||||
if verb == "" {
|
||||
verb = dec.Slots.Text
|
||||
}
|
||||
verbLower := strings.ToLower(verb)
|
||||
|
||||
var matches []*hexisclient.Capability
|
||||
for i, c := range caps {
|
||||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
||||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
||||
matches = append(matches, &caps[i])
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
var names []string
|
||||
for _, m := range matches {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
||||
}
|
||||
matched := matches[0]
|
||||
|
||||
// Read-only capabilities run immediately; mutating ones are parked for an
|
||||
// explicit spoken confirm bound to this capability + target.
|
||||
if !matched.ReadOnly {
|
||||
h.mu.Lock()
|
||||
h.pendingHexis = &pendingHexisExec{
|
||||
capabilityID: matched.ID,
|
||||
capName: matched.Name,
|
||||
entityID: entityID,
|
||||
displayName: displayName,
|
||||
expiry: h.now().Add(confirmTTL),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
||||
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
||||
}
|
||||
|
||||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||||
}
|
||||
|
||||
// execHexis runs a resolved capability and records a cross-service trace with
|
||||
// the correlation ID. It reports command success, never operational recovery
|
||||
// (Praxis observes recovery independently).
|
||||
func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityID, displayName string) string {
|
||||
started := h.now()
|
||||
causationID := correlationIDFromCtx(ctx)
|
||||
correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil)
|
||||
traced := withCorrelationID(ctx, correlationID)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err)
|
||||
h.recordEcosystemTrace(traced, "hexis", "execute", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
return "не получилось выполнить команду для " + displayName + "."
|
||||
}
|
||||
// One record per hop: the second write this used to make said the same
|
||||
// thing under a different key, in a different shape.
|
||||
h.recordEcosystemTrace(traced, "hexis", "execute", traceOK, started, map[string]any{
|
||||
"entity_id": entityID, "entity_name": displayName,
|
||||
"capability": capName, "causation_id": causationID,
|
||||
})
|
||||
return "команда выполнена для " + displayName + "."
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
// TestWireEcosystem_HexisToken — a configured Hexis token reaches the wire.
|
||||
//
|
||||
// This is the regression that closes the 2026-08-01 re-vendor. The copy of
|
||||
// github.com/kami/hexis checked into vendor/ used to predate Client.WithToken,
|
||||
// so a configured token could not be sent at all; wireEcosystem refused to wire
|
||||
// Hexis rather than execute unauthenticated. Both halves of that are gone. The
|
||||
// test asserts the outcome the refusal was standing in for: the header goes
|
||||
// out, so nobody has to trust a boot log to know auth is on.
|
||||
func TestWireEcosystem_HexisToken(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL, Token: "s3cret"}}
|
||||
w := wireEcosystem(cfg)
|
||||
if w.hexis == nil {
|
||||
t.Fatal("hexis not wired with a token configured")
|
||||
}
|
||||
if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil {
|
||||
t.Fatalf("discoverCapabilities: %v", err)
|
||||
}
|
||||
if want := "Bearer s3cret"; gotAuth != want {
|
||||
t.Errorf("Authorization = %q; want %q", gotAuth, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireEcosystem_HexisNoToken — no token configured still wires, unauthed.
|
||||
// Hexis without auth is a valid deployment on a trusted box, and the re-vendor
|
||||
// must not have turned the token into a requirement.
|
||||
func TestWireEcosystem_HexisNoToken(t *testing.T) {
|
||||
var sawAuth bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sawAuth = r.Header.Get("Authorization") != ""
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[]`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &config.Config{Hexis: &config.HexisConfig{URL: srv.URL}}
|
||||
w := wireEcosystem(cfg)
|
||||
if w.hexis == nil {
|
||||
t.Fatal("hexis not wired without a token")
|
||||
}
|
||||
if _, err := w.discoverCapabilities(context.Background(), "entity-1"); err != nil {
|
||||
t.Fatalf("discoverCapabilities: %v", err)
|
||||
}
|
||||
if sawAuth {
|
||||
t.Error("Authorization header sent with no token configured")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Phase-5 hardening suite (Vikunja #276). Everything here drives the shared
|
||||
// fake ecosystem (fakeecosystem_test.go) rather than one-off inline handlers,
|
||||
// so the same fault levers — SetFault, SetBody, SetDelay — cover every
|
||||
// service. What is asserted is the degraded-mode contract:
|
||||
//
|
||||
// - services degrade independently: one outage never mutes the others,
|
||||
// - a degraded reply is never silent, never fabricated, never "success",
|
||||
// - contract drift (old shape, unknown fields, garbage) is survivable,
|
||||
// - Maven never acts on an ambiguous target and never chains
|
||||
// Praxis observation into Hexis execution on its own.
|
||||
|
||||
// ecoHandler wires a handler against whichever of the three fakes is given
|
||||
// (pass nil to leave a service unconfigured, which is a different state from
|
||||
// "configured but down").
|
||||
func ecoHandler(t *testing.T, nexus, praxis, hexis *fakeServer) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
clock := newTickingClock(time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), time.Millisecond)
|
||||
w := &ecosystemWiring{}
|
||||
if nexus != nil {
|
||||
w.nexus = newNexusClient(nexus.URL)
|
||||
}
|
||||
if praxis != nil {
|
||||
w.praxis = newPraxisClient(praxis.URL)
|
||||
}
|
||||
if hexis != nil {
|
||||
w.hexis = hexisclient.New(hexis.URL)
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
dataStore: st,
|
||||
now: clock.Now,
|
||||
ecosystem: w,
|
||||
}
|
||||
}
|
||||
|
||||
// traces reads the ecosystem trace table. Traces live there and not in facts,
|
||||
// so a bounded reader of facts never fills up with machine-rate rows.
|
||||
func traces(t *testing.T, h *reactiveHandler) []store.EcosystemTrace {
|
||||
t.Helper()
|
||||
out, err := h.dataStore.RecentEcosystemTraces(context.Background(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("read traces: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tracesFor returns the traces recorded for one service+operation.
|
||||
func tracesFor(t *testing.T, h *reactiveHandler, service, op string) []store.EcosystemTrace {
|
||||
t.Helper()
|
||||
var out []store.EcosystemTrace
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Service == service && tr.Operation == op {
|
||||
out = append(out, tr)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// restartCaps is a read-only capability. Restarting a service is a mutation,
|
||||
// so the read-only one this suite runs through the happy paths is named for
|
||||
// what it is; the mutating restart lives in the confirmation tests.
|
||||
func restartCaps() string {
|
||||
return fixtureHexisCapabilities(map[string]any{
|
||||
"id": "cap_status", "name": "restart status", "read_only": true,
|
||||
})
|
||||
}
|
||||
|
||||
// TestEcosystem_OutagesLeaveNoSharedFailureState: the two act paths share a
|
||||
// handler, a store and a clock, so what is worth asserting is that a failure
|
||||
// on one leaves nothing behind that degrades the other. Faulting one disjoint
|
||||
// call graph and exercising the other only tests the call graph.
|
||||
func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||
}))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
||||
// store is the one thing the Praxis path could inherit it through.
|
||||
nexus.SetFault(503)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||
t.Fatal("the failed resolve must be recorded")
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a recorded nexus failure must not degrade the praxis digest, got %q", reply)
|
||||
}
|
||||
if got := tracesFor(t, h, "praxis", "list_attention"); len(got) != 1 || got[0].Status != traceOK {
|
||||
t.Fatalf("the praxis digest must trace its own success, got %+v", got)
|
||||
}
|
||||
|
||||
// And the reverse: a Praxis outage mid-session leaves the Hexis path whole.
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
||||
}
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_OneEndpointDownDoesNotMuteTheService: real outages are usually
|
||||
// partial. Attention answering while surface is down must still deliver.
|
||||
func TestEcosystem_OneEndpointDownDoesNotMuteTheService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||
}))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 503)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a downed surface endpoint must not mute the digest, got %q", reply)
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
|
||||
t.Fatal("expected the surface attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_ResolvedWithoutEntityFailsClosed: the contract violation that
|
||||
// decodes cleanly. Nexus says "resolved" and delivers no entity; treating that
|
||||
// as "no such entity" put the user's verb through to the local executor.
|
||||
func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolvedEmpty())
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" {
|
||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("hexis must not be contacted after a contract-violating resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_RejectedCredentialSaysSo: 401 and 403 must not read as an
|
||||
// outage. "Try again" is advice that never works for a misconfigured token.
|
||||
func TestEcosystem_RejectedCredentialSaysSo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, status := range []int{401, 403} {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(status)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "токен") {
|
||||
t.Fatalf("http %d must read as a credential problem, got %q", status, reply)
|
||||
}
|
||||
tr := tracesFor(t, h, "nexus", "resolve")
|
||||
if len(tr) != 1 || tr[0].Status != traceRefused || tr[0].HTTPStatus != status {
|
||||
t.Fatalf("http %d must trace as refused with its status, got %+v", status, tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_MalformedPraxisBodyDegrades: Praxis has the same decode path
|
||||
// Nexus does, and a 200 carrying garbage there is a dependency failure too.
|
||||
func TestEcosystem_MalformedPraxisBodyDegrades(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||
}))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetBody(`[{"title":`)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("a malformed praxis body must not answer with silence")
|
||||
}
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a malformed body must not produce content, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_MalformedNexusResponseFailsClosed: a 200 carrying garbage is a
|
||||
// dependency failure, not "no such entity". It must stop before Hexis.
|
||||
func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("hexis must not be contacted after a malformed nexus response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_UnknownContractFieldsTolerated: a newer Nexus adding fields
|
||||
// must not break an older Maven. Same for the older flat resolve shape.
|
||||
func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for name, body := range map[string]string{
|
||||
"future": fixtureNexusResolvedFuture("ent_muzick", "Muzick indexer", "service"),
|
||||
"flat": fixtureNexusResolvedFlat("ent_muzick", "Muzick indexer", "service"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
nexus := newFakeNexus(t, body)
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_CancelledContextDegrades: a caller hanging up (turn abandoned,
|
||||
// deadline hit) must surface as degradation, never as a fabricated result.
|
||||
func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetDelay(2 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||
}
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("hexis must not be contacted after a cancelled resolve")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_ExecutionFailureIsNotSuccess: Hexis answering 200 with
|
||||
// status=failed is a partial failure — the call worked, the command did not.
|
||||
// Maven must report it as a failure and must not write a success trace.
|
||||
func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecutionFailed("exec_1", "unit not found"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("failed execution must say something")
|
||||
}
|
||||
for _, tr := range tracesFor(t, h, "hexis", "execute") {
|
||||
if tr.Status == traceOK {
|
||||
t.Fatalf("failed execution must not write a success trace: %+v", tr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_SuccessfulActionWritesATrace is the positive half the failure
|
||||
// assertions above depend on: without it, "no success trace" passes with the
|
||||
// trace writer deleted. It was, for a while — both writers used a fact kind the
|
||||
// store's CHECK constraint rejects and the error was discarded.
|
||||
func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
exec := tracesFor(t, h, "hexis", "execute")
|
||||
if len(exec) != 1 || exec[0].Status != traceOK {
|
||||
t.Fatalf("a successful execution must leave exactly one ok trace, got %+v", exec)
|
||||
}
|
||||
if exec[0].CorrelationID == "" {
|
||||
t.Error("a trace with no correlation id cannot be stitched to anything")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_TracesStayOutOfFacts: traces are written at machine rate and
|
||||
// facts at human rate. One act turn used to write four fact rows, which pushed
|
||||
// his facts out of every bounded reader (the habit profile's window, memeval's
|
||||
// prompt, /dash, /history).
|
||||
func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
if len(traces(t, h)) == 0 {
|
||||
t.Fatal("setup: expected traces")
|
||||
}
|
||||
facts, err := h.dataStore.RecentFacts(ctx, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("read facts: %v", err)
|
||||
}
|
||||
if len(facts) != 0 {
|
||||
t.Fatalf("an ecosystem act must write no facts at all, got %+v", facts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_AmbiguousTargetBlocksExecution: ambiguity blocks mutation, and
|
||||
// the clarification must name the candidates rather than pick one.
|
||||
func TestEcosystem_AmbiguousTargetBlocksExecution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
|
||||
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
|
||||
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
|
||||
))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous resolve must list candidates, got %q", reply)
|
||||
}
|
||||
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
||||
t.Fatal("ambiguous target must never execute")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_NoAutonomousPraxisToHexis: reading the attention digest is an
|
||||
// observation. Maven must never turn an observed problem into a Hexis command
|
||||
// by herself — she is not autonomous.
|
||||
func TestEcosystem_NoAutonomousPraxisToHexis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "muzick indexer is down", "importance": 4.0, "rule": "service_down"},
|
||||
))
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("attention digest must not contact hexis on its own")
|
||||
}
|
||||
if nexus.Count("", "/api/v1/resolve") != 0 {
|
||||
t.Fatal("attention digest must not resolve targets for autonomous action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_MutatingCapabilityWaitsForConfirmation: a non-read-only
|
||||
// capability parks for an explicit spoken confirm bound to capability+target.
|
||||
func TestEcosystem_MutatingCapabilityWaitsForConfirmation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("restart"))
|
||||
if !strings.Contains(reply, "restart") || !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
||||
t.Fatal("mutating capability must not execute before confirmation")
|
||||
}
|
||||
h.mu.Lock()
|
||||
pending := h.pendingHexis
|
||||
h.mu.Unlock()
|
||||
if pending == nil || pending.capabilityID != "cap_restart" || pending.entityID != "ent_muzick" {
|
||||
t.Fatalf("confirmation must be bound to capability+target, got %+v", pending)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_SurfaceFailureStillDelivers: surfacing is bookkeeping. If the
|
||||
// surface call fails the digest must still be spoken — a partial failure
|
||||
// downgrades bookkeeping, not the answer.
|
||||
func TestEcosystem_SurfaceFailureStillDelivers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 500)
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("failed surface must not swallow the digest, got %q", reply)
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
|
||||
t.Fatal("expected the surface attempt")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_TotalOutageSaysSoForEveryPath: with all three down, every
|
||||
// entry point degrades explicitly instead of returning empty or inventing.
|
||||
func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
for _, fs := range []*fakeServer{nexus, praxis, hexis} {
|
||||
fs.SetFault(503)
|
||||
}
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
for name, reply := range map[string]string{
|
||||
"hexis act": h.handleHexisAct(ctx, actDec("muzick indexer")),
|
||||
"attention": h.handlePraxisAct(ctx, praxisActDec("list_attention")),
|
||||
"changes": h.handlePraxisAct(ctx, praxisActDec("list_changes")),
|
||||
"acknowledge": h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1")),
|
||||
} {
|
||||
if reply == "" {
|
||||
t.Errorf("%s: total outage must not answer with silence", name)
|
||||
}
|
||||
if strings.Contains(reply, "выполнена") {
|
||||
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
||||
}
|
||||
}
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Status == traceOK {
|
||||
t.Fatalf("a total outage must not leave success traces behind: %+v", tr)
|
||||
}
|
||||
}
|
||||
if len(tracesFor(t, h, "praxis", "acknowledge")) == 0 {
|
||||
t.Fatal("the acknowledge arm must reach praxis and record the refusal")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystem_RecoveryAfterOutageNeedsNoRestart: once the dependency comes
|
||||
// back the very next turn works — no cached failure state, no restart.
|
||||
func TestEcosystem_RecoveryAfterOutageNeedsNoRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("outage must not serve content, got %q", reply)
|
||||
}
|
||||
praxis.SetFault(0)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("recovery must work on the next turn, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,13 @@ func praxisActDec(fn string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true}}
|
||||
}
|
||||
|
||||
// praxisItemDec is praxisActDec for the lifecycle verbs, which need an item id
|
||||
// in the value slot. Without one they answer "which item?" and never reach
|
||||
// Praxis at all, which makes them useless for testing a Praxis outage.
|
||||
func praxisItemDec(fn, itemID string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true, Value: itemID}}
|
||||
}
|
||||
|
||||
func newPraxisTestHandler(t *testing.T, praxis *fakeServer) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
|
||||
@@ -59,8 +59,11 @@ func newHexisTestHandler(t *testing.T, resolveBody string, caps string) (*reacti
|
||||
}, executed
|
||||
}
|
||||
|
||||
func actDec(text string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: text, Fn: "restart", HasFn: true}}
|
||||
// actDec builds an act decision about subject. The verb is always "restart":
|
||||
// the argument is the utterance the entity is resolved from, never the verb,
|
||||
// so actDec("restart") reads as a verb and is not one.
|
||||
func actDec(subject string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Text: subject, Fn: "restart", HasFn: true}}
|
||||
}
|
||||
|
||||
func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Versioning, authentication and tracing of ecosystem calls (Vikunja #273).
|
||||
|
||||
func findTrace(t *testing.T, h *reactiveHandler, service, op string) *store.EcosystemTrace {
|
||||
t.Helper()
|
||||
for _, tr := range traces(t, h) {
|
||||
if tr.Service == service && tr.Operation == op {
|
||||
found := tr
|
||||
return &found
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestEcosystemHeaders_VersionRequesterAndAuth: every outgoing request carries
|
||||
// the contract version, the requester, and the bearer token when configured.
|
||||
func TestEcosystemHeaders_VersionRequesterAndAuth(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
h.ecosystem.nexus = newNexusClient(nexus.URL).withToken("nexus-secret")
|
||||
h.ecosystem.praxis = newPraxisClient(praxis.URL).withToken("praxis-secret")
|
||||
|
||||
_, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
// A bare client call carries whatever the caller assigned. Entry points
|
||||
// assign the ID, the header layer only reads it, so mirror an action here.
|
||||
if _, err := h.ecosystem.praxis.ListAttention(withCorrelationID(ctx, newCorrelationID()), 5); err != nil {
|
||||
t.Fatalf("attention: %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
fs *fakeServer
|
||||
versionHeader string
|
||||
token string
|
||||
}{
|
||||
{nexus, "X-Nexus-Version", "nexus-secret"},
|
||||
{praxis, "X-Praxis-Version", "praxis-secret"},
|
||||
} {
|
||||
reqs := tc.fs.Requests()
|
||||
if len(reqs) == 0 {
|
||||
t.Fatalf("%s: no request captured", tc.versionHeader)
|
||||
}
|
||||
r := reqs[0]
|
||||
if got := r.Header.Get(tc.versionHeader); got != ecosystemAPIVersion {
|
||||
t.Errorf("%s = %q, want %q", tc.versionHeader, got, ecosystemAPIVersion)
|
||||
}
|
||||
if got := r.Header.Get("X-Requested-By"); got != mavenRequester {
|
||||
t.Errorf("X-Requested-By = %q, want %q", got, mavenRequester)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer "+tc.token {
|
||||
t.Errorf("Authorization = %q, want bearer %q", got, tc.token)
|
||||
}
|
||||
if r.Header.Get("X-Correlation-ID") == "" {
|
||||
t.Errorf("%s: missing correlation ID", tc.versionHeader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemHeaders_NoTokenSendsNoAuth: an unconfigured token means the
|
||||
// transport is trusted, not that a bogus header is sent.
|
||||
func TestEcosystemHeaders_NoTokenSendsNoAuth(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
h := ecoHandler(t, nexus, nil, nil)
|
||||
|
||||
if _, _, _, err := h.ecosystem.resolveEntityReference(ctx, "muzick indexer", nil); err != nil {
|
||||
t.Fatalf("resolve: %v", err)
|
||||
}
|
||||
if got := nexus.Requests()[0].Header.Get("Authorization"); got != "" {
|
||||
t.Fatalf("unauthenticated client must send no Authorization header, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemError_ClassifiesRefusals: callers must be able to tell a
|
||||
// rejected credential from a version refusal from an unreachable service
|
||||
// without matching on message text.
|
||||
func TestEcosystemError_ClassifiesRefusals(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
status int
|
||||
check func(*ecosystemError) bool
|
||||
wantCls string
|
||||
}{
|
||||
{"unauthorized", 401, (*ecosystemError).Unauthorized, "unauthorized"},
|
||||
{"forbidden", 403, (*ecosystemError).Unauthorized, "unauthorized"},
|
||||
{"contract", 426, (*ecosystemError).ContractMismatch, "contract_mismatch"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
|
||||
nexus.SetFault(tc.status)
|
||||
c := newNexusClient(nexus.URL)
|
||||
_, err := c.Resolve(ctx, "x", nil)
|
||||
ee, ok := err.(*ecosystemError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *ecosystemError, got %T (%v)", err, err)
|
||||
}
|
||||
if ee.Service != "nexus" || ee.Status != tc.status {
|
||||
t.Fatalf("unexpected typed error %+v", ee)
|
||||
}
|
||||
if !tc.check(ee) {
|
||||
t.Fatalf("%s not classified: %+v", tc.name, ee)
|
||||
}
|
||||
if got := traceErrorFields(err)["class"]; got != tc.wantCls {
|
||||
t.Fatalf("trace class = %v, want %s", got, tc.wantCls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEcosystemError_UnreachableHasNoStatus(t *testing.T) {
|
||||
c := newNexusClient("http://127.0.0.1:1")
|
||||
_, err := c.Resolve(context.Background(), "x", nil)
|
||||
ee, ok := err.(*ecosystemError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *ecosystemError, got %T", err)
|
||||
}
|
||||
if !ee.Unreachable() || ee.Unauthorized() || ee.ContractMismatch() {
|
||||
t.Fatalf("a refused connection must classify as unreachable only: %+v", ee)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_SuccessfulActionTracesEveryHop: resolution, discovery and
|
||||
// execution each leave a record sharing one correlation chain, with timing and
|
||||
// status, and execution carries the causation link back to the resolve.
|
||||
func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
|
||||
var chain string
|
||||
for _, want := range [][2]string{{"nexus", "resolve"}, {"hexis", "capabilities"}, {"hexis", "execute"}} {
|
||||
d := findTrace(t, h, want[0], want[1])
|
||||
if d == nil {
|
||||
t.Fatalf("missing trace for %s %s, got %+v", want[0], want[1], traces(t, h))
|
||||
}
|
||||
if d.Status != traceOK {
|
||||
t.Errorf("%s %s status = %v, want ok", want[0], want[1], d.Status)
|
||||
}
|
||||
if d.CorrelationID == "" {
|
||||
t.Errorf("%s %s trace has no correlation id", want[0], want[1])
|
||||
}
|
||||
if want[1] != "execute" {
|
||||
if chain == "" {
|
||||
chain = d.CorrelationID
|
||||
} else if d.CorrelationID != chain {
|
||||
t.Errorf("%s %s left the correlation chain: %s != %s", want[0], want[1], d.CorrelationID, chain)
|
||||
}
|
||||
}
|
||||
}
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec.CausationID == "" {
|
||||
t.Error("execute trace must carry the causation id of the turn that caused it")
|
||||
}
|
||||
if exec.CorrelationID == exec.CausationID {
|
||||
t.Error("execute correlation and causation must be distinguishable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_OneCorrelationIDPerPraxisAction: a digest calls attention
|
||||
// once and surface once per item. All of it is one turn, so the far side must
|
||||
// see one ID and not N+1 unrelated ones.
|
||||
func TestEcosystemTrace_OneCorrelationIDPerPraxisAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
map[string]any{"id": "item_2", "title": "backup is stale", "importance": 2.0},
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("setup: expected the digest, got %q", reply)
|
||||
}
|
||||
|
||||
reqs := praxis.Requests()
|
||||
if len(reqs) < 3 {
|
||||
t.Fatalf("expected attention plus one surface per item, got %d requests", len(reqs))
|
||||
}
|
||||
first := reqs[0].Header.Get("X-Correlation-ID")
|
||||
if first == "" {
|
||||
t.Fatal("every ecosystem request must carry a correlation id")
|
||||
}
|
||||
for _, r := range reqs {
|
||||
if got := r.Header.Get("X-Correlation-ID"); got != first {
|
||||
t.Fatalf("%s %s carried %q, want the action's id %q", r.Method, r.Path, got, first)
|
||||
}
|
||||
}
|
||||
tr := findTrace(t, h, "praxis", "list_attention")
|
||||
if tr == nil || tr.CorrelationID != first {
|
||||
t.Fatalf("the trace must carry the id that was actually sent, got %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_FailuresAreTracedToo: the whole point of the change —
|
||||
// a failed hop is exactly the one worth having recorded.
|
||||
func TestEcosystemTrace_FailuresAreTracedToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(401)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
t.Fatal("a failed resolve must still be traced")
|
||||
}
|
||||
if d.Status != traceRefused {
|
||||
t.Errorf("status = %v, want refused: the far side answered", d.Status)
|
||||
}
|
||||
if d.Fields["class"] != "unauthorized" {
|
||||
t.Errorf("class = %v, want unauthorized", d.Fields["class"])
|
||||
}
|
||||
if d.HTTPStatus != 401 {
|
||||
t.Errorf("http_status = %v, want 401", d.HTTPStatus)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_UnreachableIsNotRefused: never got an answer and answered
|
||||
// with a refusal are different failures, and the trace must say which.
|
||||
func TestEcosystemTrace_UnreachableIsNotRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h := ecoHandler(t, nil, nil, nil)
|
||||
h.ecosystem.nexus = newNexusClient("http://127.0.0.1:1")
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
t.Fatal("an unreachable resolve must still be traced")
|
||||
}
|
||||
if d.Status != traceFailed {
|
||||
t.Errorf("status = %v, want failed", d.Status)
|
||||
}
|
||||
if d.Fields["class"] != "unreachable" {
|
||||
t.Errorf("class = %v, want unreachable", d.Fields["class"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_RedactsTheUtterance: traces are diagnostics, his words
|
||||
// are not. The subject must never be persisted verbatim.
|
||||
func TestEcosystemTrace_RedactsTheUtterance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusNotFound())
|
||||
h := ecoHandler(t, nexus, nil, nil)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"))
|
||||
|
||||
recorded := traces(t, h)
|
||||
if len(recorded) == 0 {
|
||||
t.Fatal("expected a not_found resolve trace")
|
||||
}
|
||||
for _, tr := range recorded {
|
||||
for k, v := range tr.Fields {
|
||||
if s, ok := v.(string); ok && strings.Contains(s, "кофемашину") {
|
||||
t.Fatalf("trace leaked the utterance in %s: %q", k, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d.Status != traceNotFound {
|
||||
t.Errorf("status = %v, want not_found", d.Status)
|
||||
}
|
||||
if d.Fields["subject"] != redactSubject("перезапусти кофемашину") {
|
||||
t.Errorf("subject = %v, want a redacted length", d.Fields["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded: the two moments
|
||||
// where Maven deliberately does not act still leave a trail.
|
||||
func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ambig := newFakeNexus(t, fixtureNexusAmbiguous(
|
||||
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
|
||||
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
|
||||
))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, ambig, nil, hexis)
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick"))
|
||||
if d := findTrace(t, h, "nexus", "resolve"); d == nil || d.Status != traceAmbig {
|
||||
t.Fatalf("ambiguous resolve must be traced as such, got %+v", d)
|
||||
}
|
||||
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
mutating := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
h2 := ecoHandler(t, nexus, nil, newFakeHexis(t, mutating, fixtureHexisExecuted("exec_1", "succeeded")))
|
||||
_ = h2.handleHexisAct(ctx, actDec("restart"))
|
||||
d := findTrace(t, h2, "hexis", "confirmation")
|
||||
if d == nil || d.Status != tracePending {
|
||||
t.Fatalf("a parked confirmation must be traced, got %+v", d)
|
||||
}
|
||||
// The confirmation hop is measured from the top of the action, not from
|
||||
// the instant it is recorded, which was always zero.
|
||||
if d.DurationMs == 0 {
|
||||
t.Error("the confirmation trace must report the time the action took to get there")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Entity-ref propagation, Maven side (Vikunja #272): the canonical Nexus
|
||||
// entity_id must reach Praxis as a query scope rather than being resolved and
|
||||
// then thrown away, and the enrichment that produces those ids must degrade
|
||||
// visibly instead of silently.
|
||||
|
||||
func entityAttentionDec(subject string) router.Decision {
|
||||
return router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: subject},
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_ScopesPraxisByCanonicalID: the resolved id must travel
|
||||
// to Praxis in the request, not be used for client-side filtering.
|
||||
func TestEntityAttention_ScopesPraxisByCanonicalID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionScoped("ent_muzick",
|
||||
map[string]any{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("expected the scoped item in the reply, got %q", reply)
|
||||
}
|
||||
|
||||
var scoped bool
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Method == "GET" && strings.HasPrefix(r.Path, "/api/v1/tools/attention") &&
|
||||
strings.Contains(r.Query, "entity_id=ent_muzick") {
|
||||
scoped = true
|
||||
}
|
||||
}
|
||||
if !scoped {
|
||||
t.Fatalf("expected attention scoped by entity_id, got requests %+v", praxis.Requests())
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") == 0 {
|
||||
t.Error("a spoken scoped item must be surfaced, like the unscoped digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_FoldsInLocalFactsForSameEntity: facts the enrichment
|
||||
// worker already tagged with the same canonical id join the same answer.
|
||||
func TestEntityAttention_FoldsInLocalFactsForSameEntity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
id, err := h.dataStore.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv,
|
||||
"descaled", "the espresso machine", "descaled in june", "infer:pref", 0.8, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
if err := h.dataStore.ResolveFactEntity(ctx, id, "ent_espresso", store.ResolutionResolved); err != nil {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
if !strings.Contains(reply, "descaled in june") {
|
||||
t.Fatalf("expected entity-scoped local facts in the reply, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_UnscopedPraxisResponseIsRefused: a Praxis old enough to
|
||||
// ignore the entity_id parameter answers the scoped question with the whole
|
||||
// unscoped list. Relabelling those items "по «X»" is the same fabrication the
|
||||
// canonical ref exists to prevent, arriving through a different door.
|
||||
func TestEntityAttention_UnscopedPraxisResponseIsRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("an unscoped response must not be read back as entity-scoped, got %q", reply)
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("refusing the answer must still say something")
|
||||
}
|
||||
if praxis.Count("POST", "/api/v1/tools/surface") != 0 {
|
||||
t.Error("items that were never spoken must not be surfaced")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_ForeignItemsAreDropped: items tagged with another entity
|
||||
// are dropped rather than spoken under this entity's name.
|
||||
func TestEntityAttention_ForeignItemsAreDropped(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
mixed := []map[string]any{
|
||||
{"id": "item_1", "title": "indexer queue is backing up", "importance": 3.0, "entity_id": "ent_muzick"},
|
||||
{"id": "item_2", "title": "the kettle is descaling", "importance": 1.0, "entity_id": "ent_kettle"},
|
||||
}
|
||||
praxis := newFakePraxis(t, mustJSON(mixed))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("the matching item must be spoken, got %q", reply)
|
||||
}
|
||||
if strings.Contains(reply, "kettle") {
|
||||
t.Fatalf("another entity's item must not be spoken here, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_TruncationIsNamed: reading three of many remembered
|
||||
// facts must not be presented as everything she knows.
|
||||
func TestEntityAttention_TruncationIsNamed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
id, err := h.dataStore.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv,
|
||||
"note", "the espresso machine", "факт "+string(rune('а'+i)), "infer:pref", 0.8, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
if err := h.dataStore.ResolveFactEntity(ctx, id, "ent_espresso", store.ResolutionResolved); err != nil {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
if !strings.Contains(reply, "и это не всё") {
|
||||
t.Fatalf("a truncated recall must say it is truncated, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_AmbiguousAsksInsteadOfGuessing.
|
||||
func TestEntityAttention_AmbiguousAsksInsteadOfGuessing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusAmbiguous(
|
||||
map[string]string{"entity_id": "ent_a", "display_name": "Muzick indexer"},
|
||||
map[string]string{"entity_id": "ent_b", "display_name": "Muzick web"},
|
||||
))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous subject must ask, got %q", reply)
|
||||
}
|
||||
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
|
||||
t.Fatal("an ambiguous subject must not be queried against praxis")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_MissingAndDegradedAreDistinct: "no such entity" and
|
||||
// "Nexus is down" must not produce the same answer.
|
||||
func TestEntityAttention_MissingAndDegradedAreDistinct(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusNotFound())
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
missing := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
if missing == "" {
|
||||
t.Fatal("an unknown entity must still get an answer")
|
||||
}
|
||||
|
||||
nexus.SetFault(503)
|
||||
degraded := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
if degraded == missing {
|
||||
t.Fatalf("outage and unknown-entity must not read the same: %q", degraded)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_DelayedNexusDegradesNotHangs: a slow Nexus past the
|
||||
// caller's deadline degrades and never queries Praxis with an empty scope.
|
||||
func TestEntityAttention_DelayedNexusDegradesNotHangs(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
nexus.SetDelay(2 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if reply == "" {
|
||||
t.Fatal("a delayed resolve must still answer")
|
||||
}
|
||||
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
|
||||
t.Fatal("praxis must not be queried without a resolved scope")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntityAttention_WithoutNexusSaysSo: no Nexus means no canonical ref, so
|
||||
// the scoped query is refused rather than answered about something else.
|
||||
func TestEntityAttention_WithoutNexusSaysSo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems(
|
||||
map[string]any{"id": "item_1", "title": "disk almost full", "importance": 3.0},
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("without nexus, items must not be passed off as entity-scoped, got %q", reply)
|
||||
}
|
||||
if praxis.Count("GET", "/api/v1/tools/attention") != 0 {
|
||||
t.Fatal("no canonical ref means no scoped query at all")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichmentBackoff_HoldsAndReleases: repeated Nexus failures back the
|
||||
// fact off instead of hammering, and the fact is retried once the window
|
||||
// elapses. Nothing is ever given up on.
|
||||
func TestEnrichmentBackoff_HoldsAndReleases(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
st := newTestStore(t)
|
||||
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
|
||||
"the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
|
||||
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
|
||||
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
||||
w.now = clock.Now
|
||||
|
||||
nexus.SetFault(503)
|
||||
w.tick(ctx)
|
||||
failedCalls := nexus.Count("POST", "/api/v1/resolve")
|
||||
if failedCalls != 1 {
|
||||
t.Fatalf("expected one resolve attempt, got %d", failedCalls)
|
||||
}
|
||||
|
||||
// Immediately after a failure the fact is in backoff: no second call.
|
||||
w.tick(ctx)
|
||||
if nexus.Count("POST", "/api/v1/resolve") != failedCalls {
|
||||
t.Fatal("a fact in backoff must not be retried on the very next tick")
|
||||
}
|
||||
if s := w.status(ctx); s.Pending != 1 || s.InBackoff != 1 || s.MaxAttempts != 1 {
|
||||
t.Fatalf("degradation must be reported, got %+v", s)
|
||||
}
|
||||
|
||||
// Once the window elapses and Nexus recovers, the fact resolves.
|
||||
clock.Advance(2 * time.Minute)
|
||||
nexus.SetFault(0)
|
||||
w.tick(ctx)
|
||||
facts, err := st.FactsByEntity(ctx, "ent_espresso", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FactsByEntity: %v", err)
|
||||
}
|
||||
if len(facts) != 1 {
|
||||
t.Fatalf("expected the fact resolved after recovery, got %+v", facts)
|
||||
}
|
||||
if s := w.status(ctx); s.Pending != 0 || s.MaxAttempts != 0 {
|
||||
t.Fatalf("recovery must clear the degradation report, got %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichmentBackoff_GrowsAndIsCapped(t *testing.T) {
|
||||
if enrichmentBackoff(1) != time.Minute {
|
||||
t.Fatalf("first retry should be a minute, got %v", enrichmentBackoff(1))
|
||||
}
|
||||
if enrichmentBackoff(3) != 4*time.Minute {
|
||||
t.Fatalf("third retry should be four minutes, got %v", enrichmentBackoff(3))
|
||||
}
|
||||
if enrichmentBackoff(50) != time.Hour {
|
||||
t.Fatalf("backoff must cap at an hour, got %v", enrichmentBackoff(50))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichment_BackedOffFactsDoNotStallTheQueue: the pending queue is ordered
|
||||
// by id, so the oldest facts are pulled first whether or not they are eligible.
|
||||
// A batch of facts in backoff at the head must not hold every slot and stop
|
||||
// enrichment for everything younger.
|
||||
func TestEnrichment_BackedOffFactsDoNotStallTheQueue(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
total := 5
|
||||
for i := 0; i < total; i++ {
|
||||
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
|
||||
"subject-"+string(rune('a'+i)), `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_x", "X", "service"))
|
||||
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
|
||||
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
||||
w.now = clock.Now
|
||||
// A batch smaller than the queue, so with no scan the last fact never
|
||||
// reaches the head while the first ones are backed off.
|
||||
w.batch = total - 1
|
||||
|
||||
nexus.SetFault(503)
|
||||
w.tick(ctx)
|
||||
if got := nexus.Count("POST", "/api/v1/resolve"); got != total-1 {
|
||||
t.Fatalf("expected the first batch attempted, got %d calls", got)
|
||||
}
|
||||
|
||||
// Second tick with Nexus healthy: the backed-off head must be skipped and
|
||||
// the fact behind it resolved, not the same batch pulled and dropped.
|
||||
nexus.SetFault(0)
|
||||
w.tick(ctx)
|
||||
facts, err := st.FactsByEntity(ctx, "ent_x", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("FactsByEntity: %v", err)
|
||||
}
|
||||
if len(facts) == 0 {
|
||||
t.Fatal("a due fact behind a backed-off batch must still be resolved")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichment_StoreWriteFailureBacksOffToo: the one failure mode where the
|
||||
// resolve worked and the write did not must be paced like any other, not
|
||||
// retried at full rate forever.
|
||||
func TestEnrichment_StoreWriteFailureBacksOffToo(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_espresso", "the espresso machine", "device"))
|
||||
st := newTestStore(t)
|
||||
if _, err := st.WriteFactAboutSubject(ctx, time.Now(), store.KindEnv, "likes",
|
||||
"the espresso machine", `"true"`, "infer:pref", 0.8, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFactAboutSubject: %v", err)
|
||||
}
|
||||
pending, err := st.PendingFactResolutions(ctx, 10)
|
||||
if err != nil || len(pending) != 1 {
|
||||
t.Fatalf("setup: pending = %+v, %v", pending, err)
|
||||
}
|
||||
|
||||
clock := newFakeClock(time.Date(2026, 8, 1, 3, 0, 0, 0, time.UTC))
|
||||
w := newFactEnrichmentWorker(st, stubEcosystem(nexus.URL, ""), time.Hour)
|
||||
w.now = clock.Now
|
||||
|
||||
// Closing the store makes the resolution write fail while the Nexus call
|
||||
// still succeeds — the split this path gets wrong.
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
if w.resolveOne(ctx, pending[0]) {
|
||||
t.Fatal("a failed store write must not report success")
|
||||
}
|
||||
if w.due(pending[0].ID) {
|
||||
t.Fatal("a failed store write must back the fact off like a failed resolve")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -24,10 +25,88 @@ type factEnrichmentWorker struct {
|
||||
eco *ecosystemWiring
|
||||
interval time.Duration
|
||||
batch int // facts resolved per tick; keeps a single slow tick bounded
|
||||
now func() time.Time
|
||||
|
||||
// Retry state for facts whose resolution failed transiently. Kept in
|
||||
// memory rather than in the DB: a restart legitimately retries
|
||||
// everything, and the backoff exists to spare a struggling Nexus, not
|
||||
// to be durable. A fact is never given up on — degraded means slower,
|
||||
// not dropped.
|
||||
mu sync.Mutex
|
||||
attempt map[int64]int // fact id → consecutive failures
|
||||
nextTry map[int64]time.Time // fact id → earliest retry
|
||||
}
|
||||
|
||||
// enrichmentScanLimit bounds how deep a single tick (or status report) walks
|
||||
// the pending queue looking for facts whose backoff has elapsed. The queue is
|
||||
// ordered by id, so without a scan the oldest facts hold every batch slot
|
||||
// whether or not they are eligible, and one permanently failing fact stalls
|
||||
// every younger one behind it.
|
||||
const enrichmentScanLimit = 1000
|
||||
|
||||
// enrichmentBackoff is the wait before retrying a fact after n consecutive
|
||||
// failures, capped so a long Nexus outage still retries about hourly.
|
||||
func enrichmentBackoff(n int) time.Duration {
|
||||
d := time.Minute
|
||||
for i := 1; i < n && d < time.Hour; i++ {
|
||||
d *= 2
|
||||
}
|
||||
if d > time.Hour {
|
||||
d = time.Hour
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval time.Duration) *factEnrichmentWorker {
|
||||
return &factEnrichmentWorker{store: st, eco: eco, interval: interval, batch: 20}
|
||||
return &factEnrichmentWorker{
|
||||
store: st,
|
||||
eco: eco,
|
||||
interval: interval,
|
||||
batch: 20,
|
||||
now: time.Now,
|
||||
attempt: map[int64]int{},
|
||||
nextTry: map[int64]time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// enrichmentStatus is what the worker reports about its own health: how many
|
||||
// facts are waiting, how many of those are currently in backoff, and the worst
|
||||
// retry count among them. Degradation is reported, never hidden — a Nexus that
|
||||
// has been down all day must be visible as a backlog, not as facts that
|
||||
// silently never got tagged.
|
||||
//
|
||||
// All three numbers describe the same set of rows, the first
|
||||
// enrichmentScanLimit pending facts. Counting Pending over a thousand rows
|
||||
// while counting InBackoff over the twenty that reached the head of a batch
|
||||
// described two different populations under one struct.
|
||||
type enrichmentStatus struct {
|
||||
Pending int
|
||||
InBackoff int
|
||||
MaxAttempts int
|
||||
Scanned int // rows the other three counts were taken over
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) status(ctx context.Context) enrichmentStatus {
|
||||
var st enrichmentStatus
|
||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||
if err != nil {
|
||||
log.Printf("factenrichment: status: %v", err)
|
||||
return st
|
||||
}
|
||||
st.Pending = len(pending)
|
||||
st.Scanned = len(pending)
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := w.now()
|
||||
for _, f := range pending {
|
||||
if next, ok := w.nextTry[f.ID]; ok && now.Before(next) {
|
||||
st.InBackoff++
|
||||
}
|
||||
if n := w.attempt[f.ID]; n > st.MaxAttempts {
|
||||
st.MaxAttempts = n
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) run(ctx context.Context) {
|
||||
@@ -52,22 +131,86 @@ func (w *factEnrichmentWorker) run(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
||||
pending, err := w.store.PendingFactResolutions(ctx, w.batch)
|
||||
// Scan past the facts that are still in backoff instead of letting them
|
||||
// occupy the batch. The queue is ordered by id, so the oldest facts are
|
||||
// pulled first whether or not they are eligible: twenty facts Nexus keeps
|
||||
// rejecting would otherwise hold every slot forever and enrichment would
|
||||
// stop with no error and no log line, because a tick that skips everything
|
||||
// fails nothing.
|
||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||
if err != nil {
|
||||
log.Printf("factenrichment: list pending: %v", err)
|
||||
return
|
||||
}
|
||||
w.forgetDeparted(pending)
|
||||
skipped, failed, attempted := 0, 0, 0
|
||||
for _, f := range pending {
|
||||
w.resolveOne(ctx, f)
|
||||
if attempted >= w.batch {
|
||||
break
|
||||
}
|
||||
if !w.due(f.ID) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
if !w.resolveOne(ctx, f) {
|
||||
failed++
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
log.Printf("factenrichment: %d/%d resolutions failed this tick, %d held in backoff",
|
||||
failed, attempted, skipped)
|
||||
}
|
||||
// Report the backlog every tick, not only when something failed: the
|
||||
// stalled state worth seeing is the one where nothing failed because
|
||||
// nothing was attempted.
|
||||
if st := w.status(ctx); st.Pending > 0 {
|
||||
log.Printf("factenrichment: %d facts pending entity resolution, %d in backoff, worst attempt %d (scanned %d)",
|
||||
st.Pending, st.InBackoff, st.MaxAttempts, st.Scanned)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) {
|
||||
// forgetDeparted drops retry state for facts that are no longer pending. A
|
||||
// fact can leave the queue without ever resolving here — voided, or resolved
|
||||
// by a later write — and its entries would otherwise live as long as the
|
||||
// process does.
|
||||
func (w *factEnrichmentWorker) forgetDeparted(pending []store.Fact) {
|
||||
live := make(map[int64]struct{}, len(pending))
|
||||
for _, f := range pending {
|
||||
live[f.ID] = struct{}{}
|
||||
}
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for id := range w.attempt {
|
||||
if _, ok := live[id]; !ok {
|
||||
delete(w.attempt, id)
|
||||
}
|
||||
}
|
||||
for id := range w.nextTry {
|
||||
if _, ok := live[id]; !ok {
|
||||
delete(w.nextTry, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// due reports whether a fact's backoff window has elapsed.
|
||||
func (w *factEnrichmentWorker) due(id int64) bool {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
next, ok := w.nextTry[id]
|
||||
return !ok || !w.now().Before(next)
|
||||
}
|
||||
|
||||
// resolveOne resolves one pending fact. It returns false when the attempt
|
||||
// failed transiently: the fact stays pending and is retried on a backoff.
|
||||
func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) bool {
|
||||
entityID, _, ambiguous, err := w.eco.resolveEntityReference(ctx, f.Subject, nil)
|
||||
if err != nil {
|
||||
// Transient (Nexus unreachable) — leave pending, retry next tick.
|
||||
log.Printf("factenrichment: resolve fact %d subject %q: %v", f.ID, f.Subject, err)
|
||||
return
|
||||
// Transient (Nexus unreachable) — leave pending, back off, retry later.
|
||||
// The subject is his words: log its length, the way the trace does.
|
||||
log.Printf("factenrichment: resolve fact %d subject %s: %v", f.ID, redactSubject(f.Subject), err)
|
||||
w.backOff(f.ID)
|
||||
return false
|
||||
}
|
||||
state := store.ResolutionNotFound
|
||||
switch {
|
||||
@@ -77,6 +220,25 @@ func (w *factEnrichmentWorker) resolveOne(ctx context.Context, f store.Fact) {
|
||||
state = store.ResolutionAmbiguous
|
||||
}
|
||||
if err := w.store.ResolveFactEntity(ctx, f.ID, entityID, state); err != nil {
|
||||
// A failed write leaves the fact pending exactly like a failed resolve
|
||||
// does, so it gets the same pacing. Clearing the counters first meant
|
||||
// this one path retried every tick, at full rate, with no ceiling.
|
||||
log.Printf("factenrichment: record resolution for fact %d: %v", f.ID, err)
|
||||
w.backOff(f.ID)
|
||||
return false
|
||||
}
|
||||
w.mu.Lock()
|
||||
delete(w.attempt, f.ID)
|
||||
delete(w.nextTry, f.ID)
|
||||
w.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
|
||||
// backOff records one more consecutive failure for a fact and pushes its next
|
||||
// attempt out accordingly.
|
||||
func (w *factEnrichmentWorker) backOff(id int64) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.attempt[id]++
|
||||
w.nextTry[id] = w.now().Add(enrichmentBackoff(w.attempt[id]))
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ import (
|
||||
type capturedRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Body []byte
|
||||
Header http.Header
|
||||
}
|
||||
|
||||
// fakeServer is the common shell behind fakeNexus/fakePraxis/fakeHexis: an
|
||||
@@ -25,9 +27,12 @@ type capturedRequest struct {
|
||||
type fakeServer struct {
|
||||
*httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
requests []capturedRequest
|
||||
fault int // non-zero: every request gets this HTTP status instead of routing
|
||||
mu sync.Mutex
|
||||
requests []capturedRequest
|
||||
fault int // non-zero: every request gets this HTTP status instead of routing
|
||||
routeFaults map[string]int // path prefix → status, for one endpoint failing alone
|
||||
garbage string // non-empty: returned 200 verbatim instead of routing (malformed-contract lever)
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
// newFakeServer starts a server dispatching to routes keyed by "METHOD
|
||||
@@ -47,14 +52,42 @@ func newFakeServer(t *testing.T, routes map[string]http.HandlerFunc) *fakeServer
|
||||
}
|
||||
}
|
||||
fs.mu.Lock()
|
||||
fs.requests = append(fs.requests, capturedRequest{Method: r.Method, Path: r.URL.Path, Body: body})
|
||||
fs.requests = append(fs.requests, capturedRequest{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.RawQuery,
|
||||
Body: body,
|
||||
Header: r.Header.Clone(),
|
||||
})
|
||||
fault := fs.fault
|
||||
if fault == 0 {
|
||||
for prefix, status := range fs.routeFaults {
|
||||
if hasPrefix(r.URL.Path, prefix) {
|
||||
fault = status
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
garbage := fs.garbage
|
||||
delay := fs.delay
|
||||
fs.mu.Unlock()
|
||||
|
||||
if delay > 0 {
|
||||
select {
|
||||
case <-time.After(delay):
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if fault != 0 {
|
||||
http.Error(w, "injected fault", fault)
|
||||
return
|
||||
}
|
||||
if garbage != "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(garbage))
|
||||
return
|
||||
}
|
||||
|
||||
for key, handler := range routes {
|
||||
method, prefix := splitRouteKey(key)
|
||||
@@ -90,6 +123,52 @@ func (fs *fakeServer) SetFault(status int) {
|
||||
fs.fault = status
|
||||
}
|
||||
|
||||
// SetRouteFault fails one endpoint while the rest of the server stays healthy,
|
||||
// which is the shape most real outages take: attention answers and pin is
|
||||
// down. Pass 0 to clear that route. A server-wide SetFault still wins.
|
||||
func (fs *fakeServer) SetRouteFault(pathPrefix string, status int) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
if fs.routeFaults == nil {
|
||||
fs.routeFaults = map[string]int{}
|
||||
}
|
||||
if status == 0 {
|
||||
delete(fs.routeFaults, pathPrefix)
|
||||
return
|
||||
}
|
||||
fs.routeFaults[pathPrefix] = status
|
||||
}
|
||||
|
||||
// SetBody makes every subsequent request answer 200 with the given body,
|
||||
// bypassing the route table. Used to serve a malformed or contract-violating
|
||||
// payload where the transport itself is healthy. Pass "" to clear it.
|
||||
func (fs *fakeServer) SetBody(body string) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.garbage = body
|
||||
}
|
||||
|
||||
// SetDelay stalls every subsequent request for d before answering, so callers
|
||||
// can drive client timeouts and context cancellation deterministically. The
|
||||
// delay is abandoned as soon as the client hangs up.
|
||||
func (fs *fakeServer) SetDelay(d time.Duration) {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.delay = d
|
||||
}
|
||||
|
||||
// Count returns how many captured requests used the given method and path
|
||||
// prefix. "" matches any method.
|
||||
func (fs *fakeServer) Count(method, prefix string) int {
|
||||
n := 0
|
||||
for _, r := range fs.Requests() {
|
||||
if (method == "" || r.Method == method) && hasPrefix(r.Path, prefix) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Requests returns a snapshot of captured requests, in arrival order.
|
||||
func (fs *fakeServer) Requests() []capturedRequest {
|
||||
fs.mu.Lock()
|
||||
@@ -118,6 +197,40 @@ func fixtureNexusResolved(entityID, displayName, entityType string) string {
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedFlat is the flat resolve shape documented in
|
||||
// ECOSYSTEM-SPEC.md §1.5 (entity_id/entity_type/display_name at the top
|
||||
// level) rather than the nested "entity" object — the older of the two
|
||||
// wire shapes Maven must keep accepting.
|
||||
func fixtureNexusResolvedFlat(entityID, displayName, entityType string) string {
|
||||
return mustJSON(map[string]any{
|
||||
"status": "resolved",
|
||||
"entity_id": entityID,
|
||||
"entity_type": entityType,
|
||||
"display_name": displayName,
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedFuture is a resolved response from a hypothetical newer
|
||||
// Nexus: same required fields plus unknown ones. Decoding must ignore the
|
||||
// extras, not fail — forward compatibility is what lets the ecosystem be
|
||||
// upgraded one service at a time.
|
||||
func fixtureNexusResolvedFuture(entityID, displayName, entityType string) string {
|
||||
return mustJSON(map[string]any{
|
||||
"status": "resolved",
|
||||
"entity": map[string]any{"id": entityID, "display_name": displayName, "type": entityType, "tenant": "home"},
|
||||
"provenance": map[string]any{"resolver": "v3", "graph_epoch": 42},
|
||||
"score_breakdown": []any{map[string]any{"signal": "alias", "weight": 0.9}},
|
||||
})
|
||||
}
|
||||
|
||||
// fixtureNexusResolvedEmpty is the contract violation that decodes cleanly:
|
||||
// Nexus claims a resolve and delivers no entity. It must not read as "no such
|
||||
// entity", which would let the caller fall through to local execution with the
|
||||
// user's verb intact.
|
||||
func fixtureNexusResolvedEmpty() string {
|
||||
return `{"status":"resolved"}`
|
||||
}
|
||||
|
||||
func fixtureNexusNotFound() string {
|
||||
return `{"status":"not_found"}`
|
||||
}
|
||||
@@ -138,6 +251,23 @@ func fixtureHexisExecuted(id, status string) string {
|
||||
return mustJSON(map[string]any{"id": id, "status": status})
|
||||
}
|
||||
|
||||
// fixtureHexisExecutionFailed is a well-formed Hexis response reporting that
|
||||
// the command itself failed: the call succeeded, the execution did not. Maven
|
||||
// must distinguish this from a transport failure and from success.
|
||||
func fixtureHexisExecutionFailed(id, message string) string {
|
||||
return mustJSON(map[string]any{"id": id, "status": "failed", "error": message})
|
||||
}
|
||||
|
||||
// fixturePraxisAttentionScoped tags each item with an entity_id, which is what
|
||||
// a Praxis that understands the entity_id query parameter returns. A Praxis
|
||||
// that ignores it answers with untagged items from every entity.
|
||||
func fixturePraxisAttentionScoped(entityID string, items ...map[string]any) string {
|
||||
for _, item := range items {
|
||||
item["entity_id"] = entityID
|
||||
}
|
||||
return mustJSON(items)
|
||||
}
|
||||
|
||||
func fixturePraxisAttentionItems(items ...map[string]any) string {
|
||||
return mustJSON(items)
|
||||
}
|
||||
@@ -156,18 +286,28 @@ func mustJSON(v any) string {
|
||||
// (e.g. asserting age-based digest ordering without sleeping).
|
||||
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
mu sync.Mutex
|
||||
t time.Time
|
||||
step time.Duration // advanced on every read, so elapsed time is measurable
|
||||
}
|
||||
|
||||
func newFakeClock(start time.Time) *fakeClock {
|
||||
return &fakeClock{t: start}
|
||||
}
|
||||
|
||||
// newTickingClock advances by step on every read. Durations measured across
|
||||
// hops are then non-zero without sleeping, which is what lets a test tell a
|
||||
// trace that measured something from one that measured nothing.
|
||||
func newTickingClock(start time.Time, step time.Duration) *fakeClock {
|
||||
return &fakeClock{t: start, step: step}
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.t
|
||||
now := c.t
|
||||
c.t = c.t.Add(c.step)
|
||||
return now
|
||||
}
|
||||
|
||||
func (c *fakeClock) Advance(d time.Duration) {
|
||||
@@ -191,8 +331,13 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
|
||||
// fault is injected via SetFault.
|
||||
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
|
||||
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
|
||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/acknowledge": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/resolve": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/ignore": jsonHandler(http.StatusOK, `{}`),
|
||||
"POST /api/v1/tools/pin": jsonHandler(http.StatusOK, `{}`),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// mavend/feeds.go — the driver for RSS/Atom reading (Vikunja #258,
|
||||
// docs/plans/13-rss-news-feeds.md). The reader itself is pure and lives in
|
||||
// internal/rss; this is the impure half: a ticker, the guarded fetcher, and the
|
||||
// two adapters that let a pure package talk to the store.
|
||||
//
|
||||
// Why in-core rather than its own daemon like mavmaild and mavpoll: those two
|
||||
// hold a CREDENTIAL (an IMAP password, a zenmoney token), and the reason they
|
||||
// are separate processes is that core must never see it. A feed URL is public,
|
||||
// there is no secret to isolate, and a whole extra binary and compose service
|
||||
// would buy nothing. The other half of the mavpoll precedent — off unless
|
||||
// configured — is kept: no `feeds` block, no poller, no outbound request.
|
||||
//
|
||||
// It is its own goroutine, not a step on the tick: the tick has a delivery
|
||||
// deadline behind it, and a feed read is a network round-trip that nobody is
|
||||
// waiting on.
|
||||
//
|
||||
// Nothing here dispatches. A feed that announced itself would be a nag, so the
|
||||
// only output is notes with source "rss:<feed>", which the answer path reads
|
||||
// when he asks ("что нового в лентах?" — see queryFeeds in actions_query.go).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/stt"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// feedWorker — ticker + poller.
|
||||
type feedWorker struct {
|
||||
poller *rss.Poller
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// feedTickInterval — how often the worker asks the poller what is due. Per-feed
|
||||
// cadence is the poller's business; this is just the granularity.
|
||||
const feedTickInterval = 5 * time.Minute
|
||||
|
||||
// newFeedWorker wires feed reading, or returns nil when it must not run:
|
||||
// no `feeds` block (the normal case), or nothing valid in it. Every caller
|
||||
// checks for nil.
|
||||
func newFeedWorker(api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *feedWorker {
|
||||
if cfg.Feeds == nil {
|
||||
return nil
|
||||
}
|
||||
fc := cfg.Feeds
|
||||
|
||||
feeds := make([]rss.FeedConfig, 0, len(fc.Sources))
|
||||
hosts := append([]string(nil), fc.AllowHosts...)
|
||||
for _, s := range fc.Sources {
|
||||
feeds = append(feeds, rss.FeedConfig{
|
||||
Name: s.Name,
|
||||
URL: s.URL,
|
||||
Category: s.Category,
|
||||
Interval: time.Duration(s.Interval),
|
||||
Include: s.Include,
|
||||
Exclude: s.Exclude,
|
||||
})
|
||||
// Each configured feed's own host is allowed. The allowlist is then
|
||||
// exactly "the feeds he asked for", so a redirect off to somewhere else
|
||||
// is refused by the fetcher rather than followed.
|
||||
if u, err := url.Parse(s.URL); err == nil && u.Hostname() != "" {
|
||||
hosts = append(hosts, u.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
fetcher := webfetch.New(webfetch.Config{
|
||||
AllowHosts: hosts,
|
||||
Timeout: time.Duration(fc.Timeout),
|
||||
MaxBytes: fc.MaxBytes,
|
||||
})
|
||||
poller := rss.NewPoller(feeds, &feedFetcher{f: fetcher}, api, &factMarks{api: api},
|
||||
embedderFor(emb), nil, rss.Config{
|
||||
DefaultInterval: time.Duration(fc.PollInterval),
|
||||
MaxItems: fc.MaxItems,
|
||||
MaxAge: time.Duration(fc.MaxAge),
|
||||
})
|
||||
if poller == nil {
|
||||
log.Printf("feeds: configured but nothing pollable — feed reading disabled")
|
||||
return nil
|
||||
}
|
||||
log.Printf("feeds: reading %d feed(s), checking what is due every %s", len(feeds), feedTickInterval)
|
||||
return &feedWorker{poller: poller, interval: feedTickInterval}
|
||||
}
|
||||
|
||||
// run polls what is due until ctx is canceled. The first round runs immediately
|
||||
// so a restart does not blind her for the first interval; it writes notes only,
|
||||
// so an early round cannot startle anyone.
|
||||
func (w *feedWorker) run(ctx context.Context) {
|
||||
w.poller.PollDue(ctx, time.Now())
|
||||
t := time.NewTicker(w.interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
w.poller.PollDue(ctx, now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// embedderOf — the voice wiring's embedder, or nil when voice is not wired.
|
||||
// Feed notes are embedded with the SAME model the rest of the store uses, or not
|
||||
// at all; a second embedder would write vectors nothing can search.
|
||||
func embedderOf(w *voiceWiring) router.Embedder {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.embedder
|
||||
}
|
||||
|
||||
// transcriberOf — the STT the voice path is using, or nil when voice is off.
|
||||
// The meeting recorder reuses it rather than dialling mavsttd a second time:
|
||||
// Maven has one speech-to-text engine and adding a second would mean two
|
||||
// whisper contexts competing for the same iGPU.
|
||||
func transcriberOf(w *voiceWiring) stt.Transcriber {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.transcriber
|
||||
}
|
||||
|
||||
// feedFetcher adapts webfetch to rss.Fetcher — the pure package names the two
|
||||
// fields it needs and stays free of net/http.
|
||||
type feedFetcher struct{ f *webfetch.Fetcher }
|
||||
|
||||
func (a *feedFetcher) Get(ctx context.Context, u string) (*rss.Body, error) {
|
||||
resp, err := a.f.Get(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rss.Body{Bytes: resp.Body}, nil
|
||||
}
|
||||
|
||||
// factMarks stores "how far this feed was read" as a config fact, the same
|
||||
// mechanism the plan named and the same one the pattern tick uses for its own
|
||||
// bookkeeping. Durable, inspectable on /dash, and cheap.
|
||||
type factMarks struct{ api ipc.CoreAPI }
|
||||
|
||||
func markKey(feed string) string { return "rss:latest:" + feed }
|
||||
|
||||
func (m *factMarks) LastMark(ctx context.Context, feed string) (time.Time, error) {
|
||||
f, err := m.api.LatestFact(ctx, markKey(feed))
|
||||
if err != nil {
|
||||
// No mark yet is not an error worth propagating: the poller treats a
|
||||
// zero time as a cold start.
|
||||
return time.Time{}, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, f.Value)
|
||||
if err != nil {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (m *factMarks) SetMark(ctx context.Context, feed string, at time.Time) error {
|
||||
_, err := m.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: time.Now(),
|
||||
Kind: "config",
|
||||
Key: markKey(feed),
|
||||
Value: at.UTC().Format(time.RFC3339),
|
||||
Source: "poll:rss",
|
||||
Confidence: 1.0,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// embedderFor adapts router.Embedder to rss.Embedder, and returns nil when
|
||||
// there is none — a note without a vector is still a note the recent-notes path
|
||||
// can read.
|
||||
//
|
||||
// EmbedPassage, not Embed: a feed item is text being searched FOR, and the e5
|
||||
// embedder is asymmetric. Getting this backwards makes the item unfindable by
|
||||
// the question that should have matched it.
|
||||
func embedderFor(emb router.Embedder) rss.Embedder {
|
||||
if emb == nil {
|
||||
return nil
|
||||
}
|
||||
return passageEmbedder{emb}
|
||||
}
|
||||
|
||||
type passageEmbedder struct{ e router.Embedder }
|
||||
|
||||
func (p passageEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
|
||||
return router.EmbedPassage(ctx, p.e, text)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// buildFeedHandler — a handler with the given feed notes already stored. No
|
||||
// embedder: the feed source answers from recent notes by source, which is what
|
||||
// makes it work for notes written before an embedder existed.
|
||||
func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHandler {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
for i, n := range notes {
|
||||
ts := now.Add(time.Duration(i) * time.Minute)
|
||||
if _, err := st.WriteNote(ctx, ts, n.Text, nil, n.Source); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
}
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
replier: voice.NewStubReplier(),
|
||||
phraser: phraser.NewStub(),
|
||||
now: func() time.Time { return now },
|
||||
feedsOn: feedsOn,
|
||||
embedder: nil,
|
||||
}
|
||||
}
|
||||
|
||||
func askFeeds(t *testing.T, h *reactiveHandler, q string) (string, bool) {
|
||||
t.Helper()
|
||||
return h.queryFeeds(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
|
||||
})
|
||||
}
|
||||
|
||||
func TestQueryFeedsReadsFeedNotes(t *testing.T) {
|
||||
h := buildFeedHandler(t, true,
|
||||
ipc.Note{Text: "Новая уязвимость в ядре [технологии]\nпатч вышел\nhttps://example.org/a", Source: "rss:habr"},
|
||||
ipc.Note{Text: "что-то он сам сказал", Source: "tap:voice"},
|
||||
)
|
||||
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
||||
if !ok {
|
||||
t.Fatal("the feed source did not claim the question")
|
||||
}
|
||||
if !strings.Contains(reply, "уязвимость") {
|
||||
t.Errorf("reply = %q, want the headline", reply)
|
||||
}
|
||||
if strings.Contains(reply, "он сам сказал") {
|
||||
t.Errorf("a note he dictated leaked into the feed answer: %q", reply)
|
||||
}
|
||||
// She reads the headline, not the summary and not the URL.
|
||||
if strings.Contains(reply, "https://") || strings.Contains(reply, "патч вышел") {
|
||||
t.Errorf("reply = %q, want the title line only", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFeedsByCategory(t *testing.T) {
|
||||
h := buildFeedHandler(t, true,
|
||||
ipc.Note{Text: "Релиз ядра [технологии]", Source: "rss:habr"},
|
||||
ipc.Note{Text: "Выборы отложены [политика]", Source: "rss:news"},
|
||||
)
|
||||
reply, ok := askFeeds(t, h, "что нового по технологиям?")
|
||||
if !ok {
|
||||
t.Fatal("not claimed")
|
||||
}
|
||||
if !strings.Contains(reply, "ядра") || strings.Contains(reply, "Выборы") {
|
||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||
}
|
||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||
if !strings.Contains(reply, "ничего") {
|
||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||
// answered by the model inventing a bulletin.
|
||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||
off := buildFeedHandler(t, false)
|
||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "не настроены") {
|
||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
on := buildFeedHandler(t, true)
|
||||
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||
h := buildFeedHandler(t, true)
|
||||
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
|
||||
t.Fatalf("claimed an unrelated question with %q", reply)
|
||||
}
|
||||
// The bare greeting is not a request for headlines. It used to be answered
|
||||
// with a configuration status.
|
||||
if reply, ok := askFeeds(t, h, "что нового?"); ok {
|
||||
t.Fatalf("claimed a greeting with %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A busy day of his own notes must not push the newest headline out of the
|
||||
// window the feed answer scans.
|
||||
func TestQueryFeedsIsNotCrowdedOutByHisOwnNotes(t *testing.T) {
|
||||
notes := []ipc.Note{{Text: "Релиз ядра [технологии]", Source: "rss:habr"}}
|
||||
for i := 0; i < feedNoteWindow+10; i++ {
|
||||
notes = append(notes, ipc.Note{Text: "мысль вслух", Source: "tap:voice"})
|
||||
}
|
||||
h := buildFeedHandler(t, true, notes...)
|
||||
reply, ok := askFeeds(t, h, "что нового в лентах?")
|
||||
if !ok || !strings.Contains(reply, "ядра") {
|
||||
t.Fatalf("reply = %q, ok = %v; the headline fell out of the window", reply, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// The mark is what stops a restart from re-noting yesterday's headlines, so the
|
||||
// fact round-trip is worth a test of its own.
|
||||
func TestFactMarksRoundTrip(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
m := &factMarks{api: ipc.NewStoreAPI(st)}
|
||||
ctx := context.Background()
|
||||
|
||||
at, err := m.LastMark(ctx, "habr")
|
||||
if err != nil || !at.IsZero() {
|
||||
t.Fatalf("no mark yet: got %v, %v — want zero time and no error", at, err)
|
||||
}
|
||||
want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
|
||||
if err := m.SetMark(ctx, "habr", want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := m.LastMark(ctx, "habr")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("mark = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured, checked at the wiring seam: no `feeds` block ⇒ no
|
||||
// worker ⇒ no outbound request is possible.
|
||||
func TestNewFeedWorkerOffByDefault(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
if w := newFeedWorker(api, nil, &config.Config{}); w != nil {
|
||||
t.Fatal("a config with no feeds block wired a feed worker")
|
||||
}
|
||||
// An empty sources list is normalised to "off" by config.Load; the worker
|
||||
// refuses it too, so a hand-built Config cannot switch it on by accident.
|
||||
if w := newFeedWorker(api, nil, &config.Config{Feeds: &config.FeedsConfig{}}); w != nil {
|
||||
t.Fatal("an empty sources list wired a feed worker")
|
||||
}
|
||||
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||
{Name: "habr", URL: "https://example.org/rss"},
|
||||
}}}
|
||||
w := newFeedWorker(api, nil, cfg)
|
||||
if w == nil {
|
||||
t.Fatal("a configured feed did not wire a worker")
|
||||
}
|
||||
if got := w.poller.Feeds(); len(got) != 1 || got[0].Name != "habr" {
|
||||
t.Fatalf("feeds = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The fetcher the worker builds must be allowlisted to the configured feeds and
|
||||
// nothing else — the crawler's SSRF guards are only worth as much as the
|
||||
// allowlist handed to them.
|
||||
func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
||||
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
|
||||
{Name: "habr", URL: "https://feeds.example.org/rss"},
|
||||
}}}
|
||||
w := newFeedWorker(ipc.NewStoreAPI(newTestStore(t)), nil, cfg)
|
||||
if w == nil {
|
||||
t.Fatal("no worker")
|
||||
}
|
||||
// PollFeed goes through the guarded fetcher; a feed URL pointing at the box
|
||||
// itself must fail rather than be read.
|
||||
_, err := w.poller.PollFeed(context.Background(), rss.FeedConfig{
|
||||
Name: "evil", URL: "http://127.0.0.1:9100/mcp",
|
||||
}, time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("the poller fetched a private address")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// mavend/intake.go — the unified event intake envelope, wired (Vikunja #283).
|
||||
//
|
||||
// internal/event defines the envelope and the bounded in-memory journal. This
|
||||
// file is the one place that FILLS it, and the reason it is one place is worth
|
||||
// stating, because the alternative was eight patches:
|
||||
//
|
||||
// Every intake path in Maven already converges on three writes, and all three
|
||||
// are ipc.CoreAPI methods —
|
||||
//
|
||||
// WriteFact ← POST /api/ambient, mavcaldav, mavpoll's zenmoney + wg reads,
|
||||
// /api/signal presence probes, the RSS/crawl watermarks
|
||||
// WriteNote ← the RSS poller, the page crawler, meeting transcripts,
|
||||
// image descriptions
|
||||
// CaptureTask ← the voice path, the web form, and the mail reader
|
||||
//
|
||||
// — so decorating that ONE interface with a publish covers the lot without a
|
||||
// caller knowing about events at all. cmd/mavmaild, cmd/mavcaldav, cmd/mavpoll,
|
||||
// cmd/mavweb and the in-core feed/crawl/capture/vision workers are unchanged:
|
||||
// they call the same interface they always called, and it now also narrates.
|
||||
//
|
||||
// The exception is cmd/mavend/mail.go, which reaches past the interface to
|
||||
// st.CaptureTask directly. It publishes explicitly; see mailIntake.ingest.
|
||||
//
|
||||
// # Production behaviour when nobody is watching
|
||||
//
|
||||
// A nil *event.Bus makes Publish a no-op, and newIntakeAPI with a nil bus
|
||||
// returns the wrapped API unchanged, so there is not even a decorator on the
|
||||
// call path. The journal is memory-only and is never consulted by the tick
|
||||
// loop, the router, or delivery — nothing Maven says depends on it. It is a
|
||||
// read surface (`/events`, `recent_events`) and an observation seam for the
|
||||
// simulator.
|
||||
//
|
||||
// # What is deliberately NOT here
|
||||
//
|
||||
// No dispatch. An event is a report that something arrived, never an
|
||||
// instruction to speak: "a feed item appeared" becoming a notification is the
|
||||
// nag this repo refuses. Digestion may one day read the journal; it will still
|
||||
// go through internal/loop's rules and the severity/presence routing table.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// newEventBus builds the journal, or returns nil when the operator turned it
|
||||
// off (a negative config.intake_journal). nil is the "behave exactly as before"
|
||||
// value all the way down: no decorator, no ring, no /events rows.
|
||||
func newEventBus(cfg *config.Config) *event.Bus {
|
||||
if cfg == nil {
|
||||
// No config at all is a test, not an operator decision. Saying "off"
|
||||
// here was noise in every suite that passes nil.
|
||||
return nil
|
||||
}
|
||||
if cfg.IntakeJournal < 0 {
|
||||
log.Printf("intake journal: off (intake_journal < 0)")
|
||||
return nil
|
||||
}
|
||||
n := cfg.IntakeJournal
|
||||
if n == 0 {
|
||||
n = config.DefaultIntakeJournal
|
||||
}
|
||||
log.Printf("intake journal: keeping the last %d intake events in memory", n)
|
||||
return event.NewBus(n)
|
||||
}
|
||||
|
||||
// intakeEventsFn is the daemonAPI.getEvents closure: the bus's ring rendered as
|
||||
// the wire type. Returns nil for a nil bus, which the daemonAPI reports as an
|
||||
// empty journal rather than an error.
|
||||
func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent {
|
||||
if bus == nil {
|
||||
return nil
|
||||
}
|
||||
return func(n int) []ipc.IntakeEvent {
|
||||
evs := bus.Recent(n)
|
||||
out := make([]ipc.IntakeEvent, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
out = append(out, ipc.IntakeEvent{
|
||||
Source: e.Source,
|
||||
Kind: e.Kind,
|
||||
EntityIDs: e.EntityIDs,
|
||||
Title: e.Title,
|
||||
Body: e.Body,
|
||||
Priority: e.Priority,
|
||||
OccurredAt: e.OccurredAt,
|
||||
NoticedAt: e.NoticedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// intakeAPI decorates a CoreAPI, publishing one envelope per successful
|
||||
// intake write. Embedding the interface means every other method passes
|
||||
// through untouched, and a new CoreAPI method is inherited rather than
|
||||
// silently dropped.
|
||||
type intakeAPI struct {
|
||||
ipc.CoreAPI
|
||||
bus *event.Bus
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newIntakeAPI wraps api so its intake writes are journalled. A nil bus
|
||||
// returns api itself — no decorator, no allocation, no behaviour change.
|
||||
func newIntakeAPI(api ipc.CoreAPI, bus *event.Bus, now func() time.Time) ipc.CoreAPI {
|
||||
if bus == nil || api == nil {
|
||||
return api
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &intakeAPI{CoreAPI: api, bus: bus, now: now}
|
||||
}
|
||||
|
||||
// WriteFact journals the fact after it lands. Order matters: an event is a
|
||||
// report of something that HAPPENED, so a failed write publishes nothing.
|
||||
func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
id, err := a.CoreAPI.WriteFact(ctx, req)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
if selfWrite(req) {
|
||||
// Maven's own bookkeeping is not something that arrived. The feed
|
||||
// watermark, the crawl hash, the praxis trace of an act she performed
|
||||
// and a quiet-hours toggle he pressed all used to sit on a page headed
|
||||
// "everything that arrived", and on a cold start a handful of feeds
|
||||
// could evict real intake behind their marks.
|
||||
return id, nil
|
||||
}
|
||||
// OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake
|
||||
// instant and the ambient path carries the meeting's start. Flattening
|
||||
// those to notice-time would make the journal lie about when things
|
||||
// happened, which is the one thing it is for.
|
||||
title := req.Key
|
||||
if req.VoidsID != nil {
|
||||
// A retraction is not a reading. Without this it published an envelope
|
||||
// indistinguishable from a fresh value for the same key, on a page
|
||||
// whose whole job is "what came in".
|
||||
title = "отмена: " + req.Key
|
||||
}
|
||||
a.bus.Publish(event.Event{
|
||||
Source: req.Source,
|
||||
Kind: event.SourceKind(req.Source, event.KindFact),
|
||||
Title: title,
|
||||
Body: req.Value,
|
||||
Priority: factPriority(req),
|
||||
OccurredAt: req.Ts,
|
||||
EntityIDs: entityIDs(req.Subject),
|
||||
Payload: factPayload(req),
|
||||
}, a.now())
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// WriteNote journals a note. This is the RSS and crawler path, and also the
|
||||
// meeting transcript and image description paths, which write their derived
|
||||
// text as ordinary notes.
|
||||
func (a *intakeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
id, err := a.CoreAPI.WriteNote(ctx, ts, text, embedding, source)
|
||||
if err != nil {
|
||||
return id, err
|
||||
}
|
||||
title, body := splitFirstLine(text)
|
||||
a.bus.Publish(event.Event{
|
||||
Source: source,
|
||||
Kind: event.SourceKind(source, event.KindNote),
|
||||
Title: title,
|
||||
Body: body,
|
||||
Priority: event.PriorityLow,
|
||||
OccurredAt: ts,
|
||||
}, a.now())
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CaptureTask journals a captured task, but only when a row was actually
|
||||
// created. CaptureTask dedupes on normalised text among live rows, so a
|
||||
// mailbox re-read after a restart must not refill the journal with tasks that
|
||||
// were already there.
|
||||
func (a *intakeAPI) CaptureTask(ctx context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
resp, err := a.CoreAPI.CaptureTask(ctx, req)
|
||||
if err != nil || !resp.Created {
|
||||
return resp, err
|
||||
}
|
||||
a.bus.Publish(publishableTask(store.Task{
|
||||
CreatedTs: req.Ts,
|
||||
Text: req.Text,
|
||||
Source: req.Source,
|
||||
Evidence: req.Evidence,
|
||||
Status: req.Status,
|
||||
Due: req.Due,
|
||||
}, a.now()), a.now())
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// publishableTask is the task→envelope shape, shared with mail.go, which
|
||||
// captures through the store directly rather than through the interface.
|
||||
//
|
||||
// Priority is high for a candidate with a due date and normal otherwise. That
|
||||
// is the only place this file makes a judgement, and it is a display hint on a
|
||||
// review page — nothing routes on it.
|
||||
func publishableTask(t store.Task, now time.Time) event.Event {
|
||||
occurred := t.CreatedTs
|
||||
if occurred.IsZero() {
|
||||
occurred = now
|
||||
}
|
||||
prio := event.PriorityNormal
|
||||
if t.Due != nil {
|
||||
prio = event.PriorityHigh
|
||||
}
|
||||
return event.Event{
|
||||
Source: t.Source,
|
||||
Kind: event.KindTask,
|
||||
Title: t.Text,
|
||||
Body: t.Evidence,
|
||||
Priority: prio,
|
||||
OccurredAt: occurred,
|
||||
}
|
||||
}
|
||||
|
||||
// selfWrite reports whether a fact write is Maven describing her own state
|
||||
// rather than something arriving from outside. The store's fact kinds are
|
||||
// 'self', 'env' and 'config'; 'config' is where every watermark and toggle
|
||||
// lands, and the praxis trace is an audit record of an act she performed, which
|
||||
// is the same class of thing under an 'env' kind.
|
||||
func selfWrite(req ipc.WriteFactReq) bool {
|
||||
switch req.Kind {
|
||||
case "config", "system":
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(req.Source, "praxis:trace")
|
||||
}
|
||||
|
||||
// factPriority is the attention hint for a fact write. Deliberately crude:
|
||||
// a low-confidence inference (the ambient notification path writes below 1.0)
|
||||
// is worth less attention than a read he or a credentialled poller made, and a
|
||||
// retraction is a correction rather than news.
|
||||
//
|
||||
// Confidence is NOT recoverable from this, which is why the number itself goes
|
||||
// into Payload: three display buckets must not be the only surviving trace of
|
||||
// the distinction internal/calendar went out of its way to keep.
|
||||
func factPriority(req ipc.WriteFactReq) string {
|
||||
if req.VoidsID != nil {
|
||||
return event.PriorityLow
|
||||
}
|
||||
if req.Confidence > 0 && req.Confidence < 1.0 {
|
||||
return event.PriorityLow
|
||||
}
|
||||
return event.PriorityNormal
|
||||
}
|
||||
|
||||
// factDetail is the fact-shaped Payload: the fields the envelope's own flat
|
||||
// shape cannot carry, kept so a reader can tell an inference from a
|
||||
// credentialled read, and "nobody said" from "certain".
|
||||
type factDetail struct {
|
||||
// FactKind — the fact's own kind ('self', 'env', 'config'), a different
|
||||
// taxonomy from Event.Kind.
|
||||
FactKind string `json:"fact_kind,omitempty"`
|
||||
// Confidence — the number itself, so an inference stays distinguishable
|
||||
// from a credentialled read. nil when the writer set none, which the ipc
|
||||
// layer rejects today; the pointer keeps "nobody said" and "certain" from
|
||||
// collapsing into each other the way the priority bucket does.
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
// VoidsID — the fact this one retracts.
|
||||
VoidsID *int64 `json:"voids_id,omitempty"`
|
||||
}
|
||||
|
||||
func factPayload(req ipc.WriteFactReq) json.RawMessage {
|
||||
d := factDetail{FactKind: req.Kind, VoidsID: req.VoidsID}
|
||||
if req.Confidence != 0 {
|
||||
c := req.Confidence
|
||||
d.Confidence = &c
|
||||
}
|
||||
if d.FactKind == "" && d.Confidence == nil && d.VoidsID == nil {
|
||||
return nil
|
||||
}
|
||||
b, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// entityIDs turns a fact's free-text Subject into the EntityIDs slot when it
|
||||
// already looks resolved. Intake runs BEFORE the fact enrichment worker
|
||||
// resolves a subject against Nexus, so this is almost always empty — the slot
|
||||
// exists for the paths that do know (the ecosystem acts), not for guessing.
|
||||
func entityIDs(subject string) []string {
|
||||
subject = strings.TrimSpace(subject)
|
||||
if subject == "" || !strings.HasPrefix(subject, "entity:") {
|
||||
return nil
|
||||
}
|
||||
return []string{strings.TrimPrefix(subject, "entity:")}
|
||||
}
|
||||
|
||||
// splitFirstLine renders a note as title + body. Feed and crawl notes are
|
||||
// written "headline\nsummary\nlink", so the first line is already the title.
|
||||
func splitFirstLine(text string) (title, body string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if i := strings.IndexByte(text, '\n'); i >= 0 {
|
||||
return strings.TrimSpace(text[:i]), strings.TrimSpace(text[i+1:])
|
||||
}
|
||||
return text, ""
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
var intakeNow = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
func intakeClock() time.Time { return intakeNow }
|
||||
|
||||
// failingAPI wraps the store adapter, failing the three intake writes on
|
||||
// demand, so the "a failed write publishes nothing" invariant is testable.
|
||||
type failingAPI struct {
|
||||
ipc.CoreAPI
|
||||
fail bool
|
||||
}
|
||||
|
||||
func (f *failingAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
if f.fail {
|
||||
return 0, errors.New("injected")
|
||||
}
|
||||
return f.CoreAPI.WriteFact(ctx, req)
|
||||
}
|
||||
|
||||
func newIntakeTestAPI(t *testing.T) (ipc.CoreAPI, *event.Bus) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
bus := event.NewBus(32)
|
||||
return newIntakeAPI(ipc.NewStoreAPI(st), bus, intakeClock), bus
|
||||
}
|
||||
|
||||
func TestIntakeAPIWithoutBusIsTheBareAPI(t *testing.T) {
|
||||
// The adoption invariant: with the journal off there is not even a
|
||||
// decorator on the intake path, so production behaves exactly as before.
|
||||
st := newTestStore(t)
|
||||
bare := ipc.NewStoreAPI(st)
|
||||
if got := newIntakeAPI(bare, nil, intakeClock); got != ipc.CoreAPI(bare) {
|
||||
t.Errorf("newIntakeAPI with a nil bus returned a wrapper, want the bare API")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEventBusOffWhenNegative(t *testing.T) {
|
||||
if b := newEventBus(&config.Config{IntakeJournal: -1}); b != nil {
|
||||
t.Error("intake_journal = -1 still built a bus")
|
||||
}
|
||||
if b := newEventBus(&config.Config{IntakeJournal: 4}); b == nil {
|
||||
t.Error("intake_journal = 4 built no bus")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeJournalsAFactWrite(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
// The ambient path's shape: an env fact below full confidence, timestamped
|
||||
// at the meeting's start rather than at notice time.
|
||||
start := intakeNow.Add(2 * time.Hour)
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: start, Kind: "env", Key: "calendar_event_20260801_планёрка",
|
||||
Value: "10:00-11:00 планёрка", Source: "ambient:notif", Confidence: 0.6,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
got := bus.Recent(0)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||
}
|
||||
e := got[0]
|
||||
if e.Source != "ambient:notif" || e.Kind != event.KindFact {
|
||||
t.Errorf("source/kind = %q/%q", e.Source, e.Kind)
|
||||
}
|
||||
if e.Title != "calendar_event_20260801_планёрка" {
|
||||
t.Errorf("title = %q, want the fact key", e.Title)
|
||||
}
|
||||
if !e.OccurredAt.Equal(start) {
|
||||
t.Errorf("occurred_at = %v, want the fact's Ts %v — the journal must not flatten intake to notice time", e.OccurredAt, start)
|
||||
}
|
||||
if e.Priority != event.PriorityLow {
|
||||
t.Errorf("priority = %q, want %q for a sub-1.0 confidence read", e.Priority, event.PriorityLow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeDoesNotJournalAFailedWrite(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
bus := event.NewBus(8)
|
||||
api := newIntakeAPI(&failingAPI{CoreAPI: ipc.NewStoreAPI(st), fail: true}, bus, intakeClock)
|
||||
if _, err := api.WriteFact(context.Background(), ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "k", Value: "v", Source: "poll:zenmoney", Confidence: 1,
|
||||
}); err == nil {
|
||||
t.Fatal("expected the injected error")
|
||||
}
|
||||
if bus.Len() != 0 {
|
||||
t.Errorf("journal has %d entries after a failed write, want 0 — an event reports something that happened", bus.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeJournalsANoteAsTitlePlusBody(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
// The RSS shape: "headline\nsummary\nlink".
|
||||
if _, err := api.WriteNote(context.Background(), intakeNow,
|
||||
"Вышло ядро 6.19\nкраткое содержание\nhttps://example.org/a", nil, "rss:tech"); err != nil {
|
||||
t.Fatalf("WriteNote: %v", err)
|
||||
}
|
||||
got := bus.Recent(1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("journal has %d entries, want 1", len(got))
|
||||
}
|
||||
if got[0].Title != "Вышло ядро 6.19" {
|
||||
t.Errorf("title = %q, want the headline", got[0].Title)
|
||||
}
|
||||
if got[0].Kind != event.KindNote {
|
||||
t.Errorf("kind = %q, want %q", got[0].Kind, event.KindNote)
|
||||
}
|
||||
if got[0].Body == "" {
|
||||
t.Error("body is empty, want the rest of the note")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeJournalsOnlyCreatedTasks(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
req := ipc.CaptureTaskReq{Text: "оплатить интернет", Source: "email:inbox", Status: "candidate", Ts: intakeNow}
|
||||
if _, err := api.CaptureTask(ctx, req); err != nil {
|
||||
t.Fatalf("CaptureTask: %v", err)
|
||||
}
|
||||
// Same text again: CaptureTask dedupes among live rows, and a re-read of a
|
||||
// mailbox must not refill the journal.
|
||||
resp, err := api.CaptureTask(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("CaptureTask (repeat): %v", err)
|
||||
}
|
||||
if resp.Created {
|
||||
t.Fatal("store did not dedupe; the test cannot check what it means to")
|
||||
}
|
||||
if bus.Len() != 1 {
|
||||
t.Errorf("journal has %d entries, want 1 — a deduped capture must not publish", bus.Len())
|
||||
}
|
||||
if got := bus.Recent(1)[0]; got.Kind != event.KindTask || got.Title != "оплатить интернет" {
|
||||
t.Errorf("entry = %+v, want the captured task", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeEventsFnRendersNewestFirst(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
for _, key := range []string{"a", "b", "c"} {
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: key, Value: "1", Source: "poll:zenmoney", Confidence: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("WriteFact %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
fn := intakeEventsFn(bus)
|
||||
got := fn(2)
|
||||
if len(got) != 2 || got[0].Title != "c" || got[1].Title != "b" {
|
||||
t.Errorf("intakeEventsFn(2) = %+v, want the two newest, newest first", got)
|
||||
}
|
||||
if intakeEventsFn(nil) != nil {
|
||||
t.Error("intakeEventsFn(nil) returned a closure, want nil so daemonAPI reports an empty journal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
|
||||
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||
got, err := d.RecentEvents(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentEvents with no journal errored: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("got %d events, want none", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// Maven's own bookkeeping is not intake. The feed watermark, the crawl hash,
|
||||
// the praxis trace of an act she performed and the quiet-hours toggle he
|
||||
// pressed all landed on a page headed "everything that arrived", and on a cold
|
||||
// start a handful of feeds could evict real intake behind their marks.
|
||||
func TestIntakeSkipsHerOwnBookkeeping(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
for _, req := range []ipc.WriteFactReq{
|
||||
{Ts: intakeNow, Kind: "config", Key: "rss:latest:tech", Value: "2026-08-01T09:00:00Z", Source: "poll:rss", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "config", Key: "crawl:hash:kernel", Value: "deadbeef", Source: "poll:crawl", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "config", Key: "quiet_hours", Value: "true", Source: "tap:voice", Confidence: 1.0},
|
||||
{Ts: intakeNow, Kind: "env", Key: "praxis:list_attention", Value: "ok", Source: "praxis:trace", Confidence: 1.0},
|
||||
} {
|
||||
if _, err := api.WriteFact(ctx, req); err != nil {
|
||||
t.Fatalf("WriteFact(%s): %v", req.Key, err)
|
||||
}
|
||||
}
|
||||
if n := bus.Len(); n != 0 {
|
||||
t.Fatalf("journalled %d bookkeeping writes, want 0: %+v", n, bus.Recent(0))
|
||||
}
|
||||
// A real arrival under the same decorator still lands.
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "spend_today", Value: "1200",
|
||||
Source: "poll:zenmoney", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bus.Len() != 1 {
|
||||
t.Fatalf("a real intake write was dropped: %+v", bus.Recent(0))
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence is the distinction between an inference and a credentialled read,
|
||||
// and the three-value priority bucket cannot carry it: unset and 1.0 land in
|
||||
// the same bucket, and 0.6 is gone entirely once mapped. Payload keeps it.
|
||||
func TestIntakeCarriesConfidenceAndFactKind(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "calendar_event_x", Value: "18:00 планёрка",
|
||||
Source: "ambient:notif", Confidence: 0.6,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "self", Key: "mood", Value: "ok", Source: "tap:web", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := bus.Recent(0)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("journal has %d entries, want 2", len(got))
|
||||
}
|
||||
var relayed, stated factDetail
|
||||
if err := json.Unmarshal(got[1].Payload, &relayed); err != nil {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
if relayed.Confidence == nil || *relayed.Confidence != 0.6 {
|
||||
t.Errorf("confidence = %v, want 0.6 recoverable from the payload", relayed.Confidence)
|
||||
}
|
||||
if relayed.FactKind != "env" {
|
||||
t.Errorf("fact_kind = %q, want env", relayed.FactKind)
|
||||
}
|
||||
// Both writes land in PriorityNormal or PriorityLow buckets that cannot be
|
||||
// told apart from the outside; the payload is where the two numbers stay
|
||||
// distinguishable.
|
||||
if err := json.Unmarshal(got[0].Payload, &stated); err != nil {
|
||||
t.Fatalf("payload: %v", err)
|
||||
}
|
||||
if stated.Confidence == nil || *stated.Confidence != 1.0 || stated.FactKind != "self" {
|
||||
t.Errorf("payload = %+v, want confidence 1.0 and fact_kind self", stated)
|
||||
}
|
||||
}
|
||||
|
||||
// A retraction is not an observation. It used to publish an envelope
|
||||
// indistinguishable from a fresh reading of the same key.
|
||||
func TestIntakeMarksARetraction(t *testing.T) {
|
||||
api, bus := newIntakeTestAPI(t)
|
||||
ctx := context.Background()
|
||||
id, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "weight", Value: "82", Source: "tap:web", Confidence: 1.0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: intakeNow, Kind: "env", Key: "weight", Value: "81", Source: "tap:web",
|
||||
Confidence: 1.0, VoidsID: &id,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := bus.Recent(1)[0]
|
||||
if e.Priority != event.PriorityLow {
|
||||
t.Errorf("priority = %q, want low for a correction", e.Priority)
|
||||
}
|
||||
if !strings.HasPrefix(e.Title, "отмена:") {
|
||||
t.Errorf("title = %q, want it marked as a retraction", e.Title)
|
||||
}
|
||||
var d factDetail
|
||||
if err := json.Unmarshal(e.Payload, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.VoidsID == nil || *d.VoidsID != id {
|
||||
t.Errorf("voids_id = %v, want %d", d.VoidsID, id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
// Writing the wrapped-key blob (Vikunja #14).
|
||||
//
|
||||
// The blob is the only thing that opens the database on a cold-started box, so
|
||||
// the two rules here are about not losing it.
|
||||
//
|
||||
// # It is rewritten on every assertion, so the write must be atomic
|
||||
//
|
||||
// mavweb calls StoreEncryptionKey after every successful assertion, not only
|
||||
// after enrolment. os.WriteFile truncates in place: a power cut or an OOM kill
|
||||
// between the truncate and the write left a zero-length blob and no previous
|
||||
// contents, on the path of every routine step-up. Write to a temp file in the
|
||||
// same directory, fsync it, rename over the target, then fsync the directory.
|
||||
//
|
||||
// # Only one authenticator can hold the cold-start key
|
||||
//
|
||||
// A blob is wrapped under one credential's PRF output and nothing else opens
|
||||
// it. mavweb sends an empty allowCredentials list and the credential store
|
||||
// keeps more than one passkey, so an unconditional rewrite meant the last
|
||||
// authenticator to assert silently locked out every other one — including the
|
||||
// backup hardware key enrolled for exactly the cold-start case. So: a blob
|
||||
// that already opens under this secret and already wraps this key is left
|
||||
// alone, a v1 blob is upgraded in place, and a v2 blob belonging to a
|
||||
// different credential is refused rather than overwritten.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// errForeignBlob — the wrapped key on disk belongs to another credential.
|
||||
// Refusing is the point: overwriting would lock that authenticator out.
|
||||
var errForeignBlob = errors.New("wrapped key belongs to a different credential")
|
||||
|
||||
// wrapKeyToFile wraps key under secret and persists it at path, unless the
|
||||
// blob already there says not to. Reports whether it wrote anything.
|
||||
func wrapKeyToFile(path string, key, secret []byte) (wrote bool, err error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
plain, version, uerr := webauthn.UnwrapKey(existing, secret)
|
||||
switch {
|
||||
case uerr == nil && version == webauthn.BlobV2 && bytes.Equal(plain, key):
|
||||
// Already wrapped under this secret, around this key. The
|
||||
// common case on every assertion after the first.
|
||||
return false, nil
|
||||
case uerr != nil && version == webauthn.BlobV2:
|
||||
return false, fmt.Errorf("%w: %s does not open under this assertion's PRF output, so another passkey holds the cold-start key; delete it deliberately to re-wrap", errForeignBlob, path)
|
||||
}
|
||||
// A v1 blob (upgrade it), or a v2 blob wrapping a stale key under
|
||||
// this same secret (the key was rotated). Both are rewrites.
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
// First wrap.
|
||||
default:
|
||||
return false, fmt.Errorf("read wrapped key: %w", err)
|
||||
}
|
||||
|
||||
blob, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("wrap encryption key: %w", err)
|
||||
}
|
||||
if err := writeFileAtomic(path, blob, 0o600); err != nil {
|
||||
return false, fmt.Errorf("write wrapped key: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path so that a reader sees either the whole
|
||||
// new file or the whole old one, never a truncated blob.
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
f, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := f.Name()
|
||||
defer os.Remove(tmp) // no-op once the rename succeeded
|
||||
|
||||
if err := f.Chmod(perm); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(data); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return err
|
||||
}
|
||||
// The rename itself needs to reach the disk, or a crash can resurrect the
|
||||
// old directory entry pointing at a file that is gone.
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer d.Close()
|
||||
return d.Sync()
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
func wrapPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join(t.TempDir(), "db_key.wrapped")
|
||||
}
|
||||
|
||||
// The first wrap writes a v2 blob that opens under the same secret.
|
||||
func TestWrapKeyToFileWritesAnOpenableBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{1}, 32)
|
||||
secret := bytes.Repeat([]byte{2}, 32)
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want a write", wrote, err)
|
||||
}
|
||||
blob, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read blob: %v", err)
|
||||
}
|
||||
plain, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil || version != webauthn.BlobV2 || !bytes.Equal(plain, key) {
|
||||
t.Fatalf("UnwrapKey = %x, %v, %v", plain, version, err)
|
||||
}
|
||||
if fi, err := os.Stat(path); err != nil || fi.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("mode = %v (%v), want 0600", fi.Mode().Perm(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// A blob that already wraps this key under this secret is left alone. Without
|
||||
// this every assertion rewrote the one file that opens the database.
|
||||
func TestWrapKeyToFileSkipsAnIdenticalBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{3}, 32)
|
||||
secret := bytes.Repeat([]byte{4}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, key, secret); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("second wrap: %v", err)
|
||||
}
|
||||
if wrote {
|
||||
t.Error("rewrote a blob that already opens under this secret")
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Error("the blob changed on a no-op wrap")
|
||||
}
|
||||
}
|
||||
|
||||
// Two enrolled authenticators, two PRF secrets, one blob. The second must not
|
||||
// silently lock the first one out — the backup passkey enrolled for exactly
|
||||
// the cold-start case is the one thing that used to stop working.
|
||||
func TestWrapKeyToFileRefusesAnotherCredentialsBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{5}, 32)
|
||||
phone := bytes.Repeat([]byte{6}, 32)
|
||||
yubikey := bytes.Repeat([]byte{7}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, key, phone); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
before, _ := os.ReadFile(path)
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, yubikey)
|
||||
if !errors.Is(err, errForeignBlob) {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want errForeignBlob", wrote, err)
|
||||
}
|
||||
after, _ := os.ReadFile(path)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("the second authenticator overwrote the first one's blob")
|
||||
}
|
||||
if _, _, err := webauthn.UnwrapKey(after, phone); err != nil {
|
||||
t.Fatalf("the first authenticator can no longer open the blob: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A v1 blob is the pre-#14 format. It is upgraded in place rather than
|
||||
// refused, because that is the only way off a format that protects nothing.
|
||||
func TestWrapKeyToFileUpgradesALegacyBlob(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
key := bytes.Repeat([]byte{8}, 32)
|
||||
secret := bytes.Repeat([]byte{9}, 32)
|
||||
|
||||
// A v1 blob is a v2 blob with the magic stripped and the v1 info string;
|
||||
// the package writes no v1, so build one the only way a test can: wrap
|
||||
// v2 under a public key, then hand the file a body with no magic. What
|
||||
// matters here is only that UnwrapKey classifies it as v1.
|
||||
v2, err := webauthn.WrapKey(key, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapKey: %v", err)
|
||||
}
|
||||
legacy := v2[7:] // drop the magic
|
||||
if err := os.WriteFile(path, legacy, 0o600); err != nil {
|
||||
t.Fatalf("write legacy blob: %v", err)
|
||||
}
|
||||
if _, version, _ := webauthn.UnwrapKey(legacy, secret); version != webauthn.BlobV1 {
|
||||
t.Fatalf("fixture is not read as v1 (got %v)", version)
|
||||
}
|
||||
|
||||
wrote, err := wrapKeyToFile(path, key, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want the legacy blob upgraded", wrote, err)
|
||||
}
|
||||
blob, _ := os.ReadFile(path)
|
||||
if _, version, err := webauthn.UnwrapKey(blob, secret); err != nil || version != webauthn.BlobV2 {
|
||||
t.Fatalf("after upgrade: version %v, err %v", version, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A rotated at-rest key under the same credential is a rewrite, not a no-op.
|
||||
func TestWrapKeyToFileRewritesARotatedKey(t *testing.T) {
|
||||
path := wrapPath(t)
|
||||
secret := bytes.Repeat([]byte{10}, 32)
|
||||
old := bytes.Repeat([]byte{11}, 32)
|
||||
fresh := bytes.Repeat([]byte{12}, 32)
|
||||
|
||||
if _, err := wrapKeyToFile(path, old, secret); err != nil {
|
||||
t.Fatalf("first wrap: %v", err)
|
||||
}
|
||||
wrote, err := wrapKeyToFile(path, fresh, secret)
|
||||
if err != nil || !wrote {
|
||||
t.Fatalf("wrapKeyToFile = %v, %v; want the rotated key written", wrote, err)
|
||||
}
|
||||
blob, _ := os.ReadFile(path)
|
||||
plain, _, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil || !bytes.Equal(plain, fresh) {
|
||||
t.Fatalf("blob still wraps the old key (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The write never truncates the target in place, so a crash mid-write cannot
|
||||
// leave a zero-length blob where the only copy of the wrapped key was.
|
||||
func TestWriteFileAtomicLeavesNoTempFilesAndReplacesWhole(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "db_key.wrapped")
|
||||
|
||||
if err := os.WriteFile(path, bytes.Repeat([]byte{0xaa}, 67), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
// Hold the old inode. A rename gives it a new one; a truncating write
|
||||
// would keep it.
|
||||
oldInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
||||
want := bytes.Repeat([]byte{0xbb}, 67)
|
||||
if err := writeFileAtomic(path, want, 0o600); err != nil {
|
||||
t.Fatalf("writeFileAtomic: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil || !bytes.Equal(got, want) {
|
||||
t.Fatalf("content = %x (%v)", got, err)
|
||||
}
|
||||
newInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if os.SameFile(oldInfo, newInfo) {
|
||||
t.Error("the target was written in place, not renamed over")
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("readdir: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("directory holds %d entries, want just the blob (a temp file leaked)", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// mavend/mail.go — core's half of the email reader (Vikunja #246,
|
||||
// docs/plans/01-email-reader.md).
|
||||
//
|
||||
// The split: cmd/mavmaild holds the IMAP credential, connects to the mailbox
|
||||
// and converts messages to plaintext; it hands each message to core over
|
||||
// ipc.MethodIngestMail. Core runs the extraction on the resident model —
|
||||
// llama-server lives in this process, spawned by the phraser — and writes what
|
||||
// comes back through the one task intake seam.
|
||||
//
|
||||
// What this file may produce is exactly one thing: rows in `tasks` with status
|
||||
// "candidate". No fact, no reminder, no note, no nudge, no calendar event. A
|
||||
// 1.7B misreading a mail can therefore put a wrong line on a review page and
|
||||
// nothing else; it can never make Maven speak, and it can never make her
|
||||
// recite something out of an advert as true.
|
||||
//
|
||||
// Off unless configured twice over: no `email` block in mavend.json ⇒ the IPC
|
||||
// method does not exist; no llama-server phraser ⇒ same. A reader pointed at a
|
||||
// core that is not set up for mail gets ErrUnknownMethod rather than silence.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// evidenceMaxChars — how much of the subject line is kept as a candidate's
|
||||
// evidence. Enough to recognise the mail on /tasks, not enough to turn the task
|
||||
// list into a copy of his mailbox.
|
||||
const evidenceMaxChars = 160
|
||||
|
||||
// captureTimeout — how long the capture writes get, separately from the
|
||||
// extraction budget. A candidate the model already produced must not be lost
|
||||
// because the model was slow.
|
||||
const captureTimeout = 30 * time.Second
|
||||
|
||||
// maxMailboxChars — a mailbox name is an IMAP folder, not free text. It ends up
|
||||
// in the provenance string, which is a small controlled vocabulary.
|
||||
const maxMailboxChars = 64
|
||||
|
||||
// validMailbox checks the name this method is willing to write provenance for.
|
||||
// Empty is refused: "email:" is not a source. So is anything with a control
|
||||
// character or a space-only value, so the source string stays greppable and
|
||||
// stays one token.
|
||||
func validMailbox(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("mail intake: mailbox is required")
|
||||
}
|
||||
if len([]rune(s)) > maxMailboxChars {
|
||||
return "", fmt.Errorf("mail intake: mailbox name too long")
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < 0x20 || r == 0x7f || unicode.IsSpace(r) {
|
||||
return "", fmt.Errorf("mail intake: mailbox name has whitespace or a control character")
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// mailIntake — extraction + capture for one message at a time.
|
||||
type mailIntake struct {
|
||||
st *store.Store
|
||||
ex *email.Extractor
|
||||
timeout time.Duration
|
||||
now func() time.Time
|
||||
// bus — the unified intake journal (Vikunja #283). This path captures
|
||||
// through the store directly rather than through ipc.CoreAPI, so the
|
||||
// decorator in intake.go does not see it and the publish is explicit here.
|
||||
// nil is a working no-op.
|
||||
bus *event.Bus
|
||||
}
|
||||
|
||||
// newMailIntake returns nil when mail ingestion must not be available, which is
|
||||
// the default. Both preconditions are real:
|
||||
//
|
||||
// - no cfg.Email ⇒ not configured, and a capability is off unless configured;
|
||||
// - no llama-server phraser ⇒ nothing to extract with. There is deliberately
|
||||
// no keyword fallback: "the subject line became a task" is not extraction,
|
||||
// it is a mailbox rendered as a to-do list, and it would fill the review
|
||||
// page faster than he could clear it.
|
||||
func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) *mailIntake {
|
||||
if cfg.Email == nil {
|
||||
return nil
|
||||
}
|
||||
lp, ok := phr.(*phraser.LLMPhraser)
|
||||
if !ok {
|
||||
// The phraser is not an *LLMPhraser. Today that means there is no
|
||||
// llama-server; if anything ever WRAPS the phraser it will mean that
|
||||
// instead, so the line names the assertion rather than guessing why.
|
||||
log.Printf("mail intake: configured but the phraser is not an *phraser.LLMPhraser (%T) — mail ingestion disabled", phr)
|
||||
return nil
|
||||
}
|
||||
timeout := time.Duration(cfg.Email.Timeout)
|
||||
if timeout <= 0 {
|
||||
timeout = config.DefaultEmailTimeout
|
||||
}
|
||||
// Background client: extraction is a job nobody is waiting on, and it shares
|
||||
// one llama-server slot with the voice turn. Through the gate it yields to
|
||||
// anything he is waiting for and only one extraction runs at a time, so a
|
||||
// first poll of 25 unseen messages cannot queue 25 model calls in front of
|
||||
// him. See llm.Gate.
|
||||
ex := email.NewExtractor(llmBackgroundClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
|
||||
// The NORMALISED bound, not the configured one: with "email": {} in
|
||||
// mavend.json the configured value is 0 and the daemon allows three.
|
||||
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", ex.Max(), timeout)
|
||||
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now, bus: bus}
|
||||
}
|
||||
|
||||
// ingest handles one ipc.MethodIngestMail call.
|
||||
//
|
||||
// Junk and empty messages are answered Skipped without touching the model — the
|
||||
// reader's header filter is what keeps the resident model off newsletters.
|
||||
//
|
||||
// Every candidate is captured with Status "candidate", Source "email:<mailbox>"
|
||||
// and the subject as Evidence, under an ExternalID naming the message and the
|
||||
// span it was extracted from. That key is unique over every row whatever its
|
||||
// status, so a mailbox re-read after a restart produces Created=0 — and, more
|
||||
// to the point, a task he already marked done is not re-proposed the next time
|
||||
// the same unread message is read again.
|
||||
func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
|
||||
// The mailbox name becomes provenance ("email:INBOX"), and the source
|
||||
// vocabulary is what the loop's rules trust. An empty name gave "email:" and
|
||||
// an arbitrary string gave an arbitrary source under that namespace.
|
||||
mailbox, err := validMailbox(req.Mailbox)
|
||||
if err != nil {
|
||||
return ipc.IngestMailResp{}, err
|
||||
}
|
||||
msg := email.Message{
|
||||
UID: req.UID,
|
||||
From: req.From,
|
||||
Subject: req.Subject,
|
||||
Date: req.Date,
|
||||
Body: req.Body,
|
||||
Junk: req.Junk,
|
||||
}
|
||||
if msg.Junk || (msg.Subject == "" && msg.Body == "") {
|
||||
return ipc.IngestMailResp{Skipped: true}, nil
|
||||
}
|
||||
|
||||
// The timeout scopes the EXTRACTION and nothing else. It used to wrap the
|
||||
// capture writes too, so a model that answered at 119 seconds of a 120
|
||||
// second budget left the first CaptureTask one second and the third none:
|
||||
// the work was done, the answer was good, and it was dropped with a
|
||||
// deadline error. Config calls this a per-message extraction budget, and now
|
||||
// it is one.
|
||||
exCtx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||
cands, err := m.ex.Extract(exCtx, msg)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// The error from internal/email never carries mail text; keep it that way
|
||||
// by not adding the subject here.
|
||||
return ipc.IngestMailResp{}, fmt.Errorf("mail intake: uid %d: %w", req.UID, err)
|
||||
}
|
||||
if len(cands) == 0 {
|
||||
return ipc.IngestMailResp{}, nil
|
||||
}
|
||||
|
||||
// A fresh budget for the writes, derived from the caller's context rather
|
||||
// than from the extraction's. Encrypted-store writes are fast; what this
|
||||
// bounds is a stuck store, not the model.
|
||||
ctx, cancel = context.WithTimeout(ctx, captureTimeout)
|
||||
defer cancel()
|
||||
|
||||
source := email.SourcePrefix + mailbox
|
||||
evidence := truncateRunes(req.Subject, evidenceMaxChars)
|
||||
now := m.now()
|
||||
var resp ipc.IngestMailResp
|
||||
for _, c := range cands {
|
||||
t := store.Task{
|
||||
CreatedTs: now,
|
||||
Text: c.Text,
|
||||
Source: source,
|
||||
Evidence: evidence,
|
||||
// The one status this path may ever write. Anything Maven derived from
|
||||
// something she read is a suggestion until he confirms it on /tasks.
|
||||
Status: store.TaskCandidate,
|
||||
}
|
||||
t.ExternalID = mailExternalID(source, req.UID, c.Text)
|
||||
if due, ok := email.ParseDue(c.Due); ok {
|
||||
t.Due = &due
|
||||
}
|
||||
res, err := m.st.CaptureTask(ctx, t)
|
||||
if err != nil {
|
||||
return resp, fmt.Errorf("mail intake: capture: %w", err)
|
||||
}
|
||||
resp.TaskIDs = append(resp.TaskIDs, res.ID)
|
||||
if res.Created {
|
||||
resp.Created++
|
||||
// Only a row that was actually created. CaptureTask dedupes on
|
||||
// normalised text among live rows, so a mailbox re-read after a
|
||||
// restart must not refill the journal with tasks already in it.
|
||||
m.bus.Publish(publishableTask(t, now), now)
|
||||
}
|
||||
}
|
||||
// Counts only: the log line names the mailbox and the UID, never the subject,
|
||||
// the sender or the task text. Reviewing a candidate is what /tasks is for.
|
||||
log.Printf("mail intake: %s uid %d → %d candidate(s), %d new", source, req.UID, len(cands), resp.Created)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// wireMailIntake installs the IPC hook, or leaves it nil so the method reports
|
||||
// ErrUnknownMethod. Called on both startup paths (unlocked boot and passkey
|
||||
// unlock) so mail behaves the same either way.
|
||||
func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) {
|
||||
mi := newMailIntake(st, phr, cfg, bus)
|
||||
if mi == nil {
|
||||
return
|
||||
}
|
||||
srv.IngestMailFn = mi.ingest
|
||||
}
|
||||
|
||||
// truncateRunes cuts a string to n runes, marking the cut.
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// mailExternalID names the message and the span a candidate was extracted
|
||||
// from. The mailbox and UID identify the message; the normalised text
|
||||
// identifies which of the candidates in it this is, so a message yielding two
|
||||
// tasks gets two keys and a re-read of it gets neither twice.
|
||||
//
|
||||
// UIDs are stable per mailbox, and a mailbox that renumbers (UIDVALIDITY
|
||||
// changing) re-proposes its tasks once, which is the safe direction.
|
||||
func mailExternalID(source string, uid uint32, text string) string {
|
||||
return fmt.Sprintf("%s#%d:%s", source, uid, store.NormalizeTaskText(text))
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// mailLLM — a canned extraction reply.
|
||||
type mailLLM struct {
|
||||
reply string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (m *mailLLM) Complete(_ context.Context, _ llm.Req) (string, error) {
|
||||
m.calls++
|
||||
return m.reply, nil
|
||||
}
|
||||
|
||||
func newTestIntake(t *testing.T, reply string) (*mailIntake, *store.Store, *mailLLM) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
fake := &mailLLM{reply: reply}
|
||||
return &mailIntake{
|
||||
st: st,
|
||||
ex: email.NewExtractor(fake, 0, nil),
|
||||
timeout: 5 * time.Second,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
||||
}, st, fake
|
||||
}
|
||||
|
||||
func ingestReq() ipc.IngestMailReq {
|
||||
return ipc.IngestMailReq{
|
||||
Mailbox: "INBOX", UID: 42,
|
||||
From: "billing@isp.example",
|
||||
Subject: "Счёт за интернет",
|
||||
Body: "Оплатите счёт до 5 августа.",
|
||||
}
|
||||
}
|
||||
|
||||
// The one property that matters: a mail-derived task is a candidate, attributed
|
||||
// to the mailbox, with the subject as reviewable evidence — and nothing else is
|
||||
// written.
|
||||
func TestIngestCapturesCandidates(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт за интернет","due":"2026-08-05"}]`)
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 1 || len(resp.TaskIDs) != 1 {
|
||||
t.Fatalf("resp = %+v, want one created task", resp)
|
||||
}
|
||||
tasks, err := st.ListTasks(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
got := tasks[0]
|
||||
if got.Status != store.TaskCandidate {
|
||||
t.Errorf("status = %q, want %q — mail may only produce candidates", got.Status, store.TaskCandidate)
|
||||
}
|
||||
if got.Source != "email:INBOX" {
|
||||
t.Errorf("source = %q, want email:INBOX", got.Source)
|
||||
}
|
||||
if got.Evidence != "Счёт за интернет" {
|
||||
t.Errorf("evidence = %q, want the subject line", got.Evidence)
|
||||
}
|
||||
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||
t.Errorf("due = %v, want 2026-08-05", got.Due)
|
||||
}
|
||||
// Nothing else may have been written: no reminder, no fact.
|
||||
rem, err := st.ListReminders(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
if len(rem) != 0 {
|
||||
t.Errorf("mail created %d reminders; a misread mail must never be able to fire", len(rem))
|
||||
}
|
||||
}
|
||||
|
||||
// Re-reading a mailbox must not grow the list — CaptureTask dedupes among live
|
||||
// rows, and the intake relies on exactly that.
|
||||
func TestIngestSameMailTwiceIsIdempotent(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт","due":""}]`)
|
||||
if _, err := mi.ingest(context.Background(), ingestReq()); err != nil {
|
||||
t.Fatalf("first ingest: %v", err)
|
||||
}
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("second ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 0 || len(resp.TaskIDs) != 1 {
|
||||
t.Errorf("resp = %+v, want the existing row and Created=0", resp)
|
||||
}
|
||||
tasks, _ := st.ListTasks(context.Background(), "")
|
||||
if len(tasks) != 1 {
|
||||
t.Errorf("got %d tasks after two reads, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestJunkSkipsTheModel(t *testing.T) {
|
||||
mi, st, fake := newTestIntake(t, `[{"text":"купить со скидкой","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Junk = true
|
||||
resp, err := mi.ingest(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if !resp.Skipped || resp.Created != 0 {
|
||||
t.Errorf("resp = %+v, want skipped", resp)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("model called %d times for junk, want 0", fake.calls)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("junk produced %d tasks, want 0", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestEmptyMessageSkipped(t *testing.T) {
|
||||
mi, _, fake := newTestIntake(t, "[]")
|
||||
resp, err := mi.ingest(context.Background(), ipc.IngestMailReq{Mailbox: "INBOX", UID: 1})
|
||||
if err != nil || !resp.Skipped {
|
||||
t.Fatalf("resp = %+v, err = %v; want skipped", resp, err)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("model called %d times for an empty message, want 0", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestNoTasksWritesNothing(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, "[]")
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 0 || len(resp.TaskIDs) != 0 || resp.Skipped {
|
||||
t.Errorf("resp = %+v, want nothing captured and not skipped", resp)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("got %d tasks, want 0", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestTruncatesEvidence(t *testing.T) {
|
||||
mi, st, _ := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Subject = strings.Repeat("щ", 400)
|
||||
if _, err := mi.ingest(context.Background(), req); err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
tasks, _ := st.ListTasks(context.Background(), "")
|
||||
if len(tasks) != 1 {
|
||||
t.Fatalf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
if n := len([]rune(tasks[0].Evidence)); n > evidenceMaxChars+1 {
|
||||
t.Errorf("evidence kept %d runes, want ≤ %d", n, evidenceMaxChars)
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured: no email block ⇒ no intake, so the IPC method does not
|
||||
// exist at all.
|
||||
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
if mi := newMailIntake(st, nil, &config.Config{}, nil); mi != nil {
|
||||
t.Error("no email block must mean no mail intake")
|
||||
}
|
||||
// Configured but with a non-LLM phraser: still off — there is no fallback
|
||||
// extraction, by design.
|
||||
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}, nil); mi != nil {
|
||||
t.Error("without a llama-server phraser there is nothing to extract with")
|
||||
}
|
||||
}
|
||||
|
||||
// The mailbox name becomes the provenance string, which is the vocabulary the
|
||||
// loop's rules trust. "email:" is not a source and neither is "email:anything
|
||||
// he could post at the socket".
|
||||
func TestIngestRejectsBadMailbox(t *testing.T) {
|
||||
for _, name := range []string{"", " ", "IN BOX", "IN\nBOX", "IN\x00BOX", strings.Repeat("щ", maxMailboxChars+1)} {
|
||||
mi, st, fake := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
||||
req := ingestReq()
|
||||
req.Mailbox = name
|
||||
if _, err := mi.ingest(context.Background(), req); err == nil {
|
||||
t.Errorf("mailbox %q was accepted", name)
|
||||
}
|
||||
if fake.calls != 0 {
|
||||
t.Errorf("mailbox %q reached the model", name)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
||||
t.Errorf("mailbox %q wrote %d tasks", name, len(tasks))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// slowLLM burns most of the extraction budget before answering, the way a
|
||||
// Thinking 1.7B does on a long mail.
|
||||
type slowLLM struct {
|
||||
reply string
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
func (s *slowLLM) Complete(ctx context.Context, _ llm.Req) (string, error) {
|
||||
select {
|
||||
case <-time.After(s.delay):
|
||||
return s.reply, nil
|
||||
case <-ctx.Done():
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// The extraction budget must not also bound the writes. It used to be one
|
||||
// context, so a model answering near the deadline lost the candidates it had
|
||||
// just produced.
|
||||
func TestIngestCapturesAfterASlowExtraction(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
mi := &mailIntake{
|
||||
st: st,
|
||||
ex: email.NewExtractor(&slowLLM{reply: `[{"text":"оплатить счёт","due":""}]`, delay: 90 * time.Millisecond}, 0, nil),
|
||||
timeout: 100 * time.Millisecond,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
resp, err := mi.ingest(context.Background(), ingestReq())
|
||||
if err != nil {
|
||||
t.Fatalf("ingest: %v", err)
|
||||
}
|
||||
if resp.Created != 1 {
|
||||
t.Fatalf("resp = %+v, want the candidate captured", resp)
|
||||
}
|
||||
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 1 {
|
||||
t.Errorf("got %d tasks, want 1", len(tasks))
|
||||
}
|
||||
}
|
||||
+323
-149
@@ -25,7 +25,7 @@
|
||||
// When a passkey credential is enrolled AND no env key is set, the daemon
|
||||
// starts in LOCKED mode: the IPC server runs but rejects all store methods
|
||||
// except MethodAssertStepUp and MethodUnlock. A passkey assertion followed
|
||||
// by MethodUnlock (with the same credential's public key) unwraps the at-rest
|
||||
// by MethodUnlock (with that credential's WebAuthn PRF output) unwraps the at-rest
|
||||
// AES-256 key from a wrapped blob on disk (HKDF-SHA256 + AES-GCM) and opens
|
||||
// the encrypted store. After unlock, the daemon wires voice, loop, and
|
||||
// delivery and runs normally.
|
||||
@@ -34,10 +34,13 @@
|
||||
// starts unlocked from the env key (pre-unlock behavior). Enrolling a passkey
|
||||
// while unlocked calls MethodStoreEncryptionKey to wrap the env key and
|
||||
// persist the wrapped blob — enabling cold-start unlock on the next boot
|
||||
// after the env key is removed.
|
||||
// after the env key is removed. That write happens once, when no blob
|
||||
// exists; replacing an existing one takes an explicit request, see
|
||||
// cmd/mavend/keyfile.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -48,6 +51,7 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -58,6 +62,7 @@ import (
|
||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
@@ -65,12 +70,19 @@ import (
|
||||
|
||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode.
|
||||
// In locked mode, all CoreAPI methods return errLocked. The unlock path
|
||||
// replaces the CoreAPI with the real store adapter and flips the flag.
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||
// owns the store handle the unlock path creates.
|
||||
//
|
||||
// The store matters here because of who runs when. In locked mode there is no
|
||||
// store at boot; one is opened inside UnlockFn, on an IPC goroutine, minutes
|
||||
// or days later. Shutdown runs on the main goroutine. Without a handoff the
|
||||
// main goroutine has nothing to close, and store.Close is what re-encrypts
|
||||
// the tmpfs working copy back over the ciphertext file — so a daemon that
|
||||
// cold-started lost every write of that session, silently, on the next boot.
|
||||
type daemonLock struct {
|
||||
mu sync.Mutex
|
||||
locked bool
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
func newDaemonLock(locked bool) *daemonLock {
|
||||
@@ -83,10 +95,25 @@ func (l *daemonLock) isLocked() bool {
|
||||
return l.locked
|
||||
}
|
||||
|
||||
func (l *daemonLock) unlock() {
|
||||
// unlock flips the flag and takes ownership of the store opened by UnlockFn.
|
||||
func (l *daemonLock) unlock(st *store.Store) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.locked = false
|
||||
l.st = st
|
||||
}
|
||||
|
||||
// closeStore seals the store the unlock path opened, if any. Safe to call
|
||||
// when the daemon never unlocked, and safe to call twice.
|
||||
func (l *daemonLock) closeStore() error {
|
||||
l.mu.Lock()
|
||||
st := l.st
|
||||
l.st = nil
|
||||
l.mu.Unlock()
|
||||
if st == nil {
|
||||
return nil
|
||||
}
|
||||
return st.Close()
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -96,100 +123,12 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// lockedAPI is a dummy CoreAPI used while the daemon is locked. Every method
|
||||
// returns errLocked. The wire protocol's StoreAPI methods all go through the
|
||||
// Server dispatch on CoreAPI, so returning errLocked from each is correct.
|
||||
type lockedAPI struct{}
|
||||
|
||||
var _ ipc.CoreAPI = (*lockedAPI)(nil)
|
||||
|
||||
func (l *lockedAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) LatestFact(ctx context.Context, key string) (ipc.Fact, error) {
|
||||
return ipc.Fact{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) LatestFactBySource(ctx context.Context, key, source string) (ipc.Fact, error) {
|
||||
return ipc.Fact{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) {
|
||||
return ipc.Presence{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
return errLocked
|
||||
}
|
||||
func (l *lockedAPI) ListReminders(ctx context.Context, n int) ([]ipc.Reminder, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||
return errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentFacts(ctx context.Context, n int) ([]ipc.Fact, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]ipc.Fact, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentNudges(ctx context.Context, n int) ([]ipc.Nudge, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]ipc.Note, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentNotes(ctx context.Context, n int) ([]ipc.Note, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||
return false, errLocked
|
||||
}
|
||||
func (l *lockedAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||
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) 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
|
||||
}
|
||||
func (l *lockedAPI) ListTools(ctx context.Context, status string) ([]ipc.Tool, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RevertFact(ctx context.Context, key string) (int64, error) { return 0, errLocked }
|
||||
func (l *lockedAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return "", errLocked
|
||||
}
|
||||
func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
return ipc.TickTrace{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
|
||||
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; the daemon does not answer until it finishes)")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -229,11 +168,28 @@ func run(args []string) error {
|
||||
var st *store.Store
|
||||
var envKeyBytes []byte // kept for WrapKeyFn (enrollment wraps this key)
|
||||
|
||||
// dbKey — the plaintext at-rest key, once the daemon has one. Set at boot
|
||||
// in env-key mode and inside UnlockFn after a cold start. WrapKeyFn reads
|
||||
// it from an IPC goroutine, hence the atomic: srv's function fields are
|
||||
// installed before Serve and must not be reassigned afterwards.
|
||||
var dbKey atomic.Pointer[[]byte]
|
||||
|
||||
// wrappedPath resolves the blob location the same way for both the read
|
||||
// at boot and every write, so a default-path deployment cannot wrap to
|
||||
// one file and unwrap from another.
|
||||
wrappedPath := func() string {
|
||||
if *wrappedKeyPath != "" {
|
||||
return *wrappedKeyPath
|
||||
}
|
||||
return cfg.DefaultWrappedKeyPath()
|
||||
}
|
||||
|
||||
if !locked {
|
||||
// Normal boot: env key or plaintext (dev/CI)
|
||||
if envKey != nil {
|
||||
envKeyBytes = make([]byte, len(envKey))
|
||||
copy(envKeyBytes, envKey)
|
||||
dbKey.Store(&envKeyBytes)
|
||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, envKey)
|
||||
} else {
|
||||
st, err = store.Open(ctx, cfg.DBPath)
|
||||
@@ -242,6 +198,14 @@ func run(args []string) error {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
} else {
|
||||
// Locked boot: the store does not exist yet. Seal whatever UnlockFn
|
||||
// opened, at shutdown, on this goroutine.
|
||||
defer func() {
|
||||
if err := dl.closeStore(); err != nil {
|
||||
log.Printf("mavend: seal store on shutdown: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ----- daemon components (only wired when unlocked) -----
|
||||
@@ -256,8 +220,21 @@ func run(args []string) error {
|
||||
coreAPI ipc.CoreAPI
|
||||
eco *ecosystemWiring
|
||||
factWorker *factEnrichmentWorker
|
||||
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
||||
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
|
||||
)
|
||||
|
||||
// The unified intake journal (Vikunja #283). Built before anything else
|
||||
// that holds a CoreAPI, because intakeAPI wraps that one interface and
|
||||
// every intake path in the daemon reaches its sink through it. nil (the
|
||||
// operator set intake_journal negative) means no decorator at all.
|
||||
evBus := newEventBus(cfg)
|
||||
// coreFor is what every in-process holder of a CoreAPI now takes, instead
|
||||
// of a bare ipc.NewStoreAPI(st). Identical behaviour plus one published
|
||||
// envelope per successful intake write.
|
||||
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
||||
|
||||
if !locked {
|
||||
rules = loop.DefaultRules()
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
@@ -269,13 +246,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"
|
||||
@@ -300,7 +278,7 @@ func run(args []string) error {
|
||||
eco = wireEcosystem(cfg)
|
||||
|
||||
// voice
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
||||
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
@@ -347,21 +325,34 @@ func run(args []string) error {
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
coreAPI = &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
api.chatFn = voiceW.handler.handleText
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
|
||||
}
|
||||
} else {
|
||||
// locked mode: dummy CoreAPI that returns errLocked for everything
|
||||
coreAPI = &lockedAPI{}
|
||||
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
||||
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
||||
// refused before it reaches this value. This is just a safe non-nil
|
||||
// placeholder: if the guard is ever bypassed by a bug, calls land
|
||||
// here and fail loudly with ipc.ErrNotImplemented instead of a nil
|
||||
// dereference or, worse, silently succeeding.
|
||||
coreAPI = ipc.UnimplementedCoreAPI{}
|
||||
}
|
||||
|
||||
// ----- IPC boundary (core ↔ modules) -----
|
||||
@@ -372,11 +363,25 @@ func run(args []string) error {
|
||||
|
||||
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
|
||||
|
||||
// Set Server.Check — in locked mode, block everything except unlock-path methods.
|
||||
// Set Server.Check — the single authorization guard, run once by
|
||||
// Server.dispatch before any CoreAPI method is called (see
|
||||
// internal/ipc/server.go). In locked mode this is the ONLY thing
|
||||
// standing between an unauthenticated caller and the store: it must
|
||||
// default-deny, with an explicit allowlist for the two methods the
|
||||
// unlock flow itself needs (MethodAssertStepUp, MethodUnlock — neither
|
||||
// of which touches CoreAPI; dispatch handles them directly via
|
||||
// srv.StepUp/srv.UnlockFn). Forgetting to allowlist a new unlock-path
|
||||
// method fails safe (denied); forgetting to guard a new CoreAPI method
|
||||
// is impossible because there is nothing left to forget — every method
|
||||
// not in the allowlist is refused by construction.
|
||||
if locked {
|
||||
srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error {
|
||||
switch m {
|
||||
case ipc.MethodAssertStepUp, ipc.MethodUnlock:
|
||||
case ipc.MethodAssertStepUp, ipc.MethodUnlock, ipc.MethodPing:
|
||||
// Ping is allowed for the same reason the two unlock methods
|
||||
// are: it never reaches CoreAPI. It answers "she is up and
|
||||
// locked", which is what mavupdate needs to tell a daemon
|
||||
// waiting for a passkey apart from one that failed to start.
|
||||
return nil // allowed in locked mode
|
||||
default:
|
||||
return errLocked
|
||||
@@ -387,42 +392,115 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
||||
srv.LockedFn = dl.isLocked
|
||||
|
||||
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
||||
// persists the wrapped blob. Only wired when the daemon has the key in
|
||||
// memory (env key mode). Called by mavweb after passkey enrollment.
|
||||
if envKeyBytes != nil {
|
||||
srv.WrapKeyFn = func(ctx context.Context, publicKey []byte) error {
|
||||
blob, err := webauthn.WrapKey(envKeyBytes, publicKey)
|
||||
// wg is declared here rather than next to srv.Serve because the media
|
||||
// retention loop starts on this path too, and shutdown has to wait for a
|
||||
// prune in flight: it deletes files.
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
|
||||
// configured and there is a llama-server to extract with, in which case
|
||||
// ipc.MethodIngestMail reports ErrUnknownMethod.
|
||||
if !locked {
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
// Vision + the media blob store (Vikunja #252). Both stay dark without a
|
||||
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
// The meeting recorder (Vikunja #253) shares that blob store and its
|
||||
// retention loop. Off unless a capture block enables it, in which case
|
||||
// all four capture methods answer ErrUnknownMethod.
|
||||
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
}
|
||||
|
||||
// WrapKeyFn — wraps the at-rest key under the passkey PRF secret and
|
||||
// persists the wrapped blob. Called by mavweb after every assertion.
|
||||
//
|
||||
// It is wired in locked mode too, not only in env-key mode, and that is
|
||||
// what makes a v1 blob recoverable. A box enrolled before Vikunja #14
|
||||
// cold-starts through the legacy public-key retry in mavweb, and the
|
||||
// StoreEncryptionKey that follows rewrites the blob as v2. Without this
|
||||
// the only escape from a v1 blob was putting MAVEN_DB_KEY back in the
|
||||
// environment, which is the thing cold-start unlock exists to avoid.
|
||||
//
|
||||
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
|
||||
// an authenticator without PRF support produces no wrapped file at all
|
||||
// rather than a file that looks protected and is not.
|
||||
if envKeyBytes != nil || locked {
|
||||
srv.WrapKeyFn = func(ctx context.Context, secret []byte, explicit bool) error {
|
||||
kp := dbKey.Load()
|
||||
if kp == nil {
|
||||
return errors.New("wrap encryption key: the daemon is locked and has no key yet (unlock first)")
|
||||
}
|
||||
wp := wrappedPath()
|
||||
// Asserting a passkey is not a request to rewrite the cold-start
|
||||
// key. Without this an assertion carrying a substituted PRF value
|
||||
// re-wrapped the real database key under it, and a second
|
||||
// authenticator silently replaced the first one's blob.
|
||||
if !explicit {
|
||||
if _, err := os.Stat(wp); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("check wrapped key: %w", err)
|
||||
}
|
||||
}
|
||||
wrote, err := wrapKeyToFile(wp, *kp, secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wrap encryption key: %w", err)
|
||||
return err
|
||||
}
|
||||
wp := *wrappedKeyPath
|
||||
if wp == "" {
|
||||
wp = cfg.DefaultWrappedKeyPath()
|
||||
if wrote {
|
||||
log.Printf("mavend: wrapped encryption key under this passkey's PRF output → %s", wp)
|
||||
}
|
||||
if err := os.WriteFile(wp, blob, 0o600); err != nil {
|
||||
return fmt.Errorf("write wrapped key: %w", err)
|
||||
}
|
||||
log.Printf("mavend: wrapped encryption key with passkey credential (%d bytes)", len(blob))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the wrapped
|
||||
// blob using the passkey credential public key, opens the store, wires all
|
||||
// UnlockFn — cold-start unlock: unwraps the encryption key from the
|
||||
// wrapped blob using the passkey PRF secret, opens the store, wires all
|
||||
// daemon components, and replaces the locked API.
|
||||
if locked {
|
||||
srv.UnlockFn = func(ctx context.Context, publicKey []byte) error {
|
||||
wp := *wrappedKeyPath
|
||||
var unlockMu sync.Mutex
|
||||
srv.UnlockFn = func(ctx context.Context, secret []byte) error {
|
||||
// One unlock at a time, and never a second one. Without this a
|
||||
// concurrent pair of Unlock calls would each open a store and
|
||||
// wire a full daemon, and the loser's goroutines would run
|
||||
// against a store nobody closes.
|
||||
unlockMu.Lock()
|
||||
defer unlockMu.Unlock()
|
||||
if !dl.isLocked() {
|
||||
return nil // already unlocked; the caller does not need to know
|
||||
}
|
||||
|
||||
// Depth, not a boundary. MethodAssertStepUp is AuthRead, so
|
||||
// anything that can open the same-uid socket can flip the
|
||||
// session and reach MethodUnlock. What actually stops a local
|
||||
// attacker is the 32-byte PRF output they do not have, and that
|
||||
// was true before this check. What this check stops is an
|
||||
// accidental unlock attempt from an unrelated local caller.
|
||||
if !passkeySess.IsStepUp() {
|
||||
return errors.New("unlock: no verified passkey assertion (assert first)")
|
||||
}
|
||||
|
||||
wp := wrappedPath()
|
||||
blob, err := os.ReadFile(wp)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read wrapped key: %w", err)
|
||||
}
|
||||
key, err := webauthn.UnwrapKey(blob, publicKey)
|
||||
key, version, err := webauthn.UnwrapKey(blob, secret)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unwrap key: %w", err)
|
||||
}
|
||||
if version == webauthn.BlobV1 {
|
||||
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Use the \"rewrite cold-start key\" button on /auth/webauthn with a PRF-capable authenticator to replace it with a v2 blob.", wp, version)
|
||||
}
|
||||
// WrapKeyFn needs the key to be able to rewrite the blob later.
|
||||
keyCopy := bytes.Clone(key)
|
||||
dbKey.Store(&keyCopy)
|
||||
// Open the store with the unwrapped key.
|
||||
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
|
||||
if err != nil {
|
||||
@@ -439,13 +517,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"
|
||||
@@ -467,7 +546,7 @@ func run(args []string) error {
|
||||
|
||||
eco = wireEcosystem(cfg)
|
||||
|
||||
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
|
||||
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
@@ -508,20 +587,33 @@ func run(args []string) error {
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
// Swap the CoreAPI from lockedAPI to the real store adapter.
|
||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||
newAPI := &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
}
|
||||
srv.SetAPI(newAPI)
|
||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
// speaker-embedding model exists on disk; off entirely without a speaker
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
|
||||
// Start voice server.
|
||||
if voiceW != nil {
|
||||
@@ -546,13 +638,43 @@ func run(args []string) error {
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
|
||||
dl.unlock()
|
||||
// Start background memory evaluation (nil unless configured).
|
||||
if evalWorker != nil {
|
||||
go func() {
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Start feed reading (nil unless configured).
|
||||
if feedWkr != nil {
|
||||
go func() {
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Start the watched-page crawls (nil unless configured).
|
||||
if crawlWkr != nil {
|
||||
go func() {
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Keep MCP connections alive (nil unless configured).
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
go voiceW.mcp.run(ctx)
|
||||
}
|
||||
|
||||
// Re-enumerate the house for new devices (nil unless configured).
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
go voiceW.home.run(ctx)
|
||||
}
|
||||
|
||||
dl.unlock(st)
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -584,6 +706,41 @@ func run(args []string) error {
|
||||
defer wg.Done()
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
if evalWorker != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
if feedWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
if crawlWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.mcp.run(ctx)
|
||||
}()
|
||||
}
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.home.run(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
@@ -599,12 +756,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()) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/mcp"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/webfetch"
|
||||
)
|
||||
|
||||
// mcpRefreshInterval — how often the manager is asked to re-dial servers that
|
||||
// are down. It is a tick, not a retry rate: mcp.Manager holds a per-server
|
||||
// backoff that starts at DefaultReconnectEvery and doubles to
|
||||
// MaxReconnectEvery, so a permanently misconfigured stdio server is not
|
||||
// re-exec'd once a minute forever.
|
||||
const mcpRefreshInterval = time.Minute
|
||||
|
||||
// mcpWiring — the MCP client, when the `mcp` block configures at least one
|
||||
// enabled server. nil ⇒ nothing was configured, nothing is connected, and an
|
||||
// allowlist row that happens to look like an MCP row refuses to run.
|
||||
//
|
||||
// It lives on the voice wiring because MCP tools ARE acts: they run through
|
||||
// tool.Executor, the enabled allowlist and the confirm turn, which only exist
|
||||
// on the voice/chat path. No voice surface ⇒ nothing that could call a tool.
|
||||
type mcpWiring struct {
|
||||
mgr *mcp.Manager
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
// wireMCP builds the manager. It does NOT dial: run does that, on its own
|
||||
// goroutine, which is what makes "Maven starting is not contingent on someone
|
||||
// else's process" true rather than merely intended.
|
||||
//
|
||||
// Dialing here used to be synchronous with a 30s budget, from wireVoice, from
|
||||
// run. Connect dials serially and each HTTP dial is three requests against
|
||||
// that server's timeout, so one black-holed endpoint cost 15s of boot and two
|
||||
// cost the whole budget. On the passkey path wireVoice runs inside the unlock
|
||||
// handler, so it delayed the answer to an unlock as well. Not failing and not
|
||||
// blocking are different properties and only the first one held.
|
||||
func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
|
||||
servers := cfg.MCPServers()
|
||||
if len(servers) == 0 {
|
||||
return nil
|
||||
}
|
||||
limits := webfetch.Config{}
|
||||
if cfg.MCP != nil {
|
||||
limits.AllowHosts = cfg.MCP.AllowHosts
|
||||
limits.DenyHosts = cfg.MCP.DenyHosts
|
||||
limits.MaxBytes = cfg.MCP.MaxBytes
|
||||
limits.Timeout = time.Duration(cfg.MCP.Timeout)
|
||||
limits.HostInterval = time.Duration(cfg.MCP.HostInterval)
|
||||
}
|
||||
mgr, err := mcp.NewManager(mcp.WebfetchDoor(limits), servers)
|
||||
if err != nil {
|
||||
// Validation already ran in config.validate, so this is a programming
|
||||
// error rather than a config one. Still not fatal: MCP off is a working
|
||||
// Maven.
|
||||
log.Printf("mcp: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &mcpWiring{mgr: mgr, st: st}
|
||||
}
|
||||
|
||||
// connect dials every server and reconciles what came back. Called from run,
|
||||
// under the daemon's context, so a shutdown during a slow dial is observed.
|
||||
func (w *mcpWiring) connect(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
w.mgr.Connect(ctx)
|
||||
w.propose(ctx)
|
||||
}
|
||||
|
||||
// propose writes a 'proposed' allowlist row for every discovered tool, and
|
||||
// reconciles the rows that already exist against what the server offers today.
|
||||
// It does NOT enable anything: a configured server is a place Maven may look,
|
||||
// not a capability she has. Kami enables what he wants on /tools, behind
|
||||
// step-up, which is the same gate a shell tool goes through.
|
||||
//
|
||||
// Three things happen per discovered tool.
|
||||
//
|
||||
// A name not in the store becomes a proposal, carrying the tool's fingerprint.
|
||||
//
|
||||
// A name already in the store is reconciled against that fingerprint. A tool
|
||||
// whose description, schema or readOnlyHint changed since it was approved drops
|
||||
// back to 'proposed' and, if it stopped claiming read-only, to destructive=1.
|
||||
// Insert-or-skip was not enough on its own: the cmd is a late-bound reference
|
||||
// to a name the far end owns, so the server can redefine list_tasks into
|
||||
// something that writes without the row changing at all.
|
||||
//
|
||||
// A row whose server is connected and no longer offers the tool is withdrawn.
|
||||
func (w *mcpWiring) propose(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
fresh, changed := 0, 0
|
||||
seen := map[string]string{} // local name → "server/tool", for collisions
|
||||
for _, t := range w.mgr.Tools() {
|
||||
name := mcp.LocalName(t.Server, t.Name)
|
||||
remote := t.Server + "/" + t.Name
|
||||
// Two different tools can flatten to one local name: server "vik" with
|
||||
// tool "list_tasks" and server "vik_list" with tool "tasks" both give
|
||||
// "vik_list_tasks". The store keys rows by name, so the second would
|
||||
// land on the first one's row. Config-controlled and therefore rare,
|
||||
// but silently reusing a row is the wrong way to lose that race.
|
||||
if prev, dup := seen[name]; dup {
|
||||
log.Printf("mcp: %s and %s both map to the allowlist name %q — skipping the second, rename a server",
|
||||
prev, remote, name)
|
||||
continue
|
||||
}
|
||||
seen[name] = remote
|
||||
// No readOnlyHint ⇒ assume it mutates ⇒ the confirm turn. Being wrong
|
||||
// in this direction only costs a question.
|
||||
destructive := !t.ReadOnly
|
||||
provenance := fmt.Sprintf("mcp %s/%s", t.Server, t.Name)
|
||||
if t.Description != "" {
|
||||
provenance += ": " + t.Description
|
||||
}
|
||||
fp := mcp.Fingerprint(t)
|
||||
ok, err := w.st.ProposeMCPTool(ctx, name, mcp.Scope(t.Server),
|
||||
mcp.Cmd(t.Server, t.Name), destructive, provenance, fp, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: propose %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
fresh++
|
||||
continue
|
||||
}
|
||||
// The row already existed. Its provenance is whatever the server said
|
||||
// the first time; reconciling rewrites it, so what /tools shows is what
|
||||
// the server says now.
|
||||
ch, err := w.st.ReconcileMCPTool(ctx, name, fp, destructive, provenance, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: reconcile %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if !ch.Changed {
|
||||
continue
|
||||
}
|
||||
changed++
|
||||
switch {
|
||||
case ch.Demoted && ch.Escalated:
|
||||
log.Printf("mcp: %s changed on the server and no longer claims read-only — disabled and marked destructive, re-approve it on /tools", name)
|
||||
case ch.Demoted:
|
||||
log.Printf("mcp: %s changed on the server since it was enabled — disabled, re-approve it on /tools", name)
|
||||
default:
|
||||
log.Printf("mcp: %s changed on the server; the proposal now shows the new description", name)
|
||||
}
|
||||
}
|
||||
w.withdrawGone(ctx, seen, now)
|
||||
if fresh > 0 {
|
||||
log.Printf("mcp: %d new tool proposal(s) waiting on /tools", fresh)
|
||||
}
|
||||
if changed > 0 {
|
||||
log.Printf("mcp: %d tool(s) changed since approval and need another look", changed)
|
||||
}
|
||||
}
|
||||
|
||||
// withdrawGone disarms rows whose tool the server stopped offering. Only
|
||||
// servers that are CONNECTED are considered: a tool missing because its server
|
||||
// is down is not a tool that was withdrawn, and disabling a capability every
|
||||
// time a process restarts would be worse than the problem.
|
||||
func (w *mcpWiring) withdrawGone(ctx context.Context, seen map[string]string, now time.Time) {
|
||||
live := map[string]bool{}
|
||||
for _, name := range w.mgr.Connected() {
|
||||
live[name] = true
|
||||
}
|
||||
if len(live) == 0 {
|
||||
return
|
||||
}
|
||||
rows, err := w.st.ListTools(ctx, "")
|
||||
if err != nil {
|
||||
log.Printf("mcp: list tools: %v", err)
|
||||
return
|
||||
}
|
||||
for _, row := range rows {
|
||||
server, remote, ok := mcp.ParseCmd(row.Cmd)
|
||||
if !ok || !live[server] {
|
||||
continue
|
||||
}
|
||||
if _, still := seen[row.Name]; still {
|
||||
continue
|
||||
}
|
||||
note := fmt.Sprintf("mcp %s/%s: no longer offered by the server", server, remote)
|
||||
wasEnabled, err := w.st.WithdrawTool(ctx, row.Name, note, now)
|
||||
if err != nil {
|
||||
log.Printf("mcp: withdraw %s: %v", row.Name, err)
|
||||
continue
|
||||
}
|
||||
if wasEnabled {
|
||||
log.Printf("mcp: %s was enabled but %s no longer offers it — disabled", row.Name, server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// run re-dials downed servers and picks up tools that appeared, until ctx is
|
||||
// canceled.
|
||||
func (w *mcpWiring) run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
// The first dial happens here rather than at wiring time, so boot never
|
||||
// waits on someone else's process.
|
||||
w.connect(ctx)
|
||||
t := time.NewTicker(mcpRefreshInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.mgr.Refresh(ctx)
|
||||
w.propose(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// status maps the manager's view onto the wire type the web surface reads.
|
||||
func (w *mcpWiring) status() []ipc.MCPServerStatus {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
in := w.mgr.Status()
|
||||
out := make([]ipc.MCPServerStatus, 0, len(in))
|
||||
for _, s := range in {
|
||||
out = append(out, ipc.MCPServerStatus{
|
||||
Name: s.Name,
|
||||
Transport: s.Transport,
|
||||
Target: s.Target,
|
||||
Connected: s.Connected,
|
||||
Server: s.Server,
|
||||
Tools: s.Tools,
|
||||
Err: s.Err,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (w *mcpWiring) close() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
_ = w.mgr.Close()
|
||||
}
|
||||
|
||||
// caller is the tool.MCPCaller the executor gets, or nil when MCP is off.
|
||||
func (w *mcpWiring) caller() *mcp.Manager {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.mgr
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
func TestWireMCPOffWhenUnconfigured(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"nothing enabled": {MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{
|
||||
{Name: "vikunja", URL: "http://192.168.1.104:9100/mcp"},
|
||||
}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireMCP(cfg, st); w != nil {
|
||||
t.Fatal("MCP must be off unless a server is configured AND enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
// nil wiring must be safe to use everywhere it is reachable.
|
||||
var w *mcpWiring
|
||||
w.close()
|
||||
w.propose(context.Background())
|
||||
if w.status() != nil || w.caller() != nil {
|
||||
t.Fatal("a nil wiring must report nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// Wiring must not dial. Boot used to block for the whole per-server timeout
|
||||
// budget on a black-holed endpoint, and on the passkey path that delay landed
|
||||
// inside the unlock handler.
|
||||
func TestWireMCPDoesNotDial(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured server should wire")
|
||||
}
|
||||
defer w.close()
|
||||
if s := w.status(); len(s) != 1 || s[0].Err != "" {
|
||||
t.Fatalf("wireMCP dialled: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable server must not stop the daemon, must be reported as down, and
|
||||
// must propose nothing.
|
||||
func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured server should still wire")
|
||||
}
|
||||
defer w.close()
|
||||
w.connect(context.Background())
|
||||
st2 := w.status()
|
||||
if len(st2) != 1 || st2[0].Connected || st2[0].Err == "" {
|
||||
t.Fatalf("status = %+v", st2)
|
||||
}
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Fatalf("a server that never answered must propose nothing, got %+v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// A url server whose address is private is refused by webfetch unless that
|
||||
// server sets allow_private. This is the guard the whole MCP path rides on, so
|
||||
// it is asserted here too, at the wiring level.
|
||||
func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
|
||||
Name: "lan", URL: "http://127.0.0.1:9100/mcp", Enabled: true,
|
||||
}}}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("should wire")
|
||||
}
|
||||
defer w.close()
|
||||
w.connect(context.Background())
|
||||
s := w.status()[0]
|
||||
if s.Connected {
|
||||
t.Fatal("a loopback server must not connect without allow_private")
|
||||
}
|
||||
if !strings.Contains(s.Err, "private address") {
|
||||
t.Fatalf("err = %q, want the private-address refusal", s.Err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// mavend/memoryeval.go — the driver for background memory evaluation
|
||||
// (Vikunja #248). The evaluator itself is pure-ish and lives in
|
||||
// internal/memeval; this is the one impure part: a ticker, the store, and the
|
||||
// resident model's base URL.
|
||||
//
|
||||
// It is its own goroutine and NOT a step on the main tick, deliberately. The
|
||||
// tick runs every 60s and has a delivery deadline behind it; an evaluation is
|
||||
// a multi-second LLM round-trip on the same llama-server that answers voice
|
||||
// turns, and it happens hourly at most. Bolting it onto the tick would make
|
||||
// every hour's tick the slow one for no benefit.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/memeval"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// memoryEvalTimeout — the per-request deadline on one evaluation.
|
||||
//
|
||||
// It used to be five minutes, on the grounds that nobody waits for the answer.
|
||||
// Nobody waits for the evaluation, but there is ONE resident model behind one
|
||||
// llama-server, so a voice turn arriving mid-evaluation waited behind it: five
|
||||
// minutes of evaluation was five minutes of a mute assistant.
|
||||
//
|
||||
// The background client now yields the slot while a turn is in flight, so the
|
||||
// collision is handled where it belongs and this is a prompt budget again.
|
||||
// Sixty seconds is long enough for a Thinking model here, and an evaluation cut
|
||||
// off costs nothing, because it is retried at the next interval. Raise it if
|
||||
// observations start truncating.
|
||||
const memoryEvalTimeout = 60 * time.Second
|
||||
|
||||
// memoryEvalWorker — ticker + evaluator.
|
||||
type memoryEvalWorker struct {
|
||||
eval *memeval.Evaluator
|
||||
interval time.Duration
|
||||
}
|
||||
|
||||
// newMemoryEvalWorker wires the evaluation loop, or returns nil when it should
|
||||
// not run at all. nil is the normal case and every caller must handle it:
|
||||
//
|
||||
// - no memory_eval config block ⇒ off (a capability is off unless configured);
|
||||
// - no LLM phraser ⇒ nothing to evaluate with. There is no template fallback
|
||||
// here on purpose: a "memory evaluation" assembled from string templates
|
||||
// would be a fixed sentence pretending to be an observation.
|
||||
func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Config) *memoryEvalWorker {
|
||||
if cfg.MemoryEval == nil {
|
||||
return nil
|
||||
}
|
||||
lp, ok := phr.(*phraser.LLMPhraser)
|
||||
if !ok {
|
||||
log.Printf("memory eval: configured but no llama-server phraser — evaluation disabled")
|
||||
return nil
|
||||
}
|
||||
interval := time.Duration(cfg.MemoryEval.Interval)
|
||||
if interval <= 0 {
|
||||
interval = config.DefaultMemoryEvalInterval
|
||||
}
|
||||
// Background: nobody is waiting on an observation, and it must not sit in
|
||||
// front of a voice turn on the single llama-server slot.
|
||||
client := llmBackgroundClientFor(lp, memoryEvalTimeout)
|
||||
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
|
||||
MaxItems: cfg.MemoryEval.MaxItems,
|
||||
MinConfidence: cfg.MemoryEval.MinConfidence,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
})
|
||||
log.Printf("memory eval: enabled, every %s", interval)
|
||||
return &memoryEvalWorker{eval: ev, interval: interval}
|
||||
}
|
||||
|
||||
// run evaluates every interval until ctx is canceled.
|
||||
//
|
||||
// The first evaluation waits a full interval rather than firing at startup, the
|
||||
// opposite of the tick loop's cold-start behaviour. A tick that fires late is a
|
||||
// nudge that arrives late; an evaluation that fires late is nothing at all, and
|
||||
// the alternative is a heavy LLM call competing with startup — including with
|
||||
// the first voice turn after a restart.
|
||||
func (w *memoryEvalWorker) run(ctx context.Context) {
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
obs, err := w.eval.Evaluate(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("memory eval: %v", err)
|
||||
continue
|
||||
}
|
||||
for _, o := range obs {
|
||||
log.Printf("memory eval: noted (%.2f, %s): %s", o.Conf, o.Action, o.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// Swapping the resident model while the daemon runs (Vikunja #250).
|
||||
//
|
||||
// Off unless configured: with no phraser.swap_models allowlist the two IPC
|
||||
// methods are never wired, so they answer ErrUnknownMethod. When it is wired the
|
||||
// swap method is AuthStepUp (internal/auth), which means an authed human surface
|
||||
// only — there is no act, no intent and no timer that reaches it. The daemon
|
||||
// never decides to change its own brain.
|
||||
//
|
||||
// The allowlist is exact-match against paths a human wrote in mavend.json. The
|
||||
// request carries a path and llama-server is started with it as `-m`, so
|
||||
// anything looser would turn "swap the model" into "load any file on my disk".
|
||||
func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
|
||||
if cfg.Phraser == nil || len(cfg.Phraser.SwapModels) == 0 {
|
||||
return
|
||||
}
|
||||
lp, ok := phr.(*phraser.LLMPhraser)
|
||||
if !ok {
|
||||
log.Printf("model swap: phraser.swap_models is set but there is no llama-server phraser — swap disabled")
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{}
|
||||
for _, m := range cfg.Phraser.SwapModels {
|
||||
allowed[filepath.Clean(m)] = true
|
||||
}
|
||||
// The configured model is always swappable back to, listed or not: the way
|
||||
// out of a bad swap must not depend on remembering to allowlist the model
|
||||
// you are already running.
|
||||
allowed[filepath.Clean(cfg.Phraser.ModelPath)] = true
|
||||
|
||||
srv.SwapModelFn = func(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
|
||||
path := filepath.Clean(req.ModelPath)
|
||||
if !allowed[path] {
|
||||
log.Printf("model swap: REFUSED %q — not in phraser.swap_models", req.ModelPath)
|
||||
return ipc.SwapModelResp{}, fmt.Errorf("%w: %q is not in phraser.swap_models", ipc.ErrForbidden, req.ModelPath)
|
||||
}
|
||||
res, err := lp.Swap(ctx, phraser.SwapSpec{
|
||||
ModelPath: path,
|
||||
NGpuLayers: req.NGpuLayers,
|
||||
NCtx: req.NCtx,
|
||||
})
|
||||
resp := ipc.SwapModelResp{
|
||||
Model: res.Model,
|
||||
ModelPath: res.ModelPath,
|
||||
BaseURL: res.BaseURL,
|
||||
RolledBack: res.RolledBack,
|
||||
NoBackend: res.NoBackend,
|
||||
TookMs: res.Took.Milliseconds(),
|
||||
}
|
||||
if err != nil {
|
||||
// A rolled-back swap is a failure that left a working daemon behind.
|
||||
// Both halves matter to the caller, so the response is filled in even
|
||||
// though the error is returned.
|
||||
log.Printf("model swap: %v", err)
|
||||
return resp, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
srv.ModelStatusFn = func(ctx context.Context) (ipc.ModelStatusResp, error) {
|
||||
path, ngl, nctx := lp.LiveModel()
|
||||
base := lp.BaseURL()
|
||||
resp := ipc.ModelStatusResp{
|
||||
ModelPath: path,
|
||||
BaseURL: base,
|
||||
NGpuLayers: ngl,
|
||||
NCtx: nctx,
|
||||
Swappable: cfg.Phraser.SwapModels,
|
||||
}
|
||||
if base == "" {
|
||||
resp.Model = llm.UnknownModel
|
||||
return resp, nil
|
||||
}
|
||||
id, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
// Report the honest "I could not confirm it" rather than echoing the
|
||||
// configured filename as if the server had said it.
|
||||
resp.Model = llm.UnknownModel
|
||||
return resp, nil
|
||||
}
|
||||
resp.Model = id
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
log.Printf("model swap: enabled, %d allowlisted model(s) — step-up required", len(cfg.Phraser.SwapModels))
|
||||
}
|
||||
|
||||
// llmClientFor builds a completion client on the phraser's llama-server and
|
||||
// keeps it pointed at the right one across a model swap.
|
||||
//
|
||||
// Without the OnSwap registration every holder of a base URL — the LLM router,
|
||||
// the replier, the mail extractor, the memory evaluator — would keep talking to
|
||||
// the port of a server that no longer exists, and the daemon would degrade to
|
||||
// the classifier permanently after the first swap. The client is re-pointed, not
|
||||
// rebuilt, so nothing that holds it has to know a swap happened.
|
||||
// SetSwapGate is the other half, and on the deploy shape it is the load-bearing
|
||||
// one:
|
||||
// llama-server is relaunched on the same fixed port, so SetBaseURL is usually a
|
||||
// no-op, while the gate is what makes the swap's drain count these callers at
|
||||
// all. Without it a swap can kill the server mid-routing-decision.
|
||||
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
|
||||
c := llm.New(lp.BaseURL(), timeout)
|
||||
c.SetGate(residentGate, false)
|
||||
c.SetSwapGate(lp)
|
||||
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
|
||||
return c
|
||||
}
|
||||
|
||||
// backgroundQuiet — how long background work stays off the resident model after
|
||||
// a foreground request. Long enough to cover the gap between the router call and
|
||||
// the phraser call of one turn (router p50 is ~2.7s on this box), short enough
|
||||
// that a quiet mailbox is still read promptly.
|
||||
const backgroundQuiet = 10 * time.Second
|
||||
|
||||
// residentGate — the priority gate on the one llama-server slot, shared by every
|
||||
// client llmClientFor builds. Package level because the daemon owns exactly one
|
||||
// llama-server: two gates would be two opinions about one queue.
|
||||
//
|
||||
// The problem it solves: llama-server runs a single slot, so requests queue. Mail
|
||||
// extraction is allowed two minutes, and a first poll can hand core 25 messages
|
||||
// back to back. Without a gate a voice turn arriving mid-extraction waits for
|
||||
// whatever is left of that budget, the router times out into the classifier
|
||||
// cascade at its 36.8% floor, and the phraser just waits.
|
||||
var residentGate = llm.NewGate(backgroundQuiet)
|
||||
|
||||
// llmBackgroundClientFor is llmClientFor for work nobody is waiting on: mail
|
||||
// extraction and memory evaluation. Same swap-following client, but it yields
|
||||
// to voice turns and only one such request runs at a time.
|
||||
func llmBackgroundClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
|
||||
c := llm.New(lp.BaseURL(), timeout)
|
||||
c.SetGate(residentGate, true)
|
||||
c.SetSwapGate(lp)
|
||||
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
)
|
||||
|
||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||
// longer than this has already failed as a turn, so the scan returns whatever
|
||||
// it found rather than keeping him waiting.
|
||||
//
|
||||
// It has to be consistent with the shipped defaults or every scan is truncated:
|
||||
// a /24 at four ports is 1016 probes, which at netscan.DefaultRate of 100 a
|
||||
// second is a little over ten seconds plus the tail dials. 30s leaves room for
|
||||
// that without pretending a slower rate would fit.
|
||||
const scanBudget = 30 * time.Second
|
||||
|
||||
// scanCacheTTL — how long a scan answer is reused. Two questions in a row used
|
||||
// to be two full sweeps of the LAN, up to a thousand connections each. The
|
||||
// network does not change on the scale of a follow-up question, and the cheapest
|
||||
// packet is the one not sent.
|
||||
const scanCacheTTL = 2 * time.Minute
|
||||
|
||||
// scanReadOut — how many hosts go into the written record's first lines before
|
||||
// it says "и ещё N". Nothing reads addresses out loud; see scanSummary.
|
||||
const scanReadOut = 20
|
||||
|
||||
// netWiring — the LAN scanner, when the `netscan` block is enabled. nil ⇒ Maven
|
||||
// never puts a discovery packet on the network.
|
||||
//
|
||||
// Unlike the house, a scan is a READ, so it is a query source rather than an
|
||||
// act: there is no allowlist row and no confirm turn, because nothing changes.
|
||||
// What makes that safe is that the range is not an argument — see
|
||||
// internal/netscan's package comment.
|
||||
type netWiring struct {
|
||||
scanner *netscan.Scanner
|
||||
subnets []string
|
||||
// api — where the address list is WRITTEN. The spoken answer is a count
|
||||
// and a shape, so the detail has to land somewhere readable; a note under
|
||||
// source "scan:lan" puts it on /history and, through the intake decorator,
|
||||
// on /events. It is also the only record that Maven put packets on the LAN
|
||||
// at all. nil ⇒ nothing is written, which is what the tests use.
|
||||
api ipc.CoreAPI
|
||||
now func() time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
cached netscan.Result
|
||||
cachedAt time.Time
|
||||
}
|
||||
|
||||
// wireNetScan builds the scanner. nil unless the block is enabled and valid.
|
||||
func wireNetScan(cfg *config.Config, api ipc.CoreAPI) *netWiring {
|
||||
nc, ok := cfg.NetScanner()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if err := netscan.Validate(nc); err != nil {
|
||||
// config.validate already ran this, so reaching here is a programming
|
||||
// error rather than a config one. Not fatal: the scanner off is a
|
||||
// working Maven.
|
||||
log.Printf("netscan: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
return &netWiring{scanner: netscan.New(nc), subnets: nc.Subnets, api: api, now: time.Now}
|
||||
}
|
||||
|
||||
// scan runs a scan, or reuses one younger than scanCacheTTL.
|
||||
func (w *netWiring) scan(ctx context.Context) (netscan.Result, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
now := w.now()
|
||||
if !w.cachedAt.IsZero() && now.Sub(w.cachedAt) < scanCacheTTL {
|
||||
return w.cached, nil
|
||||
}
|
||||
scanCtx, cancel := context.WithTimeout(ctx, scanBudget)
|
||||
defer cancel()
|
||||
res, err := w.scanner.Scan(scanCtx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
w.cached, w.cachedAt = res, now
|
||||
// Written on a fresh scan only: the record is a trace of packets going out,
|
||||
// so a cached answer must not forge a second one.
|
||||
w.writeScanRecord(ctx, res)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// scanSummary answers "какие устройства в сети?" in one spoken line.
|
||||
//
|
||||
// It does NOT read addresses out. This is the query path, so the reply goes to
|
||||
// piper as well as to /chat, and "192.168.1.1 (80, 443); 192.168.1.14 (22)" is
|
||||
// a digit stream nobody can follow through a speaker. She says how many and
|
||||
// what shape they are; the addresses go into a note (see writeScanRecord).
|
||||
func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
res, err := w.scan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("netscan: scan: %v", err)
|
||||
return "не получилось просканировать сеть.", true
|
||||
}
|
||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||
// устройств" after stopping two thirds of the way through the range is a
|
||||
// false claim, and the addresses at the end are the ones that go missing.
|
||||
tail := ""
|
||||
if res.Truncated {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
if len(res.Hosts) == 0 {
|
||||
return "в сети никого не нашла" + tail + ".", true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
out += ", " + shape
|
||||
}
|
||||
out += tail
|
||||
if w.api != nil {
|
||||
out += ". список записала"
|
||||
}
|
||||
return out + ".", true
|
||||
}
|
||||
|
||||
// scanShape describes the hosts by what they answer on, which is the part of
|
||||
// the answer that carries meaning out loud: "два с вебом" says more about the
|
||||
// flat than four octets do.
|
||||
func scanShape(hosts []netscan.Host) string {
|
||||
var web, ssh, quiet int
|
||||
for _, h := range hosts {
|
||||
hasWeb, hasSSH := false, false
|
||||
for _, p := range h.Ports {
|
||||
switch p {
|
||||
case 80, 443, 8080:
|
||||
hasWeb = true
|
||||
case 22:
|
||||
hasSSH = true
|
||||
}
|
||||
}
|
||||
if hasWeb {
|
||||
web++
|
||||
}
|
||||
if hasSSH {
|
||||
ssh++
|
||||
}
|
||||
// No open port at all: seen only through the ARP cache.
|
||||
if len(h.Ports) == 0 {
|
||||
quiet++
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if web > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с вебом", web))
|
||||
}
|
||||
if ssh > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d с ssh", ssh))
|
||||
}
|
||||
if quiet > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d молча", quiet))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "из них " + strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// writeScanRecord stores the address list as a note. This is both where the
|
||||
// detail becomes readable and the only trace that a scan happened at all: a
|
||||
// scan is a read, but "when did she last put packets on the LAN" deserves an
|
||||
// answer.
|
||||
func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
||||
if w.api == nil {
|
||||
return
|
||||
}
|
||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if res.Truncated {
|
||||
head += " (не вся сеть)"
|
||||
}
|
||||
lines := []string{head, "подсети: " + strings.Join(w.subnets, ", ")}
|
||||
shown := res.Hosts
|
||||
if len(shown) > scanReadOut {
|
||||
shown = shown[:scanReadOut]
|
||||
}
|
||||
for _, h := range shown {
|
||||
s := h.Addr
|
||||
if len(h.Ports) > 0 {
|
||||
ps := make([]string, 0, len(h.Ports))
|
||||
for _, p := range h.Ports {
|
||||
ps = append(ps, fmt.Sprintf("%d", p))
|
||||
}
|
||||
s += " (" + strings.Join(ps, ", ") + ")"
|
||||
}
|
||||
if h.MAC != "" {
|
||||
s += " " + h.MAC
|
||||
}
|
||||
lines = append(lines, s)
|
||||
}
|
||||
if len(res.Hosts) > len(shown) {
|
||||
lines = append(lines, fmt.Sprintf("и ещё %d", len(res.Hosts)-len(shown)))
|
||||
}
|
||||
if _, err := w.api.WriteNote(ctx, w.now(), strings.Join(lines, "\n"), nil, "scan:lan"); err != nil {
|
||||
log.Printf("netscan: write scan note: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
||||
// 5+ устройств, and the teens are all the last form.
|
||||
func hostWord(n int) string {
|
||||
if n%100 >= 11 && n%100 <= 14 {
|
||||
return "устройств"
|
||||
}
|
||||
switch n % 10 {
|
||||
case 1:
|
||||
return "устройство"
|
||||
case 2, 3, 4:
|
||||
return "устройства"
|
||||
default:
|
||||
return "устройств"
|
||||
}
|
||||
}
|
||||
|
||||
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||
// bounds exist to prevent.
|
||||
func isNetworkQuery(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
// Whole tokens for the network nouns: the bare substring "сети" is inside
|
||||
// "посетил", so "сколько машин я посетил?" used to read as a request to
|
||||
// scan the LAN. The prefix forms below are stems that have no such
|
||||
// collisions.
|
||||
network := false
|
||||
for _, w := range []string{"сеть", "сети", "сетке", "сетку"} {
|
||||
if homeWord(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
for _, w := range []string{"локальн", "wifi", "wi-fi", "вайфай"} {
|
||||
if strings.Contains(s, w) {
|
||||
network = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !network {
|
||||
return false
|
||||
}
|
||||
// An explicit ask to scan, or a phrase that can only be about the LAN.
|
||||
// "кто в сети" carries no device noun but means nothing else.
|
||||
for _, w := range []string{"просканируй", "сканируй", "скан", "просканир", "кто в сети", "кто в сетке"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
ask := strings.Contains(s, "?") || homeWord(s, "какие") || homeWord(s, "кто") ||
|
||||
homeWord(s, "что") || homeWord(s, "сколько") || strings.Contains(s, "покажи")
|
||||
if !ask {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"устройств", "хост", "компьютер", "машин", "адрес"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"written but dark": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"},
|
||||
}},
|
||||
"enabled but nothing to scan": {NetScan: &config.NetScanConfig{Enabled: true}},
|
||||
"enabled but public": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"8.8.8.0/24"}, Enabled: true,
|
||||
}},
|
||||
"enabled but far too wide": {NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"10.0.0.0/8"}, Enabled: true,
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireNetScan(cfg, nil); w != nil {
|
||||
t.Fatal("the scanner must not wire for this config")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
var w *netWiring
|
||||
if _, ok := w.scanSummary(context.Background()); ok {
|
||||
t.Fatal("a nil wiring must not claim a query")
|
||||
}
|
||||
|
||||
ok := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"192.168.1.0/24"}, Enabled: true,
|
||||
}}, nil)
|
||||
if ok == nil {
|
||||
t.Fatal("a valid enabled block should wire")
|
||||
}
|
||||
}
|
||||
|
||||
// A loopback /32 with nothing listening on the scanned port: the summary must
|
||||
// come back honest rather than inventing a host. This also exercises the real
|
||||
// dialer end to end without touching anything outside this box.
|
||||
func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
// Port 1 on loopback: nothing listens and the connection is refused
|
||||
// immediately, so the scan is fast and touches only this machine.
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{1}, Rate: 1000, Enabled: true,
|
||||
}}, nil)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if out == "" {
|
||||
t.Fatal("empty summary")
|
||||
}
|
||||
// Persona: feminine self-reference, informal address, no pet names.
|
||||
low := strings.ToLower(out)
|
||||
for _, bad := range []string{"нашёл", "не смог ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(low, bad) {
|
||||
t.Errorf("persona violation %q in %q", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
||||
for n, want := range map[int]string{
|
||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
||||
} {
|
||||
if got := hostWord(n); got != want {
|
||||
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNetworkQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие устройства в сети?",
|
||||
"кто в сети?",
|
||||
"просканируй сеть",
|
||||
"покажи устройства в локальной сети",
|
||||
"сколько машин в сети",
|
||||
}
|
||||
no := []string{
|
||||
"",
|
||||
"интернет не работает",
|
||||
"сеть какая-то медленная",
|
||||
"я в сети инстаграма",
|
||||
"что включено дома?",
|
||||
"напомни оплатить интернет",
|
||||
}
|
||||
for _, u := range yes {
|
||||
if !isNetworkQuery(u) {
|
||||
t.Errorf("isNetworkQuery(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range no {
|
||||
if isNetworkQuery(u) {
|
||||
t.Errorf("isNetworkQuery(%q) = true, want false", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notingAPI counts the notes a scan writes, and remembers the last one.
|
||||
type notingAPI struct {
|
||||
ipc.CoreAPI
|
||||
n int
|
||||
last string
|
||||
}
|
||||
|
||||
func (a *notingAPI) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, _ string) (int64, error) {
|
||||
a.n++
|
||||
a.last = text
|
||||
return int64(a.n), nil
|
||||
}
|
||||
|
||||
// The spoken answer must not be a list of IP addresses. It goes to piper as
|
||||
// well as to /chat, and six dotted quads read out as a digit stream is not an
|
||||
// answer anybody can use. The addresses belong in the written record.
|
||||
func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
api := ¬ingAPI{}
|
||||
w := wireNetScan(&config.Config{NetScan: &config.NetScanConfig{
|
||||
Subnets: []string{"127.0.0.1/32"}, Ports: []int{port}, Rate: 1000, Enabled: true,
|
||||
}}, api)
|
||||
if w == nil {
|
||||
t.Fatal("wireNetScan returned nil")
|
||||
}
|
||||
out, claimed := w.scanSummary(context.Background())
|
||||
if !claimed {
|
||||
t.Fatal("the summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "127.0.0.1") || strings.Contains(out, portStr) {
|
||||
t.Errorf("the spoken reply reads addresses out loud: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "нашла 1 устройство") {
|
||||
t.Errorf("reply = %q, want a count", out)
|
||||
}
|
||||
if api.n != 1 {
|
||||
t.Fatalf("wrote %d notes, want 1", api.n)
|
||||
}
|
||||
if !strings.Contains(api.last, "127.0.0.1") {
|
||||
t.Errorf("the written record has no addresses: %q", api.last)
|
||||
}
|
||||
|
||||
// A follow-up question inside the TTL reuses the answer: two questions in
|
||||
// a row must not be two sweeps of the LAN.
|
||||
if _, _ = w.scanSummary(context.Background()); api.n != 1 {
|
||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// mavend/patterns.go — the shared detect+propose step of pattern inference
|
||||
// (Vikunja #43). Event *extraction* (fact -> action/object) happens at fact-
|
||||
// write time in detectPattern below, tied to whichever channel wrote the
|
||||
// fact. Detection — turning a run of events into a proposed routine — is
|
||||
// channel-agnostic: it only needs what's already in the events table, so it
|
||||
// runs both right after a voice fact-write (for the immediate "напоминать?"
|
||||
// confirmation) and, proactively, from the digestion tick (tick.go's
|
||||
// detectPatterns) over every action+object pair on record, not just the one
|
||||
// that was just talked about.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// detectAndPropose runs the pattern detector over every recorded event for
|
||||
// action+object and, if a stable pattern is found and nothing has been
|
||||
// proposed/accepted/dismissed for this pair yet, creates a proposed_routines
|
||||
// row. Returns (nil, 0, nil) — not an error — whenever there is nothing new
|
||||
// to report: too few events, irregular intervals, or a pair that already has
|
||||
// a row in any status. That last case is the one that matters most: it is
|
||||
// how a routine the owner already DISMISSED stays dismissed forever, because
|
||||
// the row survives dismissal (status flips in place, see
|
||||
// store.DismissProposedRoutine) and both the Lookup check here and the
|
||||
// table's UNIQUE(action, object) constraint refuse to create a second one.
|
||||
func detectAndPropose(ctx context.Context, ds *store.Store, action, object string, ts time.Time) (*pattern.ProposedRoutine, int64, error) {
|
||||
events, err := ds.EventsFor(ctx, action, object)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("events for %s/%s: %w", action, object, err)
|
||||
}
|
||||
patEvents := make([]pattern.Event, len(events))
|
||||
for i, e := range events {
|
||||
patEvents[i] = pattern.Event{
|
||||
FactID: e.FactID,
|
||||
Action: e.Action,
|
||||
Object: e.Object,
|
||||
Ts: e.Ts,
|
||||
}
|
||||
}
|
||||
r, err := pattern.Detect(patEvents)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("detect %s/%s: %w", action, object, err)
|
||||
}
|
||||
if r == nil {
|
||||
return nil, 0, nil // not enough data or intervals too irregular
|
||||
}
|
||||
|
||||
// Belt: check first so the common "nothing new" case never even attempts
|
||||
// an insert. Suspenders: CreateProposedRoutine's ON CONFLICT DO NOTHING
|
||||
// (backed by the UNIQUE(action,object) constraint) is the actual
|
||||
// guarantee — this Lookup is an optimization, not the source of truth.
|
||||
existing, err := ds.LookupProposedRoutine(ctx, r.Action, r.Object)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("lookup proposed routine %s/%s: %w", action, object, err)
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, 0, nil // already proposed, accepted, or dismissed — say nothing
|
||||
}
|
||||
|
||||
id, err := ds.CreateProposedRoutine(ctx, r.Action, r.Object, r.IntervalDays, ts)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrProposedRoutineExists) {
|
||||
return nil, 0, nil // lost a race with another caller — not an error
|
||||
}
|
||||
return nil, 0, fmt.Errorf("create proposed routine %s/%s: %w", action, object, err)
|
||||
}
|
||||
return r, id, nil
|
||||
}
|
||||
|
||||
// detectPattern extracts an event from the written fact and runs the pattern
|
||||
// detector. If a stable recurring pattern is found and no proposed routine
|
||||
// exists for this action+object yet, one is created and the user is prompted
|
||||
// to confirm via the park() mechanism. Returns the suggestion phrase when a
|
||||
// new proposal was created and parked; "" otherwise.
|
||||
func (h *reactiveHandler) detectPattern(ctx context.Context, factID int64, key, value string, ts time.Time) string {
|
||||
ev := pattern.Extract(factID, key, value, ts)
|
||||
if ev == nil {
|
||||
return "" // not an actionable event
|
||||
}
|
||||
if _, err := h.dataStore.CreateEvent(ctx, factID, ev.Action, ev.Object, ts); err != nil {
|
||||
log.Printf("voice: create event: %v", err)
|
||||
return ""
|
||||
}
|
||||
// Detect+propose (Vikunja #43) is shared with the digestion tick's
|
||||
// proactive scan — see detectAndPropose above. Event *extraction* stays
|
||||
// here, tied to this fact write; detection over the accumulated history does
|
||||
// not need to happen right now for the voice path to have already done
|
||||
// its job — it's dedupe-safe to also let the next tick find the same
|
||||
// pattern independently.
|
||||
r, id, err := detectAndPropose(ctx, h.dataStore, ev.Action, ev.Object, ts)
|
||||
if err != nil {
|
||||
log.Printf("voice: detect pattern %s/%s: %v", ev.Action, ev.Object, err)
|
||||
return ""
|
||||
}
|
||||
if r == nil {
|
||||
return "" // not enough data, too irregular, or already proposed/decided
|
||||
}
|
||||
log.Printf("voice: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
|
||||
// Park the proposal for voice confirmation.
|
||||
phrase := pattern.PhraseRoutine(r)
|
||||
h.mu.Lock()
|
||||
h.pendingRoutine = &pendingRoutineConfirm{
|
||||
routineID: id,
|
||||
action: r.Action,
|
||||
object: r.Object,
|
||||
interval: r.IntervalDays,
|
||||
phrase: phrase,
|
||||
expiry: ts.Add(confirmTTL),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return phrase
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// seedRefillEvents writes N weekly "refill/cat_water" events straight to the
|
||||
// events table — this is what the tick reads, independent of any utterance.
|
||||
func seedRefillEvents(t *testing.T, st *store.Store, ctx context.Context, base time.Time, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
factID, err := st.WriteFact(ctx, base.Add(time.Duration(i)*7*24*time.Hour), store.KindSelf,
|
||||
"cat_water", "refill", "test", 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("write fact %d: %v", i, err)
|
||||
}
|
||||
if _, err := st.CreateEvent(ctx, factID, "refill", "cat_water", base.Add(time.Duration(i)*7*24*time.Hour)); err != nil {
|
||||
t.Fatalf("create event %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTickDetectsPatternFromStoredEvents proves the tick notices a pattern on
|
||||
// its own, reading straight from the store — not as a side effect of a live
|
||||
// utterance (Vikunja #43). MinEvents weekly events with no voice turn in
|
||||
// sight must produce exactly one proposed routine.
|
||||
func TestTickDetectsPatternFromStoredEvents(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
tl.detectPatterns(ctx, now, loop.State{})
|
||||
|
||||
rows, err := st.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed routines: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("proposed routines = %d, want 1: %+v", len(rows), rows)
|
||||
}
|
||||
if rows[0].Action != "refill" || rows[0].Object != "cat_water" {
|
||||
t.Errorf("proposed routine = %s/%s, want refill/cat_water", rows[0].Action, rows[0].Object)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTickPatternDetectionIsIdempotent proves running the tick's pattern scan
|
||||
// twice does not spam a second proposal for the same pair, and that the store
|
||||
// itself is what stops the duplicate (not tick-local state) — the whole point
|
||||
// of the guard, since the tick has no memory of what it proposed last time.
|
||||
func TestTickPatternDetectionIsIdempotent(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
tl.detectPatterns(ctx, now, loop.State{})
|
||||
tl.detectPatterns(ctx, now.Add(time.Hour), loop.State{})
|
||||
|
||||
rows, err := st.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed routines: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("proposed routines after two ticks = %d, want 1 (no duplicate): %+v", len(rows), rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTickPatternDetectionRespectsDismissal proves the single worst failure
|
||||
// mode here — a proposal the owner already said no to coming back on the next
|
||||
// tick — cannot happen. Dismissal flips the row's status in place; it must
|
||||
// still be there to block re-proposal.
|
||||
func TestTickPatternDetectionRespectsDismissal(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
tl.detectPatterns(ctx, now, loop.State{})
|
||||
|
||||
rows, err := st.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed routines: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("setup: proposed routines = %d, want 1", len(rows))
|
||||
}
|
||||
if err := st.DismissProposedRoutine(ctx, rows[0].ID); err != nil {
|
||||
t.Fatalf("dismiss: %v", err)
|
||||
}
|
||||
|
||||
// More events for the same pair arrive, and the tick runs again — a
|
||||
// dismissed pattern must not resurface.
|
||||
seedRefillEvents(t, st, ctx, now.Add(30*24*time.Hour), pattern.MinEvents)
|
||||
tl.detectPatterns(ctx, now.Add(60*24*time.Hour), loop.State{})
|
||||
|
||||
proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed: %v", err)
|
||||
}
|
||||
if len(proposed) != 0 {
|
||||
t.Fatalf("a dismissed pattern came back: %+v", proposed)
|
||||
}
|
||||
all, err := st.ListProposedRoutinesByStatus(ctx, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list all: %v", err)
|
||||
}
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("total rows for the pair = %d, want 1 (still dismissed, not duplicated): %+v", len(all), all)
|
||||
}
|
||||
if all[0].Status != store.RoutineDismissed {
|
||||
t.Errorf("status = %s, want dismissed", all[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
// proposalRule — the rule name announceProposal uses for the seeded pair.
|
||||
const proposalRule = "proposal:refill cat_water"
|
||||
|
||||
// TestTickProposalSilentByDefault — detection is always on, announcing is not.
|
||||
// With no pattern_proposals block the tick still records the proposal, and says
|
||||
// nothing about it: Maven is not autonomous, so a behaviour that speaks without
|
||||
// being asked stays off until it is configured.
|
||||
func TestTickProposalSilentByDefault(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
markPresent(t, st, ctx, now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
tl.tick(ctx, now)
|
||||
|
||||
if n := countSends(sink, proposalRule); n != 0 {
|
||||
t.Fatalf("announced %d proposals with no config, want 0", n)
|
||||
}
|
||||
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("proposed routines = %d, want 1 (silent, but recorded)", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTickAnnouncesProposalWhenConfigured — with notify on, the proposal goes
|
||||
// out once through the ordinary delivery path, worded by the detector itself.
|
||||
// Later ticks stay quiet because the pair is already proposed: one pattern is
|
||||
// one announcement, ever.
|
||||
func TestTickAnnouncesProposalWhenConfigured(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
markPresent(t, st, ctx, now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
|
||||
tl.tick(ctx, now)
|
||||
|
||||
var got *delivery.Sendable
|
||||
for i := range sink.sends {
|
||||
if sink.sends[i].RuleName == proposalRule {
|
||||
got = &sink.sends[i]
|
||||
}
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("proposal was not announced; sends=%+v", sink.sends)
|
||||
}
|
||||
if !strings.Contains(got.Body, "напоминать?") {
|
||||
t.Errorf("body = %q, want the detector's own question", got.Body)
|
||||
}
|
||||
if got.Channel != delivery.ChannelVoice {
|
||||
t.Errorf("channel = %v, want voice (sev1, present)", got.Channel)
|
||||
}
|
||||
|
||||
// A month of further ticks: the pair already has a row, so there is
|
||||
// nothing new to detect and nothing more to say.
|
||||
sink.sends = nil
|
||||
later := now.Add(40 * 24 * time.Hour)
|
||||
markPresent(t, st, ctx, later)
|
||||
tl.tick(ctx, later)
|
||||
if n := countSends(sink, proposalRule); n != 0 {
|
||||
t.Fatalf("re-announced an existing proposal %d times, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTickProposalRespectsGate — a proposal is the least urgent thing Maven can
|
||||
// say, so it is sev1 and the restraint gate suppresses it. Away presence means
|
||||
// it is not announced at all: it is not held, not retried, it just lives on
|
||||
// /routines. The proposal row is still written — noticing is never gated.
|
||||
func TestTickProposalRespectsGate(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
// no presence probes ⇒ away ⇒ care-class gate blocks.
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
tl.proposalCfg = &config.PatternProposalConfig{Notify: true}
|
||||
tl.tick(ctx, now)
|
||||
|
||||
if n := countSends(sink, proposalRule); n != 0 {
|
||||
t.Fatalf("away: announced %d proposals, want 0", n)
|
||||
}
|
||||
if !tl.lastProposalAt.IsZero() {
|
||||
t.Error("cooldown clock advanced on a suppressed announcement")
|
||||
}
|
||||
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("proposed routines = %d, want 1 (detection is never gated)", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTickProposalCooldownSpacesAnnouncements — two patterns detected on the
|
||||
// same tick must not become two interruptions. The second one waits for the
|
||||
// cooldown, and is on /routines meanwhile.
|
||||
func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedRefillEvents(t, st, ctx, now, pattern.MinEvents)
|
||||
for i := 0; i < pattern.MinEvents; i++ {
|
||||
ts := now.Add(time.Duration(i) * 3 * 24 * time.Hour)
|
||||
factID, err := st.WriteFact(ctx, ts, store.KindSelf, "litter_box", "clean", "test", 1.0, sql.NullInt64{})
|
||||
if err != nil {
|
||||
t.Fatalf("write fact: %v", err)
|
||||
}
|
||||
if _, err := st.CreateEvent(ctx, factID, "clean", "litter_box", ts); err != nil {
|
||||
t.Fatalf("create event: %v", err)
|
||||
}
|
||||
}
|
||||
markPresent(t, st, ctx, now)
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
tl.proposalCfg = &config.PatternProposalConfig{Notify: true, Cooldown: config.Duration(24 * time.Hour)}
|
||||
tl.tick(ctx, now)
|
||||
|
||||
announced := 0
|
||||
for _, s := range sink.sends {
|
||||
if strings.HasPrefix(s.RuleName, "proposal:") {
|
||||
announced++
|
||||
}
|
||||
}
|
||||
if announced != 1 {
|
||||
t.Fatalf("announced %d proposals on one tick, want exactly 1", announced)
|
||||
}
|
||||
rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed)
|
||||
if err != nil {
|
||||
t.Fatalf("list proposed: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("proposed routines = %d, want 2 (both recorded, one announced)", len(rows))
|
||||
}
|
||||
|
||||
// Still inside the cooldown: silence, even though a proposal is pending.
|
||||
sink.sends = nil
|
||||
soon := now.Add(time.Hour)
|
||||
markPresent(t, st, ctx, soon)
|
||||
tl.tick(ctx, soon)
|
||||
for _, s := range sink.sends {
|
||||
if strings.HasPrefix(s.RuleName, "proposal:") {
|
||||
t.Fatalf("announced %q inside the cooldown", s.RuleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Quiet-mode toggle recognition — the pre-route keyword check that lets
|
||||
// "тихий режим" flip the daemon-wide quiet_hours config without going through
|
||||
// the router. Moved out of voice.go unchanged (Vikunja #321); the tests live in
|
||||
// quiet_toggle_test.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||
// the utterance is a quiet-on/off command; ("", false) otherwise. Called from
|
||||
// runTurn BEFORE the router so a classifier miscue can't drop it — which means
|
||||
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
|
||||
// so a false positive here is a network-reachable way to flip a daemon-wide
|
||||
// setting. See classifyQuietToggle for the matching rule.
|
||||
//
|
||||
// src is the channel the utterance arrived on, and it is written straight into
|
||||
// the fact. Every toggle used to be stored as "tap:voice", including the ones
|
||||
// typed into the web UI, which left the facts table claiming a microphone flipped
|
||||
// a setting nobody spoke to. This is the one function where that matters most:
|
||||
// when he goes looking at why quiet mode is on, provenance is the first column
|
||||
// he reads.
|
||||
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||
on, off := classifyQuietToggle(text)
|
||||
if !on && !off {
|
||||
return "", false
|
||||
}
|
||||
val := "false"
|
||||
reply := "тихий режим выключен."
|
||||
if on {
|
||||
val = "true"
|
||||
reply = "тихий режим включён. буду реже напоминать."
|
||||
}
|
||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: h.now(),
|
||||
Kind: "config",
|
||||
Key: "quiet_hours",
|
||||
Value: val,
|
||||
Source: string(src),
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("voice: write quiet_hours: %v", err)
|
||||
return "не получилось переключить тихий режим.", true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// quietInflections — the inflectional endings a stem may carry and still be
|
||||
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
|
||||
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
|
||||
// "тихонько"/"потихоньку", which are different words: "онько" is not an
|
||||
// ending, and "потихоньку" doesn't start with the stem at all.
|
||||
var quietInflections = []string{
|
||||
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
|
||||
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
|
||||
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
|
||||
}
|
||||
|
||||
// quietStem reports whether tok is the given stem carrying at most one
|
||||
// inflectional ending. Word boundaries come from tokenisation (see
|
||||
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
|
||||
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
|
||||
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
|
||||
func quietStem(tok, stem string) bool {
|
||||
if !strings.HasPrefix(tok, stem) {
|
||||
return false
|
||||
}
|
||||
suffix := tok[len(stem):]
|
||||
for _, e := range quietInflections {
|
||||
if suffix == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// quietTokens splits an utterance into lowercase word tokens, dropping
|
||||
// punctuation and spacing. Unicode-aware, so Cyrillic words tokenise the same
|
||||
// way ASCII ones do.
|
||||
func quietTokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(strings.TrimSpace(text)), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// quietPhrase matches a pattern (a sequence of stems) against the token list.
|
||||
// Multi-word patterns match any contiguous run of tokens — "включи тихий
|
||||
// режим" carries "тихий режим". Single-word patterns match ONLY when they are
|
||||
// the whole utterance: bare "тихо" is a command, but "в комнате тихо" is a
|
||||
// remark about the room and must not flip a daemon-wide setting.
|
||||
func quietPhrase(tokens, pattern []string) bool {
|
||||
if len(pattern) == 0 || len(tokens) < len(pattern) {
|
||||
return false
|
||||
}
|
||||
if len(pattern) == 1 {
|
||||
return len(tokens) == 1 && quietStem(tokens[0], pattern[0])
|
||||
}
|
||||
for i := 0; i+len(pattern) <= len(tokens); i++ {
|
||||
hit := true
|
||||
for j, stem := range pattern {
|
||||
if !quietStem(tokens[i+j], stem) {
|
||||
hit = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if hit {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
||||
//
|
||||
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
||||
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
||||
// and negation is not an adjacency phenomenon. "не надо тихий режим" put two
|
||||
// tokens between "не" and "тих", so the OFF pattern missed, the ON pattern
|
||||
// {"тих","режим"} matched, and asking for quiet mode to stop turned it on.
|
||||
// Negation is handled by quietNegators below, over the whole utterance.
|
||||
var (
|
||||
quietOffPhrases = [][]string{
|
||||
{"quiet", "off"}, {"quiet", "end"},
|
||||
{"громк", "режим"}, {"шумн", "режим"},
|
||||
{"отмен", "тих"}, {"выключ", "тих"},
|
||||
}
|
||||
quietOnPhrases = [][]string{
|
||||
{"quiet", "on"}, {"quiet", "mode"},
|
||||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
||||
{"тих"},
|
||||
}
|
||||
)
|
||||
|
||||
// quietNegatorWords — negators that are whole words with no useful stem.
|
||||
var quietNegatorWords = map[string]bool{
|
||||
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
||||
}
|
||||
|
||||
// quietNegatorStems — negators that inflect. Matched through quietStem, the
|
||||
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
|
||||
// and "выключай" all count and "выключатель" does not.
|
||||
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
|
||||
|
||||
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
||||
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
||||
// requests FOR quiet, so they are excluded before the scan: a negator only
|
||||
// counts when it is not part of the phrase that matched.
|
||||
func quietNegated(tokens []string, matched []string) bool {
|
||||
if len(matched) > 0 && matched[0] == "не" {
|
||||
return false
|
||||
}
|
||||
for _, t := range tokens {
|
||||
if quietNegatorWords[t] {
|
||||
return true
|
||||
}
|
||||
for _, stem := range quietNegatorStems {
|
||||
if quietStem(t, stem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// classifyQuietToggle reads an utterance as a quiet-mode command.
|
||||
//
|
||||
// Explicit OFF phrases resolve first, for the same reason classifyConfirm
|
||||
// checks negatives first: they are built out of the ON words ("выключи тихий"
|
||||
// contains "тихий"), so scanning ON first would shadow them. An ON phrase that
|
||||
// matches is then checked for negation across the whole utterance, so any way
|
||||
// of saying "not quiet mode" turns it off rather than on.
|
||||
func classifyQuietToggle(text string) (on, off bool) {
|
||||
tokens := quietTokens(text)
|
||||
for _, p := range quietOffPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
for _, p := range quietOnPhrases {
|
||||
if quietPhrase(tokens, p) {
|
||||
if quietNegated(tokens, p) {
|
||||
return false, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
// No ON phrase matched, but he negated a quiet word: "не тихо", "хватит
|
||||
// тихого режима". The ON vocabulary cannot see these — bare "тих" only
|
||||
// matches a one-token utterance, by design, so the negator pushes the token
|
||||
// count past it — and reading them as "no command" would leave quiet mode
|
||||
// on after he asked for it to stop.
|
||||
if quietNegated(tokens, nil) {
|
||||
for _, t := range tokens {
|
||||
if quietStem(t, "тих") {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// quietFakeAPI records the WriteFact the toggle performs.
|
||||
type quietFakeAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
got ipc.WriteFactReq
|
||||
call int
|
||||
}
|
||||
|
||||
func (a *quietFakeAPI) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
a.got, a.call = req, a.call+1
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// quietVerdict — what a phrase should do to the setting.
|
||||
type quietVerdict int
|
||||
|
||||
const (
|
||||
quietNone quietVerdict = iota
|
||||
quietOn
|
||||
quietOff
|
||||
)
|
||||
|
||||
func TestResolveQuietToggle(t *testing.T) {
|
||||
cases := []struct {
|
||||
text string
|
||||
want quietVerdict
|
||||
}{
|
||||
// ON vocabulary.
|
||||
{"quiet on", quietOn},
|
||||
{"quiet mode", quietOn},
|
||||
{"тихий режим", quietOn},
|
||||
{"тихий", quietOn},
|
||||
{"не шуми", quietOn},
|
||||
{"не беспокоить", quietOn},
|
||||
{"тихо", quietOn},
|
||||
// ON, inflected / embedded in a sentence.
|
||||
{"включи тихий режим", quietOn},
|
||||
{"побудь в тихом режиме", quietOn},
|
||||
{"Тихий Режим!", quietOn},
|
||||
{"тихая", quietOn},
|
||||
|
||||
// OFF vocabulary — all seven, incl. the three that used to say ON.
|
||||
{"quiet off", quietOff},
|
||||
{"quiet end", quietOff},
|
||||
{"громкий режим", quietOff},
|
||||
{"шумный режим", quietOff},
|
||||
{"отмени тихий", quietOff},
|
||||
{"выключи тихий", quietOff},
|
||||
{"не тихо", quietOff},
|
||||
// OFF wins over the ON words it contains.
|
||||
{"выключи тихий режим", quietOff},
|
||||
{"отмени тихий режим пожалуйста", quietOff},
|
||||
{"верни громкий режим", quietOff},
|
||||
|
||||
// False positives: "тихо"/"тихий" as ordinary Russian.
|
||||
{"очень тихий сегодня день", quietNone},
|
||||
{"в комнате тихо", quietNone},
|
||||
{"тихонько напомни", quietNone},
|
||||
{"потихоньку", quietNone},
|
||||
{"тихонько", quietNone},
|
||||
{"он говорил тихим голосом весь вечер", quietNone},
|
||||
|
||||
// Unrelated.
|
||||
{"напомни завтра позвонить маме", quietNone},
|
||||
{"какая погода", quietNone},
|
||||
{"", quietNone},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.text, func(t *testing.T) {
|
||||
api := &quietFakeAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||
reply, handled := h.resolveQuietToggle(context.Background(), tc.text, sourceVoice)
|
||||
|
||||
if tc.want == quietNone {
|
||||
if handled || reply != "" {
|
||||
t.Fatalf("%q: got (%q, %v), want no match", tc.text, reply, handled)
|
||||
}
|
||||
if api.call != 0 {
|
||||
t.Fatalf("%q: wrote a fact on a non-match", tc.text)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !handled {
|
||||
t.Fatalf("%q: not handled, want %v", tc.text, tc.want)
|
||||
}
|
||||
wantReply, wantVal := "тихий режим выключен.", "false"
|
||||
if tc.want == quietOn {
|
||||
wantReply, wantVal = "тихий режим включён. буду реже напоминать.", "true"
|
||||
}
|
||||
if reply != wantReply {
|
||||
t.Errorf("%q: reply = %q, want %q", tc.text, reply, wantReply)
|
||||
}
|
||||
if api.call != 1 {
|
||||
t.Fatalf("%q: WriteFact called %d times, want 1", tc.text, api.call)
|
||||
}
|
||||
if api.got.Kind != "config" || api.got.Key != "quiet_hours" || api.got.Source != "tap:voice" || api.got.Confidence != 1.0 {
|
||||
t.Errorf("%q: request shape = %+v", tc.text, api.got)
|
||||
}
|
||||
if api.got.Value != wantVal {
|
||||
t.Errorf("%q: value = %q, want %q", tc.text, api.got.Value, wantVal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietToggleNegationIsNotAdjacency — negation used to be an adjacency
|
||||
// pattern ({"не","тих"} in the OFF list), so any word between the negator and
|
||||
// the quiet word made the ON pattern win and asking for quiet mode to STOP
|
||||
// turned it on. Negation is scanned over the whole utterance now.
|
||||
func TestQuietToggleNegationIsNotAdjacency(t *testing.T) {
|
||||
off := []string{
|
||||
"не надо тихий режим",
|
||||
"не хочу тихий режим",
|
||||
"тихий режим выключи",
|
||||
"убери тихий режим",
|
||||
"хватит тихого режима",
|
||||
"прекрати тихий режим",
|
||||
"тихий режим отмени пожалуйста",
|
||||
}
|
||||
for _, text := range off {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
on, isOff := classifyQuietToggle(text)
|
||||
if on || !isOff {
|
||||
t.Fatalf("%q: want OFF, got on=%v off=%v", text, on, isOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The two ON phrases that are themselves built on "не" must stay ON: they
|
||||
// are requests FOR quiet, not negations of one.
|
||||
for _, text := range []string{"не шуми", "не беспокоить"} {
|
||||
t.Run(text, func(t *testing.T) {
|
||||
on, isOff := classifyQuietToggle(text)
|
||||
if !on || isOff {
|
||||
t.Fatalf("%q: want ON, got on=%v off=%v", text, on, isOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestQuietToggleRecordsTheChannelItArrivedOn — the toggle is reachable from
|
||||
// mavweb /api/chat and telegram, not only the microphone. Every write used to
|
||||
// be stamped "tap:voice", so a toggle typed into the web UI claimed a mic wrote
|
||||
// it and the provenance column lied about a daemon-wide setting.
|
||||
func TestQuietToggleRecordsTheChannelItArrivedOn(t *testing.T) {
|
||||
for _, src := range []turnSource{sourceVoice, sourceText} {
|
||||
t.Run(string(src), func(t *testing.T) {
|
||||
api := &quietFakeAPI{}
|
||||
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||
if _, handled := h.resolveQuietToggle(context.Background(), "тихий режим", src); !handled {
|
||||
t.Fatal("expected the toggle to match")
|
||||
}
|
||||
if api.got.Source != string(src) {
|
||||
t.Errorf("source = %q, want %q", api.got.Source, src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+18
-18
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,7 @@ type mockCompleter struct {
|
||||
func (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
||||
|
||||
func TestLLMReplierReturnsLLMReply(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`})
|
||||
r := newLLMReplier(mockCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||
@@ -25,7 +25,7 @@ func TestLLMReplierReturnsLLMReply(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"})
|
||||
r := newLLMReplier(mockCompleter{out: "записала, кофе закончился"}, nil)
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||
@@ -33,7 +33,7 @@ func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{err: errTestLLMDown})
|
||||
r := newLLMReplier(mockCompleter{err: errTestLLMDown}, nil)
|
||||
noteDec := router.Decision{Intent: router.IntentNote}
|
||||
got := r.Reply(noteDec)
|
||||
want := voice.NewStubReplier().Reply(noteDec)
|
||||
@@ -43,7 +43,7 @@ func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: ""})
|
||||
r := newLLMReplier(mockCompleter{out: ""}, nil)
|
||||
noteDec := router.Decision{Intent: router.IntentNote}
|
||||
got := r.Reply(noteDec)
|
||||
want := voice.NewStubReplier().Reply(noteDec)
|
||||
@@ -53,7 +53,7 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
r := newLLMReplier(mockCompleter{out: "я всё поняла"})
|
||||
r := newLLMReplier(mockCompleter{out: "я всё поняла"}, nil)
|
||||
clarifyDec := router.Decision{Clarify: true}
|
||||
got := r.Reply(clarifyDec)
|
||||
want := voice.NewStubReplier().Reply(clarifyDec)
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
// Package main — ruwords.go holds Russian language + calendar/time formatting
|
||||
// helpers used by the voice reply paths (replySystem, the reminder/routine
|
||||
// phrasing, etc). Pure functions, no receivers: weekday/month name tables,
|
||||
// plural agreement, clock/date rendering, and the "do I actually know this
|
||||
// place/day" guards that pick an honest reply over a confidently wrong one.
|
||||
// Extend this file rather than voice.go for anything in that shape.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ruWeekdays = []string{
|
||||
"воскресенье", "понедельник", "вторник", "среда",
|
||||
"четверг", "пятница", "суббота",
|
||||
}
|
||||
|
||||
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 {
|
||||
return many
|
||||
}
|
||||
n = n % 10
|
||||
switch n {
|
||||
case 1:
|
||||
return one
|
||||
case 2, 3, 4:
|
||||
return two
|
||||
default:
|
||||
return many
|
||||
}
|
||||
}
|
||||
|
||||
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
||||
// rather than the current clock — guards replySystem from replying "сейчас
|
||||
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
||||
func hasDurationWords(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
// First-word duration markers (same keywords as timeQueryBuild in stage0).
|
||||
first := strings.Fields(s)
|
||||
if len(first) > 0 {
|
||||
switch first[0] {
|
||||
case "прошло", "осталось", "пройдет", "минуло", "проходит":
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Broader duration keywords appearing anywhere in the utterance.
|
||||
if strings.Contains(s, "прошло") || strings.Contains(s, "осталось") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(s, " до ") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// formatTime returns a human-readable Russian time string for a fact timestamp.
|
||||
// Used by the query handler when answering "когда я это сделал?"-style questions.
|
||||
func formatTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
|
||||
return "только что"
|
||||
}
|
||||
diff := now.Sub(t)
|
||||
switch {
|
||||
case diff < 10*time.Minute:
|
||||
return "несколько минут назад"
|
||||
case diff < 60*time.Minute:
|
||||
return fmt.Sprintf("%d минут назад", int(diff.Minutes()))
|
||||
case diff < 2*time.Hour:
|
||||
return "час назад"
|
||||
case diff < 24*time.Hour:
|
||||
return fmt.Sprintf("%d часа назад", int(diff.Hours()))
|
||||
default:
|
||||
return t.Format("2 января 15:04")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,248 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/smarthome"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// homeWiring — the Home Assistant client, when the `smarthome` block is present
|
||||
// AND enabled. nil ⇒ the house is not wired, nothing was proposed, and an
|
||||
// allowlist row that happens to look like a house row refuses to run.
|
||||
//
|
||||
// It lives on the voice wiring for the same reason MCP does: a house control IS
|
||||
// an act. It goes through tool.Executor, the enabled allowlist and the confirm
|
||||
// turn, all of which only exist on the voice/chat path.
|
||||
type homeWiring struct {
|
||||
client *smarthome.Client
|
||||
st *store.Store
|
||||
refresh time.Duration
|
||||
}
|
||||
|
||||
// wireSmartHome builds the client and proposes what it found. It never fails
|
||||
// the daemon: an instance that is down at boot is logged and retried, because
|
||||
// Maven starting is not contingent on someone else's process.
|
||||
func wireSmartHome(cfg *config.Config, st *store.Store) *homeWiring {
|
||||
hc, ok := cfg.SmartHomeClient()
|
||||
if !ok || st == nil {
|
||||
return nil
|
||||
}
|
||||
if err := smarthome.Validate(hc); err != nil {
|
||||
// config.validate already ran this, so reaching here is a programming
|
||||
// error rather than a config one. Still not fatal: the house off is a
|
||||
// working Maven.
|
||||
log.Printf("smarthome: not wired: %v", err)
|
||||
return nil
|
||||
}
|
||||
w := &homeWiring{
|
||||
client: smarthome.NewClient(hc),
|
||||
st: st,
|
||||
refresh: time.Duration(cfg.SmartHome.Refresh),
|
||||
}
|
||||
// No first propose here. This runs inside wireVoice, inside run, before the
|
||||
// IPC socket is serving, and on the locked path inside the passkey unlock
|
||||
// handler. A Home Assistant box that is powered off but still on a routed
|
||||
// subnet black-holes the connection rather than refusing it, so a
|
||||
// synchronous enumeration held the daemon's start for the per-call timeout.
|
||||
// run does the first propose off the ticker instead.
|
||||
return w
|
||||
}
|
||||
|
||||
// caller is the tool.HomeCaller seam.
|
||||
func (w *homeWiring) caller() *smarthome.Client {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.client
|
||||
}
|
||||
|
||||
// propose writes a 'proposed' allowlist row for every controllable device. It
|
||||
// does NOT enable anything: a reachable house is a place Maven may look, not a
|
||||
// set of switches she may flip. Kami enables what he wants on /tools, behind
|
||||
// step-up, which is the same gate a shell tool goes through.
|
||||
//
|
||||
// Sensors are read but never proposed — there is nothing to call on them.
|
||||
func (w *homeWiring) propose(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
ents, err := w.client.States(ctx)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: read states: %v", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
fresh, devices := 0, 0
|
||||
for _, e := range ents {
|
||||
svcs := smarthome.Services(e.Domain)
|
||||
if len(svcs) == 0 {
|
||||
continue
|
||||
}
|
||||
devices++
|
||||
for _, s := range svcs {
|
||||
name := smarthome.LocalName(e.ID, s.Verb)
|
||||
provenance := "дом: " + s.Name + " → " + e.Name + " (" + e.ID + ")"
|
||||
ok, err := w.st.ProposeSmartHomeTool(ctx, name, smarthome.Scope(e.Domain),
|
||||
smarthome.Cmd(e.ID, s.Name), provenance, now)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: propose %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
fresh++
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Printf("smarthome: %d entities, %d controllable", len(ents), devices)
|
||||
if fresh > 0 {
|
||||
log.Printf("smarthome: %d new device proposal(s) waiting on /tools", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// run re-enumerates the house and picks up devices that appeared, until ctx is
|
||||
// canceled.
|
||||
func (w *homeWiring) run(ctx context.Context) {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
iv := w.refresh
|
||||
if iv <= 0 {
|
||||
iv = config.DefaultSmartHomeRefresh
|
||||
}
|
||||
t := time.NewTicker(iv)
|
||||
defer t.Stop()
|
||||
// The first enumeration, off the daemon's start path. wireSmartHome used to
|
||||
// do it synchronously and a dead house delayed the socket coming up.
|
||||
w.propose(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
w.propose(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// homeSummary answers "что дома?" — a read of the current entity states, one
|
||||
// short line. Read-only: it can never call a service, so it needs no confirm
|
||||
// and no allowlist row.
|
||||
func (w *homeWiring) homeSummary(ctx context.Context) (string, bool) {
|
||||
if w == nil {
|
||||
return "", false
|
||||
}
|
||||
ents, err := w.client.States(ctx)
|
||||
if err != nil {
|
||||
log.Printf("smarthome: summary: %v", err)
|
||||
return "не смогла достучаться до дома.", true
|
||||
}
|
||||
if len(ents) == 0 {
|
||||
return "дом ничего не отдаёт.", true
|
||||
}
|
||||
var on []string
|
||||
var sensors []string
|
||||
dark := 0
|
||||
for _, e := range ents {
|
||||
switch {
|
||||
case e.Domain == "sensor" || e.Domain == "binary_sensor":
|
||||
if e.State == "" || e.State == "unavailable" {
|
||||
dark++
|
||||
continue
|
||||
}
|
||||
if len(sensors) < 3 {
|
||||
sensors = append(sensors, e.Name+" "+e.State+e.Unit)
|
||||
}
|
||||
case e.State == "unavailable" || e.State == "unknown" || e.State == "":
|
||||
// A lamp that is not reachable is not a lamp that is off. Counting
|
||||
// it as neither used to make "всё выключено" and "one device is
|
||||
// unreachable" read identically.
|
||||
dark++
|
||||
case e.State == "on" || e.State == "open" || e.State == "unlocked":
|
||||
on = append(on, e.Name)
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
switch {
|
||||
case len(on) > 0:
|
||||
shown, rest := on, 0
|
||||
if len(shown) > 5 {
|
||||
rest = len(shown) - 5
|
||||
shown = shown[:5]
|
||||
}
|
||||
// Silent truncation on a status read is the same failure as the cap
|
||||
// one layer up: she has to say the list is not the whole list.
|
||||
line := "включено: " + strings.Join(shown, ", ")
|
||||
if rest > 0 {
|
||||
line += fmt.Sprintf(" и ещё %d", rest)
|
||||
}
|
||||
parts = append(parts, line)
|
||||
case dark > 0 && len(sensors) == 0:
|
||||
// Nothing is on and everything she can see is unreachable. "всё
|
||||
// выключено" would be a claim about the house she cannot make.
|
||||
return fmt.Sprintf("дом молчит: %d %s не отвечают.", dark, hostWord(dark)), true
|
||||
default:
|
||||
parts = append(parts, "всё выключено")
|
||||
}
|
||||
if len(sensors) > 0 {
|
||||
parts = append(parts, strings.Join(sensors, ", "))
|
||||
}
|
||||
if dark > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d %s не отвечают", dark, hostWord(dark)))
|
||||
}
|
||||
return strings.Join(parts, "; ") + ".", true
|
||||
}
|
||||
|
||||
// isHomeQuery recognises a question about the house, narrowly. "дома" on its
|
||||
// own is not enough — "я дома" is a fact, not a question — so it takes a house
|
||||
// marker AND an ask AND either a device word or the word "включ…". Weather
|
||||
// wording bails out first: "какая температура на улице?" belongs to the weather
|
||||
// source, and both questions contain "температура".
|
||||
func isHomeQuery(u string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(u))
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"погод", "на улице", "прогноз"} {
|
||||
if strings.Contains(s, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, phrase := range []string{"что включено", "что выключено", "умный дом", "что в доме включено"} {
|
||||
if strings.Contains(s, phrase) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
house := homeWord(s, "дома") || strings.Contains(s, "в доме") || strings.Contains(s, "в квартире")
|
||||
if !house {
|
||||
return false
|
||||
}
|
||||
ask := strings.Contains(s, "?") || homeWord(s, "что") || homeWord(s, "какая") ||
|
||||
homeWord(s, "какой") || homeWord(s, "сколько")
|
||||
if !ask {
|
||||
return false
|
||||
}
|
||||
for _, w := range []string{"свет", "лампа", "лампы", "розетк", "датчик", "температур", "включ", "выключ"} {
|
||||
if strings.Contains(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// homeWord — whole-token membership, so "дома" does not fire on "домашний".
|
||||
// Punctuation is trimmed off each token because a spoken question arrives with
|
||||
// a question mark glued to the last word.
|
||||
func homeWord(s, w string) bool {
|
||||
for _, tok := range strings.Fields(s) {
|
||||
if strings.Trim(tok, ".,!?;:") == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
)
|
||||
|
||||
const haStatesFixture = `[
|
||||
{"entity_id":"light.living_room","state":"on","attributes":{"friendly_name":"Гостиная"}},
|
||||
{"entity_id":"switch.kettle","state":"off","attributes":{"friendly_name":"Чайник"}},
|
||||
{"entity_id":"sensor.bedroom_temp","state":"22.5","attributes":{"friendly_name":"Спальня","unit_of_measurement":"°C"}}
|
||||
]`
|
||||
|
||||
func TestWireSmartHomeOffUnlessEnabled(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
for name, cfg := range map[string]*config.Config{
|
||||
"no block": {},
|
||||
"written but dark": {SmartHome: &config.SmartHomeConfig{
|
||||
URL: "http://ha.lan:8123", Token: "t",
|
||||
}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if w := wireSmartHome(cfg, st); w != nil {
|
||||
t.Fatal("the house must be off unless the block is enabled")
|
||||
}
|
||||
})
|
||||
}
|
||||
// nil wiring must be safe everywhere it is reachable.
|
||||
var w *homeWiring
|
||||
w.propose(context.Background())
|
||||
w.run(context.Background())
|
||||
if w.caller() != nil {
|
||||
t.Fatal("a nil wiring must have no caller")
|
||||
}
|
||||
if _, ok := w.homeSummary(context.Background()); ok {
|
||||
t.Fatal("a nil wiring must not claim a query")
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable instance must not stop the daemon and must propose nothing.
|
||||
func TestWireSmartHomeUnreachableIsNotFatal(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
// Port 1 on loopback: nothing listens, and it fails fast.
|
||||
URL: "http://127.0.0.1:1", Token: "t", Enabled: true,
|
||||
}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("a configured house should still wire")
|
||||
}
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tools) != 0 {
|
||||
t.Fatalf("an instance that never answered must propose nothing, got %+v", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// Discovery proposes one row per controllable service, always destructive,
|
||||
// always 'proposed'. A sensor gets no row: there is nothing to call on it.
|
||||
func TestProposeOnlyProposesControllableDevices(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(haStatesFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
st := newTestStore(t)
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, st)
|
||||
if w == nil {
|
||||
t.Fatal("wireSmartHome returned nil for an enabled, reachable house")
|
||||
}
|
||||
// Wiring alone must not have touched the house: enumeration happens off
|
||||
// the ticker, not on the daemon's start path.
|
||||
if pre, err := st.ListTools(context.Background(), ""); err != nil || len(pre) != 0 {
|
||||
t.Fatalf("wireSmartHome enumerated the house synchronously: %+v (%v)", pre, err)
|
||||
}
|
||||
w.propose(context.Background())
|
||||
|
||||
tools, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, tl := range tools {
|
||||
got[tl.Name] = true
|
||||
if tl.Status != "proposed" {
|
||||
t.Errorf("%s status = %q: discovery must never enable", tl.Name, tl.Status)
|
||||
}
|
||||
if !tl.Destructive {
|
||||
t.Errorf("%s is not destructive: every house control needs the confirm turn", tl.Name)
|
||||
}
|
||||
if len(tl.Cmd) == 0 || tl.Cmd[0] != "smarthome" {
|
||||
t.Errorf("%s cmd = %v", tl.Name, tl.Cmd)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"home_light_living_room_on", "home_light_living_room_off",
|
||||
"home_switch_kettle_on", "home_switch_kettle_off",
|
||||
} {
|
||||
if !got[want] {
|
||||
t.Errorf("missing proposal %q (have %v)", want, got)
|
||||
}
|
||||
}
|
||||
if len(tools) != 4 {
|
||||
t.Fatalf("got %d rows, want 4 — the sensor must not be proposed: %+v", len(tools), tools)
|
||||
}
|
||||
|
||||
// A second pass must be idempotent: re-discovery duplicates nothing and
|
||||
// never rewrites a row Kami already enabled.
|
||||
if err := st.EnableTool(context.Background(), "home_switch_kettle_on",
|
||||
[]string{"smarthome", "switch.kettle", "turn_on"}, true, "smarthome:switch", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.propose(context.Background())
|
||||
again, err := st.ListTools(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(again) != 4 {
|
||||
t.Fatalf("re-discovery duplicated rows: %d", len(again))
|
||||
}
|
||||
for _, tl := range again {
|
||||
if tl.Name == "home_switch_kettle_on" && tl.Status != "enabled" {
|
||||
t.Errorf("re-discovery un-enabled a device he had enabled: %q", tl.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeSummaryReadsState(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(haStatesFixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, ok := w.homeSummary(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("summary did not claim the turn")
|
||||
}
|
||||
if !strings.Contains(out, "Гостиная") {
|
||||
t.Errorf("the lamp that is on should be named: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "Чайник") {
|
||||
t.Errorf("a device that is off should not be listed as on: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "22.5") {
|
||||
t.Errorf("the sensor reading should be there: %q", out)
|
||||
}
|
||||
// Persona: no masculine self-reference, no "вы", no pet names.
|
||||
for _, bad := range []string{"рад ", "готов ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(strings.ToLower(out), bad) {
|
||||
t.Errorf("persona violation %q in %q", bad, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHomeQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"что включено дома?",
|
||||
"что выключено",
|
||||
"какой свет горит дома",
|
||||
"свет в доме включен?",
|
||||
"какая температура в квартире?",
|
||||
"покажи умный дом",
|
||||
}
|
||||
no := []string{
|
||||
"",
|
||||
"я дома",
|
||||
"буду дома в семь",
|
||||
"какая погода дома", // weather wording wins
|
||||
"какая температура на улице?",
|
||||
"домашние дела", // "дома" must not fire on "домашние"
|
||||
"что мне нужно сделать?",
|
||||
"напомни выключить чайник в семь", // a reminder, not a house read
|
||||
}
|
||||
for _, u := range yes {
|
||||
if !isHomeQuery(u) {
|
||||
t.Errorf("isHomeQuery(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range no {
|
||||
if isHomeQuery(u) {
|
||||
t.Errorf("isHomeQuery(%q) = true, want false", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A house that black-holes the connection must not hold the daemon's start.
|
||||
// wireSmartHome used to enumerate synchronously with a 30s context, inside
|
||||
// wireVoice, inside run, before the IPC socket was serving — and on the locked
|
||||
// path, inside the passkey unlock handler.
|
||||
func TestWireSmartHomeDoesNotBlockOnTheHouse(t *testing.T) {
|
||||
// A handler that never answers: the client's own timeout is the only way
|
||||
// out, and it is ten seconds.
|
||||
block := make(chan struct{})
|
||||
defer close(block)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-block
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
done := make(chan *homeWiring, 1)
|
||||
go func() {
|
||||
done <- wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
}()
|
||||
select {
|
||||
case w := <-done:
|
||||
if w == nil {
|
||||
t.Fatal("a configured house should still wire")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("wireSmartHome waited on the house")
|
||||
}
|
||||
}
|
||||
|
||||
// A lamp that is unreachable is not a lamp that is off, and a list she cut
|
||||
// short has to say so. Both used to read as plain statements about the house.
|
||||
func TestHomeSummaryDoesNotCallUnreachableDevicesOff(t *testing.T) {
|
||||
const fixture = `[
|
||||
{"entity_id":"light.a","state":"unavailable","attributes":{"friendly_name":"Прихожая"}},
|
||||
{"entity_id":"light.b","state":"unavailable","attributes":{"friendly_name":"Кухня"}}
|
||||
]`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(fixture))
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, ok := w.homeSummary(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("summary did not claim the turn")
|
||||
}
|
||||
if strings.Contains(out, "всё выключено") {
|
||||
t.Errorf("two unreachable lamps were reported as off: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "не отвечают") {
|
||||
t.Errorf("the unreachable devices are not mentioned: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeSummarySaysWhenTheListIsCutShort(t *testing.T) {
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i := 0; i < 8; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"entity_id":"light.l%d","state":"on","attributes":{"friendly_name":"лампа%d"}}`, i, i)
|
||||
}
|
||||
b.WriteString("]")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}))
|
||||
defer srv.Close()
|
||||
w := wireSmartHome(&config.Config{SmartHome: &config.SmartHomeConfig{
|
||||
URL: srv.URL, Token: "t", Enabled: true,
|
||||
}}, newTestStore(t))
|
||||
out, _ := w.homeSummary(context.Background())
|
||||
if !strings.Contains(out, "и ещё 3") {
|
||||
t.Errorf("eight lamps on, five named, and nothing said about the rest: %q", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// mavend/speaker.go — core's half of voice identification (Vikunja #255,
|
||||
// docs/plans/10-speaker-recognition.md).
|
||||
//
|
||||
// # What is actually wired here, and what is not
|
||||
//
|
||||
// Nothing is, on this box. There is no speaker-embedding model on disk — no
|
||||
// ECAPA, no x-vector, no titanet, no wespeaker, nothing in /mnt/hdd1/llms but
|
||||
// text ggufs. Until one is downloaded, newSpeakerEmbedder returns nil.
|
||||
//
|
||||
// Without an embedder the capability has no runnable half. This comment used to
|
||||
// say enrolment was real and only recognition was blocked, and the startup log
|
||||
// said the same. Both were wrong: Recognizer.Enroll embeds every sample before
|
||||
// it stores anything, so with no model it fails on the first sample and nothing
|
||||
// is ever stored, which leaves List empty forever and Forget with nothing to
|
||||
// delete. So the gate is cfg.Speaker.Recognizes() — enabled AND a model path —
|
||||
// and a box without one gets no speaker methods, not three no-ops.
|
||||
//
|
||||
// This is deliberately not papered over with a hand-rolled MFCC floor. A
|
||||
// biometric that is confidently wrong writes false claims about named people
|
||||
// into his memory, and that is worse than a capability that is honestly absent.
|
||||
//
|
||||
// # Off unless configured
|
||||
//
|
||||
// No speaker block, or one without enabled, ⇒ the three methods do not exist and
|
||||
// answer ErrUnknownMethod. On an unconfigured box there is no wire path that
|
||||
// takes a voiceprint at all.
|
||||
//
|
||||
// # The refused design step
|
||||
//
|
||||
// The plan asks for unknown speakers to be enrolled on first interaction. That
|
||||
// is refused in internal/speaker/enroll.go and there is no handler for it here:
|
||||
// no request shape in the protocol enrols whoever just spoke. Taking a biometric
|
||||
// of a guest who walked past the microphone is not something this daemon does.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/speaker"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// speakerWiring holds the recognizer behind the three IPC handlers.
|
||||
type speakerWiring struct {
|
||||
rec *speaker.Recognizer
|
||||
}
|
||||
|
||||
// newSpeakerEmbedder loads the speaker-embedding model named by the config.
|
||||
//
|
||||
// It always returns nil today. The seam exists so that wiring a real model is a
|
||||
// change to this one function and nothing else: give it a loader, and Identify
|
||||
// starts working with no change to the store, the protocol, the auth table or
|
||||
// the handlers. See the plan document for what to download.
|
||||
func newSpeakerEmbedder(cfg *config.SpeakerConfig) speaker.Embedder {
|
||||
_ = cfg
|
||||
return nil
|
||||
}
|
||||
|
||||
// newSpeakerWiring builds the recognizer, or nil when the capability is off.
|
||||
func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
|
||||
if cfg == nil || cfg.Speaker == nil {
|
||||
return nil
|
||||
}
|
||||
if !cfg.Speaker.Recognizes() {
|
||||
// Recognizes() was written as the gate and documented as one, and then
|
||||
// never called. "enabled": true with no model_path used to wire all
|
||||
// three methods and log "enrolment on", which is the one config shape
|
||||
// where the operator most needs to be told otherwise.
|
||||
if cfg.Speaker.Enabled {
|
||||
log.Print("speaker: enabled but no model_path, so there is nothing to embed with; " +
|
||||
"enrol, list and forget would all be no-ops, staying off " +
|
||||
"(see docs/plans/10-speaker-recognition.md)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if st == nil {
|
||||
log.Print("speaker: enabled but there is no store to keep profiles in; staying off")
|
||||
return nil
|
||||
}
|
||||
rec, err := speaker.New(newSpeakerEmbedder(cfg.Speaker), st.VectorMemory(), speaker.Config{
|
||||
Threshold: cfg.Speaker.Threshold,
|
||||
MinSeconds: cfg.Speaker.MinSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("speaker: %v; staying off", err)
|
||||
return nil
|
||||
}
|
||||
if rec.Enabled() {
|
||||
log.Printf("speaker: recognition on, threshold %.2f", rec.Threshold())
|
||||
} else {
|
||||
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet, "+
|
||||
"so enrol, list and forget are all no-ops (Vikunja #255)", cfg.Speaker.ModelPath)
|
||||
}
|
||||
return &speakerWiring{rec: rec}
|
||||
}
|
||||
|
||||
func (w *speakerWiring) enroll(ctx context.Context, req ipc.EnrollSpeakerReq) (ipc.EnrollSpeakerResp, error) {
|
||||
p, err := w.rec.Enroll(ctx, req.ID, req.Name, req.Samples)
|
||||
if err != nil {
|
||||
return ipc.EnrollSpeakerResp{}, speakerErr(err)
|
||||
}
|
||||
return ipc.EnrollSpeakerResp{Speaker: toWireSpeaker(p)}, nil
|
||||
}
|
||||
|
||||
func (w *speakerWiring) list(ctx context.Context) (ipc.ListSpeakersResp, error) {
|
||||
ps, err := w.rec.List(ctx)
|
||||
if err != nil {
|
||||
return ipc.ListSpeakersResp{}, speakerErr(err)
|
||||
}
|
||||
out := make([]ipc.Speaker, 0, len(ps))
|
||||
for _, p := range ps {
|
||||
out = append(out, toWireSpeaker(p))
|
||||
}
|
||||
return ipc.ListSpeakersResp{Speakers: out, Enabled: w.rec.Enabled()}, nil
|
||||
}
|
||||
|
||||
func (w *speakerWiring) forget(ctx context.Context, req ipc.ForgetSpeakerReq) error {
|
||||
return speakerErr(w.rec.Forget(ctx, req.ID))
|
||||
}
|
||||
|
||||
// toWireSpeaker drops the voiceprint. A listing says who is enrolled; it does
|
||||
// not hand the biometric back out over the socket.
|
||||
func toWireSpeaker(p speaker.Profile) ipc.Speaker {
|
||||
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples, Damaged: p.Damaged}
|
||||
}
|
||||
|
||||
// speakerErr maps the package sentinels onto the wire vocabulary so a surface
|
||||
// can tell "you asked wrong" from "core broke".
|
||||
func speakerErr(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, speaker.ErrDisabled):
|
||||
// Not a core failure. The capability is present on the wire but has no
|
||||
// embedding model behind it, which is the same thing an unconfigured
|
||||
// method says, so say it the same way.
|
||||
return ipc.ErrUnknownMethod
|
||||
case errors.Is(err, speaker.ErrNotFound):
|
||||
return ipc.ErrNoFact
|
||||
case errors.Is(err, speaker.ErrBadID),
|
||||
errors.Is(err, speaker.ErrBadFormat),
|
||||
errors.Is(err, speaker.ErrTooShort):
|
||||
return errors.Join(ipc.ErrBadParams, err)
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// wireSpeaker attaches the three handlers when the capability is configured.
|
||||
func wireSpeaker(srv *ipc.Server, st *store.Store, cfg *config.Config) {
|
||||
w := newSpeakerWiring(st, cfg)
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
srv.EnrollSpeakerFn = w.enroll
|
||||
srv.ListSpeakersFn = w.list
|
||||
srv.ForgetSpeakerFn = w.forget
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/speaker"
|
||||
)
|
||||
|
||||
// "enabled": true with no model_path used to wire all three methods and log
|
||||
// "enrolment on". Nothing behind them works without an embedder, so the
|
||||
// capability stays off and the socket answers "no such method".
|
||||
func TestSpeakerStaysOffWithoutAModelPath(t *testing.T) {
|
||||
srv := &ipc.Server{}
|
||||
cfg := &config.Config{Speaker: &config.SpeakerConfig{Enabled: true}}
|
||||
|
||||
wireSpeaker(srv, nil, cfg)
|
||||
|
||||
if srv.EnrollSpeakerFn != nil || srv.ListSpeakersFn != nil || srv.ForgetSpeakerFn != nil {
|
||||
t.Error("speaker methods were wired with nothing to embed with")
|
||||
}
|
||||
}
|
||||
|
||||
// The gate is Recognizes(), so a disabled block with a model path is off too.
|
||||
func TestSpeakerStaysOffWhenDisabled(t *testing.T) {
|
||||
srv := &ipc.Server{}
|
||||
cfg := &config.Config{Speaker: &config.SpeakerConfig{ModelPath: "/nope/ecapa.onnx"}}
|
||||
|
||||
wireSpeaker(srv, nil, cfg)
|
||||
|
||||
if srv.EnrollSpeakerFn != nil {
|
||||
t.Error("speaker methods were wired for a disabled block")
|
||||
}
|
||||
}
|
||||
|
||||
// ErrDisabled is "this capability is off", not "core broke". It used to fall
|
||||
// through speakerErr's default and reach the surface as an opaque failure.
|
||||
func TestSpeakerErrMapsDisabledToUnknownMethod(t *testing.T) {
|
||||
if got := speakerErr(speaker.ErrDisabled); !errors.Is(got, ipc.ErrUnknownMethod) {
|
||||
t.Errorf("speakerErr(ErrDisabled) = %v, want ErrUnknownMethod", got)
|
||||
}
|
||||
if got := speakerErr(speaker.ErrNotFound); !errors.Is(got, ipc.ErrNoFact) {
|
||||
t.Errorf("speakerErr(ErrNotFound) = %v, want ErrNoFact", got)
|
||||
}
|
||||
if got := speakerErr(nil); got != nil {
|
||||
t.Errorf("speakerErr(nil) = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Package main — strutil.go holds small, receiver-free string utilities used
|
||||
// across the voice reply paths: trimming a wake token, pulling out the first
|
||||
// word or first line, and a minimal JSON string encoder for the one payload
|
||||
// shape that needs it. Extend this file rather than voice.go for anything in
|
||||
// that shape.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// stripWake removes a leading wake token (any script the STT phonetically
|
||||
// transcribes "Maven" as) so the verb is the first word.
|
||||
func stripWake(u string) string {
|
||||
stripped, had := router.StripWakeToken(u)
|
||||
if !had {
|
||||
return strings.TrimSpace(u)
|
||||
}
|
||||
return stripped
|
||||
}
|
||||
|
||||
// firstWord returns the first whitespace-delimited token (lowercased) — the
|
||||
// proposed tool's name.
|
||||
func firstWord(s string) string {
|
||||
f := strings.Fields(s)
|
||||
if len(f) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(f[0])
|
||||
}
|
||||
|
||||
// firstLine — the first non-empty line of a tool's output, for a short spoken
|
||||
// reply (the full output goes to the log, not the TTS). Trimmed to keep the
|
||||
// utterance sane if a command dumps a wall of text.
|
||||
func firstLine(s string) string {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
if len(line) > 200 {
|
||||
line = line[:200]
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// jsonString — a one-line JSON string encoder without dragging encoding/json
|
||||
// into the top of this file. Used to wrap a reminder payload's text field;
|
||||
// the router's reminder Slots are already absolute (DateTimeParser resolved
|
||||
// relative→absolute), the payload shape is conventional {"text":...}.
|
||||
func jsonString(s string) string {
|
||||
// minimal JSON string escape — quotes + backslash + control chars.
|
||||
// adequate for the reminder payload's text field; not a general JSON
|
||||
// encoder. The chroma / RAG modules (when they land) use a real json
|
||||
// encoder for richer payloads. Keep it inline here so the import
|
||||
// direction stays narrow.
|
||||
var b []byte
|
||||
b = append(b, '"')
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '"':
|
||||
b = append(b, '\\', '"')
|
||||
case '\\':
|
||||
b = append(b, '\\', '\\')
|
||||
case '\n':
|
||||
b = append(b, '\\', 'n')
|
||||
case '\r':
|
||||
b = append(b, '\\', 'r')
|
||||
case '\t':
|
||||
b = append(b, '\\', 't')
|
||||
default:
|
||||
if r < 0x20 {
|
||||
b = append(b, []byte(fmt.Sprintf("\\u%04x", r))...)
|
||||
} else {
|
||||
b = append(b, []byte(string(r))...)
|
||||
}
|
||||
}
|
||||
}
|
||||
b = append(b, '"')
|
||||
return string(b)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "act_degraded",
|
||||
"description": "The act path against a Praxis that goes down and comes back. This is the case the harness promised and did not have: the other two scenarios never produce an act, so the ecosystem fakes saw zero requests and the fault lever was inert. Here a scripted act reaches an enabled allowlist row, the row is a Praxis verb, and the same utterance runs healthy, then at 503, then healthy again. The degraded turn must say she cannot reach it and must not send anything at him off the back of it.",
|
||||
"start": "2026-08-01T09:00:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
|
||||
"tools": [{ "name": "list_attention" }],
|
||||
"script": [
|
||||
{
|
||||
"match": "требует внимания",
|
||||
"route": "[{\"intent\":\"act\",\"verb\":\"list_attention\"}]"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "09:00",
|
||||
"note": "a healthy act reaches Praxis and speaks what it found",
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["medicine not taken"],
|
||||
"expect_called": ["/api/v1/tools/attention"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "09:05",
|
||||
"note": "the ecosystem goes down",
|
||||
"fault": 503
|
||||
},
|
||||
{
|
||||
"at": "09:10",
|
||||
"note": "the same act against a 503. She says she cannot reach it. She does not invent an answer and she does not push anything at him.",
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["не могу сейчас узнать"],
|
||||
"expect_reply_lacks": ["medicine not taken"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "09:15",
|
||||
"note": "a tick while the ecosystem is down touches nothing out there — the proactive loop has no business calling Praxis",
|
||||
"tick": true,
|
||||
"expect_not_called": ["/api/v1"],
|
||||
"expect_no_send": true,
|
||||
"expect_no_events": true
|
||||
},
|
||||
{
|
||||
"at": "09:20",
|
||||
"note": "recovery: the same act works again, so the degraded turn left no sticky state",
|
||||
"clear_fault": true,
|
||||
"say": "что требует внимания?",
|
||||
"expect_reply_contains": ["medicine not taken"],
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "evening_degraded",
|
||||
"description": "The tier-2 pipeline case #288 deferred here, plus degraded mode. A golden WAV goes in at the microphone end and comes out as a written fact, and then the ecosystem starts answering 503 and the proactive loop has to stay quiet instead of falling over. The audio step asserts the PIPELINE — mic to STT seam to router to store to TTS — not whisper's accuracy; cmd/mavsttd/golden_test.go owns accuracy.",
|
||||
"start": "2026-08-01T21:00:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"evening_medicine\"}]",
|
||||
"script": [
|
||||
{
|
||||
"match": "выпил воды",
|
||||
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||
},
|
||||
{
|
||||
"match": "записала факт: water",
|
||||
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "21:00",
|
||||
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||
"audio": "ru_fact",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
"at": "21:05",
|
||||
"note": "a healthy tick with him just having spoken stays silent",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "21:10",
|
||||
"note": "the ecosystem goes down",
|
||||
"fault": 503
|
||||
},
|
||||
{
|
||||
"at": "21:15",
|
||||
"note": "a tick against a dead ecosystem must degrade, not send half a thought",
|
||||
"tick": true,
|
||||
"expect_no_send": true,
|
||||
"expect_no_events": true
|
||||
},
|
||||
{
|
||||
"at": "21:20",
|
||||
"note": "intake keeps working while the ecosystem is down — a write does not depend on it",
|
||||
"arrive": {
|
||||
"source": "rss:tech",
|
||||
"note": { "text": "Патч 6.19.1 [tech]\nисправления\nhttps://example.org/b" }
|
||||
},
|
||||
"expect_events": ["rss:tech"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "21:25",
|
||||
"note": "recovery",
|
||||
"clear_fault": true,
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "morning_missed",
|
||||
"description": "The scenario from Vikunja #284's description, replayed. He appears at 08:30, things arrive through the morning while he is at the desk, and at 08:50 he asks what he missed. The assertions are as much about what did NOT happen — nothing was sent at him unprompted — as about what she said.",
|
||||
"start": "2026-08-01T08:30:00+03:00",
|
||||
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
|
||||
"script": [
|
||||
{
|
||||
"match": "выпил воды",
|
||||
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
|
||||
},
|
||||
{
|
||||
"match": "записала факт: water",
|
||||
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "что я пропустил",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"что я пропустил\"}]"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
|
||||
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "08:30",
|
||||
"note": "he appears at the desk",
|
||||
"signal": { "key": "desk_active", "value": "true", "source": "infer:hyprland" },
|
||||
"expect_events": ["infer:hyprland"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:32",
|
||||
"note": "a feed item arrives, published half an hour ago",
|
||||
"arrive": {
|
||||
"source": "rss:tech",
|
||||
"as_of": "08:02",
|
||||
"note": { "text": "Вышло ядро 6.19 [tech]\nкраткое содержание\nhttps://example.org/a" }
|
||||
},
|
||||
"expect_events": ["rss:tech"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:35",
|
||||
"note": "the mail reader extracts a candidate — a candidate is never spoken",
|
||||
"arrive": {
|
||||
"source": "email:inbox",
|
||||
"task": { "text": "продлить домен", "evidence": "Домен истекает через 7 дней" }
|
||||
},
|
||||
"expect_events": ["email:inbox", "продлить домен"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:40",
|
||||
"note": "the work calendar signal — a relayed notification, at the ambient path's own 0.6 rather than an observation she made herself. That is the branch factPriority takes, so the journal must file it low.",
|
||||
"arrive": {
|
||||
"source": "ambient:notif",
|
||||
"fact": {
|
||||
"key": "calendar_event_20260801_планёрка",
|
||||
"value": "10:00-11:00 планёрка",
|
||||
"confidence": 0.6
|
||||
}
|
||||
},
|
||||
"expect_events": ["планёрка", "ambient:notif/fact pri=low"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:45",
|
||||
"note": "a tick with him present and nothing wrong must stay silent",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "08:50",
|
||||
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
|
||||
"say": "что я пропустил?",
|
||||
"expect_reply_contains": ["не знаю"],
|
||||
"expect_reply_lacks": ["рад ", "милый", "ваш"]
|
||||
},
|
||||
{
|
||||
"at": "08:55",
|
||||
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
|
||||
"say": "я выпил воды",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
"at": "09:00",
|
||||
"note": "a second tick, still nothing unprompted",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -19,11 +19,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -67,6 +69,14 @@ type tickLoop struct {
|
||||
morningRoutines []morning.Routine
|
||||
morningLast map[string]time.Time
|
||||
|
||||
// proposalCfg — announcement policy for routines the tick inferred itself.
|
||||
// nil ⇒ detect silently, never announce (the default). lastProposalAt is
|
||||
// the cooldown clock, in-memory on purpose: a restart is allowed to permit
|
||||
// one more announcement, and a restart-per-day loop is a bigger problem
|
||||
// than a duplicate proposal notice.
|
||||
proposalCfg *config.PatternProposalConfig
|
||||
lastProposalAt time.Time
|
||||
|
||||
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
||||
// populated when digestCfg != nil && digestCfg.Enabled.
|
||||
digestQ []QueuedNudge
|
||||
@@ -92,6 +102,7 @@ func newTickLoop(
|
||||
digestCfg *config.DigestConfig,
|
||||
routines []routine.Routine,
|
||||
morningRoutines []morning.Routine,
|
||||
proposalCfg *config.PatternProposalConfig,
|
||||
) *tickLoop {
|
||||
return &tickLoop{
|
||||
store: st,
|
||||
@@ -108,6 +119,7 @@ func newTickLoop(
|
||||
routineLast: make(map[string]time.Time),
|
||||
morningRoutines: morningRoutines,
|
||||
morningLast: make(map[string]time.Time),
|
||||
proposalCfg: proposalCfg,
|
||||
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
||||
}
|
||||
}
|
||||
@@ -180,6 +192,17 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// (with dedup) avoids re-queueing the same rule after a flush.
|
||||
t.maybeFlush(ctx, now, state)
|
||||
|
||||
// gate-suppressed digest (Vikunja #281): rules the restraint gate held
|
||||
// back this tick (quiet hours / away / calendar-busy), not because they
|
||||
// weren't due, but because it wasn't the moment. Some of those are worth
|
||||
// resurfacing later instead of just being lost — loop.DigestEligible
|
||||
// draws that line. This is a SEPARATE mechanism from the in-memory
|
||||
// digestQ above: that one batches candidates the gate already ALLOWED to
|
||||
// fire; this one durably holds candidates the gate BLOCKED.
|
||||
t.enqueueSuppressedDigest(ctx, trace, state, now)
|
||||
t.expireStaleDigest(ctx, now)
|
||||
t.maybeDrainDigest(ctx, state, now)
|
||||
|
||||
// routines: operator-declared scheduled behaviors. fire the ones whose cron
|
||||
// crossed since last fire, delivered through the normal routing (voice when
|
||||
// present, away channels otherwise). bodies are literal operator text — not
|
||||
@@ -195,6 +218,14 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// nudge time. See internal/morning for the "why not four timers" rationale.
|
||||
t.fireMorningRoutines(ctx, now, state)
|
||||
|
||||
// pattern detection: scan every action+object pair with recorded events
|
||||
// and propose a routine for any stable one not already decided (Vikunja
|
||||
// #43). This used to only run as a side effect of the voice fact-write
|
||||
// path, so a pattern already sitting in history went unnoticed until he
|
||||
// happened to mention it again by voice. See patterns.go and
|
||||
// detectPatterns below for how idempotence and dismissal are respected.
|
||||
t.detectPatterns(ctx, now, state)
|
||||
|
||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||
// delivery. a failed send leaves the reminder pending — the next tick
|
||||
// re-gathers and re-attempts.
|
||||
@@ -342,6 +373,252 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
|
||||
t.digestQ = nil
|
||||
}
|
||||
|
||||
// detectPatterns runs the pattern detector proactively over every
|
||||
// action+object pair that has ever produced an event, independent of
|
||||
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
||||
// what makes pattern inference actually proactive: it fires on the daemon's
|
||||
// own schedule reading accumulated history, not only as a side effect of a
|
||||
// live voice turn.
|
||||
//
|
||||
// Idempotence and noise are handled by the store, not here — this function
|
||||
// is safe to call every tick:
|
||||
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
||||
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
||||
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
||||
// already has a row — in ANY status — produces no second row and no log
|
||||
// spam beyond the one line at genuine creation.
|
||||
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
||||
// status in place; the row is never deleted. So the same Lookup check
|
||||
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||
// calling the same shared path the voice route already used.
|
||||
//
|
||||
// By default this only creates a row for the /routines page to show: it does
|
||||
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||
// for the restraints that apply even then. A proposal only starts producing
|
||||
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: distinct event pairs: %v", err)
|
||||
return
|
||||
}
|
||||
announced := false
|
||||
for _, p := range pairs {
|
||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
||||
continue
|
||||
}
|
||||
if r == nil {
|
||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||
}
|
||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
// One announcement per tick at most, whatever the scan turned up. The
|
||||
// rest are on /routines; they are not lost, they are just not shouted.
|
||||
// Nor are they queued: the row now exists, so no later tick re-detects
|
||||
// them and they are never announced. See announceProposal.
|
||||
if announced {
|
||||
continue
|
||||
}
|
||||
announced = t.announceProposal(ctx, r, now, state)
|
||||
}
|
||||
}
|
||||
|
||||
// announceProposal offers a freshly inferred routine through the ordinary
|
||||
// care-delivery path, if announcing is switched on at all. Returns true when
|
||||
// something was actually sent.
|
||||
//
|
||||
// Everything here is restraint. The feature is off unless configured; when on
|
||||
// it is sev1 (the lowest severity, so quiet hours, away presence and snooze
|
||||
// all suppress it via loop.Gate exactly like a care nudge); it is spaced by
|
||||
// proposalCfg.Cooldown across every pair, not per pair; and a suppressed or
|
||||
// dropped announcement is NOT retried — the cooldown clock advances only on a
|
||||
// real send, but the proposal row already exists, so the next tick will not
|
||||
// re-detect it and nothing queues up behind it. A missed announcement means
|
||||
// he reads it on /routines instead, which is the whole point of the page.
|
||||
//
|
||||
// What the cooldown is and is not. detectAndPropose returns non-nil only for a
|
||||
// newly created row, so a pair gets exactly one chance to be spoken: the tick
|
||||
// that first proposes it. Combined with one announcement per tick, the first
|
||||
// tick over a populated history announces one pattern and permanently silences
|
||||
// every other pattern found in the same pass. That is the intent, not an
|
||||
// oversight — an inferred routine is not worth a second attempt at his
|
||||
// attention, and /routines lists all of them. So the cooldown does not drain a
|
||||
// backlog. It only spaces announcements of genuinely new pairs discovered on
|
||||
// later ticks. If it should ever become "one per day until each is mentioned",
|
||||
// that needs a queue rather than this counter.
|
||||
//
|
||||
// Cooldown gets its default here as well as in applyDefaults. That is
|
||||
// deliberate: a tickLoop assembled directly in a test never goes through Load,
|
||||
// and an unspaced announcer is not what those tests mean to exercise.
|
||||
//
|
||||
// The body is the detector's own literal Russian phrasing (pattern.PhraseRoutine
|
||||
// — "ты заправляешь поилку раз в 7 дней — напоминать?"), not LLM-generated, so
|
||||
// an inferred routine cannot arrive worded as something Maven never observed.
|
||||
func (t *tickLoop) announceProposal(ctx context.Context, r *pattern.ProposedRoutine, now time.Time, state loop.State) bool {
|
||||
if !t.proposalCfg.AnnounceProposals() {
|
||||
return false
|
||||
}
|
||||
cooldown := time.Duration(t.proposalCfg.Cooldown)
|
||||
if cooldown <= 0 {
|
||||
cooldown = config.DefaultProposalCooldown
|
||||
}
|
||||
if !t.lastProposalAt.IsZero() && now.Sub(t.lastProposalAt) < cooldown {
|
||||
return false
|
||||
}
|
||||
|
||||
rule := loop.Rule{Name: "proposal:" + r.Action + " " + r.Object, Severity: loop.Sev1}
|
||||
if !loop.Gate(state, rule) {
|
||||
return false
|
||||
}
|
||||
body := pattern.PhraseRoutine(r)
|
||||
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: announce proposal %s/%s: %v", r.Action, r.Object, err)
|
||||
return false
|
||||
}
|
||||
if len(sent) == 0 {
|
||||
return false // routing dropped it — /routines still has it.
|
||||
}
|
||||
t.lastProposalAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
// digestExpiry — how long a gate-suppressed care nudge stays worth
|
||||
// resurfacing. 24h: these are daily-cadence rules (water/meal/break run on
|
||||
// hour-scale cooldowns and re-derive from facts that reset every day), so a
|
||||
// digest entry that outlives one full day is describing a day that's already
|
||||
// over — "you skipped a break yesterday" said tomorrow evening is noise, not
|
||||
// news. Bounding at one day also means a digest can never silently span a
|
||||
// weekend of quiet hours into an unbounded backlog.
|
||||
const digestExpiry = 24 * time.Hour
|
||||
|
||||
// maxDigestSpokenItems — the bundle read-out is capped so "batched, not
|
||||
// dropped" cannot regress into "she dumps twelve things on me the moment I
|
||||
// walk in" — a digest that nags in bulk is worse than the drops it replaced.
|
||||
// Anything beyond the cap is still marked drained (it did get its moment;
|
||||
// the cap limits WORDS, not whether it counted) and folded into a trailing
|
||||
// count instead of being spoken in full.
|
||||
const maxDigestSpokenItems = 3
|
||||
|
||||
// enqueueSuppressedDigest scans this tick's trace for care candidates the
|
||||
// gate blocked for a genuine restraint reason and durably records the
|
||||
// digest-eligible ones (loop.DigestEligible). Phrasing happens once, here,
|
||||
// at enqueue time — not re-derived at drain time — the same way queueNudge
|
||||
// phrases once and caches, so a rule suppressed for hours isn't re-prompting
|
||||
// the LLM every tick it stays blocked (EnqueueDigestEntry's rule+body dedupe
|
||||
// makes repeat calls here harmless, but skipping the phrase call entirely
|
||||
// when a pending entry already exists avoids the LLM round-trip too).
|
||||
func (t *tickLoop) enqueueSuppressedDigest(ctx context.Context, trace *loop.TickTrace, state loop.State, now time.Time) {
|
||||
if trace == nil {
|
||||
return
|
||||
}
|
||||
for _, tr := range trace.RuleTraces {
|
||||
if !tr.PredicateResult || tr.GateResult {
|
||||
continue // didn't want to fire, or wasn't suppressed
|
||||
}
|
||||
if !loop.DigestEligible(tr.Severity, tr.GateBlockedBy) {
|
||||
continue
|
||||
}
|
||||
rule := loop.Rule{Name: tr.RuleName, Severity: tr.Severity}
|
||||
cand := loop.Candidate{Rule: rule, Severity: tr.Severity, State: state}
|
||||
pn, err := t.phraser.PhraseNudge(ctx, cand)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase digest candidate %s: %v", tr.RuleName, err)
|
||||
continue
|
||||
}
|
||||
expires := now.Add(digestExpiry)
|
||||
if _, deduped, err := t.store.EnqueueDigestEntry(ctx, tr.RuleName, int(tr.Severity), pn.Body, now, expires); err != nil {
|
||||
log.Printf("tick: enqueue digest entry %s: %v", tr.RuleName, err)
|
||||
} else if deduped {
|
||||
// same suppressed nudge already pending — nothing new to say.
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// expireStaleDigest sweeps entries past their expiry once per tick — cheap
|
||||
// bookkeeping, mirrors ReconcileStaleDeliveryAttempts's shape.
|
||||
func (t *tickLoop) expireStaleDigest(ctx context.Context, now time.Time) {
|
||||
n, err := t.store.ExpireStaleDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: expire stale digest entries: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("tick: expired %d stale digest entr(y/ies) unspoken", n)
|
||||
}
|
||||
}
|
||||
|
||||
// maybeDrainDigest speaks the pending digest bundle once the gate's
|
||||
// suppression reasons have actually cleared — quiet hours over, back from
|
||||
// away, out of the meeting. Draining while still suppressed would just be a
|
||||
// second way to nag through quiet hours; the bundle waits for the same "is
|
||||
// it allowed right now" condition a live nudge already waits for.
|
||||
func (t *tickLoop) maybeDrainDigest(ctx context.Context, state loop.State, now time.Time) {
|
||||
if state.QuietHours || state.CalendarBusy || state.Presence == store.Away {
|
||||
return
|
||||
}
|
||||
entries, err := t.store.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: pending digest entries: %v", err)
|
||||
return
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
spoken := entries
|
||||
extra := 0
|
||||
if len(spoken) > maxDigestSpokenItems {
|
||||
spoken = entries[:maxDigestSpokenItems]
|
||||
extra = len(entries) - maxDigestSpokenItems
|
||||
}
|
||||
var b strings.Builder
|
||||
maxSev := 0
|
||||
for i, e := range spoken {
|
||||
if i > 0 {
|
||||
b.WriteString(" · ")
|
||||
}
|
||||
b.WriteString(e.Body)
|
||||
if e.Severity > maxSev {
|
||||
maxSev = e.Severity
|
||||
}
|
||||
}
|
||||
if extra > 0 {
|
||||
fmt.Fprintf(&b, " · и ещё %d", extra)
|
||||
}
|
||||
body := b.String()
|
||||
summary := fmt.Sprintf("%d отложенных уведомлений", len(entries))
|
||||
|
||||
cand := loop.Candidate{
|
||||
Rule: loop.Rule{Name: "digest", Severity: loop.Severity(maxSev)},
|
||||
Severity: loop.Severity(maxSev),
|
||||
State: state,
|
||||
}
|
||||
pn := delivery.PhrasedNudge{Candidate: cand, Body: body, Summary: summary}
|
||||
t.cachePhrase(pn)
|
||||
if _, err := t.dispatcher.DispatchNudge(ctx, pn, now); err != nil {
|
||||
log.Printf("tick: dispatch digest bundle: %v", err)
|
||||
return // leave entries pending; retried next tick
|
||||
}
|
||||
ids := make([]int64, len(entries))
|
||||
for i, e := range entries {
|
||||
ids[i] = e.ID
|
||||
}
|
||||
if err := t.store.DrainDigestEntries(ctx, ids, now); err != nil {
|
||||
log.Printf("tick: drain digest entries: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// routinesFromConfig maps the config's routine blocks to the engine type.
|
||||
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||
// in config.Load, so this is a pure field copy.
|
||||
@@ -530,6 +807,75 @@ func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.Morni
|
||||
return out
|
||||
}
|
||||
|
||||
// dayPlan is the read-only "what does today hold" query (Vikunja #128). It is
|
||||
// the impure half of morning.BuildPlan: it reads the calendar events, the
|
||||
// pending reminders and the checklist facts, and the pure builder orders them.
|
||||
//
|
||||
// It never dispatches. Asking for the plan is a query like any other; the only
|
||||
// unprompted delivery in maven stays with the morning nudge and the
|
||||
// dispatcher's policy.
|
||||
func (t *tickLoop) dayPlan(ctx context.Context, now time.Time) ipc.DayPlan {
|
||||
y, m, d := now.Date()
|
||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
var events []morning.PlanEntry
|
||||
facts, err := t.store.CalendarEvents(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: calendar events: %v", err)
|
||||
}
|
||||
for _, f := range facts {
|
||||
events = append(events, morning.PlanEntry{
|
||||
At: f.Ts,
|
||||
// The plan prints the hour itself, so the "@ 14:00-14:30" tail the
|
||||
// fact value carries would say it twice.
|
||||
Text: calendar.FactSummary(f.Value),
|
||||
Kind: morning.PlanEvent,
|
||||
// Provenance below a calendar read (an ambient relay, #126) is
|
||||
// hedged rather than recited as fact.
|
||||
Uncertain: f.Confidence < 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
var reminders []morning.PlanEntry
|
||||
rems, err := t.store.PendingReminders(ctx, dayStart, dayEnd)
|
||||
if err != nil {
|
||||
log.Printf("tick: day plan: pending reminders: %v", err)
|
||||
}
|
||||
for _, r := range rems {
|
||||
if r.Status != store.ReminderPending {
|
||||
continue
|
||||
}
|
||||
fire := r.NextFireTs
|
||||
if fire.IsZero() {
|
||||
fire = r.FireTs
|
||||
}
|
||||
reminders = append(reminders, morning.PlanEntry{
|
||||
At: fire,
|
||||
Text: strings.TrimSpace(r.Payload),
|
||||
Kind: morning.PlanReminder,
|
||||
})
|
||||
}
|
||||
|
||||
var checklistFacts map[string]store.Fact
|
||||
if len(t.morningRoutines) > 0 {
|
||||
checklistFacts = t.gatherMorningFacts(ctx)
|
||||
}
|
||||
plan := morning.BuildPlan(t.morningRoutines, checklistFacts, events, reminders, now)
|
||||
|
||||
out := ipc.DayPlan{Date: plan.Date, Spoken: plan.FormatRU()}
|
||||
out.Items = make([]ipc.DayPlanItem, len(plan.Items))
|
||||
for i, it := range plan.Items {
|
||||
out.Items[i] = ipc.DayPlanItem{
|
||||
At: it.At,
|
||||
Text: it.Text,
|
||||
Kind: string(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||
// rule:
|
||||
@@ -609,7 +955,21 @@ type daemonAPI struct {
|
||||
ipc.CoreAPI
|
||||
getTrace func() *loop.TickTrace
|
||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||
chatFn func(ctx context.Context, text string) string
|
||||
getMCPServers func() []ipc.MCPServerStatus
|
||||
getEvents func(n int) []ipc.IntakeEvent
|
||||
}
|
||||
|
||||
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
|
||||
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
|
||||
// look the same to a reader on purpose, because neither is a fault and the
|
||||
// page renders both as an empty table.
|
||||
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
|
||||
if d.getEvents == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getEvents(n), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
@@ -619,6 +979,16 @@ func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return d.chatFn(ctx, text), nil
|
||||
}
|
||||
|
||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||
// default state and the web surface renders it as such.
|
||||
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
|
||||
if d.getMCPServers == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.getMCPServers(), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
trace := d.getTrace()
|
||||
if trace == nil {
|
||||
@@ -634,6 +1004,13 @@ func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat
|
||||
return d.getMorningStatus(ctx), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||
if d.getDayPlan == nil {
|
||||
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||
}
|
||||
return d.getDayPlan(ctx), nil
|
||||
}
|
||||
|
||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||
for i, r := range t.RuleTraces {
|
||||
|
||||
@@ -46,7 +46,7 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil)
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil, nil)
|
||||
}
|
||||
|
||||
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
||||
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil)
|
||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil, nil)
|
||||
|
||||
// first tick: seeds, does not fire the routine.
|
||||
tl.tick(ctx, now)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// mavend/vision.go — core's half of image understanding (Vikunja #252,
|
||||
// docs/plans/07-vision.md).
|
||||
//
|
||||
// The split: any surface that can receive a picture (mavweb upload, a Telegram
|
||||
// photo through mavpoll, a path he names) hands the bytes to core over
|
||||
// ipc.MethodDescribeImage. Core stores them content-addressed under
|
||||
// media.dir, prepares a downscaled JPEG, and asks a local vision server what it
|
||||
// is. The description comes back as words; nothing about the image is echoed.
|
||||
//
|
||||
// Off unless configured: no `media` block ⇒ nowhere to keep the bytes, so the
|
||||
// method does not exist and a surface cannot make Maven accept a photo by
|
||||
// merely sending one. A `media` block with no `vision` block is a real state,
|
||||
// the one this box is in today: the store is wired, the method exists, the
|
||||
// bytes are kept and the reply says she cannot read the picture yet. That reply
|
||||
// is re-runnable by id on the day a vision model lands, which is the reason to
|
||||
// keep the bytes at all. Saving the description as a note needs more than the
|
||||
// read rung — see the scope check on auth.ImageNoteSource.
|
||||
//
|
||||
// Two things this file deliberately does not do:
|
||||
//
|
||||
// - No cloud vision call, ever. internal/vision refuses a non-private
|
||||
// endpoint at construction; there is no config shape here that could reach
|
||||
// an upstream API even if someone wanted one.
|
||||
// - No automatic memory. SaveNote is opt-in per call. Glancing at a screenshot
|
||||
// is not the same act as remembering it, and a 1.7B-class VLM's guess about
|
||||
// a photo is not a fact worth carrying around.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/media"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/vision"
|
||||
)
|
||||
|
||||
// prunePeriod — how often stored blobs are checked against media.retention.
|
||||
// Hourly is far more often than needed for a 7-day retention and costs a
|
||||
// directory walk over a handful of sidecars; the point is that the promise is
|
||||
// kept by a loop that runs, not by an operator remembering a cron.
|
||||
const prunePeriod = time.Hour
|
||||
|
||||
// mediaKeeper — the blob store plus the loop that enforces its retention. The
|
||||
// two are one object because a store without the loop is a directory that grows
|
||||
// forever, and shipping that would break the only interesting promise this
|
||||
// capability makes.
|
||||
type mediaKeeper struct {
|
||||
store *media.Store
|
||||
}
|
||||
|
||||
// openMediaStore builds the blob store from config, or returns nil when media is
|
||||
// not configured. A relative dir resolves against StateDir, the same rule the db
|
||||
// and socket paths follow.
|
||||
func openMediaStore(cfg *config.Config) *mediaKeeper {
|
||||
dir := cfg.Media.StoreDir()
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
if !filepath.IsAbs(dir) && cfg.StateDir != "" {
|
||||
dir = filepath.Join(cfg.StateDir, dir)
|
||||
}
|
||||
st, err := media.OpenWithBudget(dir, cfg.Media.MaxBytes, cfg.Media.MaxTotalBytes,
|
||||
time.Duration(cfg.Media.Retention))
|
||||
if err != nil {
|
||||
log.Printf("media: %v — image and audio intake disabled", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("media: blob store at %s, retention %s, %d of %d bytes used",
|
||||
st.Dir(), st.Retention(), st.Total(), st.Budget())
|
||||
return &mediaKeeper{store: st}
|
||||
}
|
||||
|
||||
// runPrune deletes over-retention blobs on a loop until ctx ends. It prunes once
|
||||
// immediately, so a daemon restarted after a long downtime does not sit on a
|
||||
// month of stale recordings until the first tick.
|
||||
func (k *mediaKeeper) runPrune(ctx context.Context) {
|
||||
prune := func() {
|
||||
n, err := k.store.Prune()
|
||||
if err != nil {
|
||||
log.Printf("media: prune: %v", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("media: pruned %d blob(s) older than %s", n, k.store.Retention())
|
||||
}
|
||||
}
|
||||
prune()
|
||||
t := time.NewTicker(prunePeriod)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
prune()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// visionIntake — one image at a time: store, prepare, describe, optionally note.
|
||||
type visionIntake struct {
|
||||
in *vision.Intake
|
||||
st *store.Store
|
||||
emb router.Embedder
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newVisionIntake returns nil when there is nothing to wire. keeper == nil means
|
||||
// no media block, which disables the method outright; a missing or disabled
|
||||
// vision block still wires the method, because storing an image and answering
|
||||
// "I can't look at it yet" is more useful than pretending the surface does not
|
||||
// exist — and it is exactly the state this box is in until a vision model is on
|
||||
// disk.
|
||||
func newVisionIntake(keeper *mediaKeeper, st *store.Store, emb router.Embedder, cfg *config.Config) *visionIntake {
|
||||
if keeper == nil {
|
||||
return nil
|
||||
}
|
||||
vc := cfg.Vision
|
||||
maxDim := 0
|
||||
var provider vision.Provider = vision.Disabled{}
|
||||
if vc.LooksAtImages() {
|
||||
p, err := vision.NewLocal(vision.Config{
|
||||
Endpoint: vc.Endpoint,
|
||||
Model: vc.Model,
|
||||
Timeout: time.Duration(vc.Timeout),
|
||||
MaxTokens: vc.MaxTokens,
|
||||
Prompt: vc.Prompt,
|
||||
})
|
||||
if err != nil {
|
||||
// A public endpoint, a hostname, a bad URL. Logged once here rather
|
||||
// than failing every turn, and the store still works.
|
||||
log.Printf("vision: %v — she can store images but not describe them", err)
|
||||
} else {
|
||||
provider = p
|
||||
maxDim = vc.MaxDim
|
||||
log.Printf("vision: enabled against %s", p.Endpoint())
|
||||
}
|
||||
} else {
|
||||
log.Printf("vision: not configured — images are stored, not described")
|
||||
}
|
||||
return &visionIntake{
|
||||
in: vision.NewIntake(keeper.store, provider, maxDim),
|
||||
st: st,
|
||||
emb: emb,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// describe handles one ipc.MethodDescribeImage call.
|
||||
//
|
||||
// A description failure is NOT an error out of this method when the bytes were
|
||||
// stored: the caller gets the id and an empty description, which is honest ("it
|
||||
// is kept, I cannot read it yet") and re-runnable. A failure to store, or bytes
|
||||
// that are not an image at all, is an error — there is nothing to come back to.
|
||||
func (v *visionIntake) describe(ctx context.Context, req ipc.DescribeImageReq) (ipc.DescribeImageResp, error) {
|
||||
if len(req.Data) == 0 && req.ID == "" {
|
||||
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: neither data nor id")
|
||||
}
|
||||
if len(req.Data) > 0 && req.ID != "" {
|
||||
// The contract says exactly one. Taking the ID branch and dropping the
|
||||
// bytes silently is the worst of the three possible answers: the caller
|
||||
// believes it sent a new image and nothing says otherwise.
|
||||
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: both data and id given, send one")
|
||||
}
|
||||
|
||||
var (
|
||||
res vision.Result
|
||||
err error
|
||||
)
|
||||
if req.ID != "" {
|
||||
res, err = v.in.Rerun(ctx, req.ID, req.Question)
|
||||
} else {
|
||||
res, err = v.in.Accept(ctx, req.Data, sourceOrDefault(req.Source), req.Question)
|
||||
}
|
||||
if res.Blob.ID == "" {
|
||||
// Nothing was stored: bad format, over the size cap, unwritable dir.
|
||||
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: %w", err)
|
||||
}
|
||||
|
||||
resp := ipc.DescribeImageResp{
|
||||
ID: res.Blob.ID,
|
||||
Description: res.Description,
|
||||
Width: res.Image.Width,
|
||||
Height: res.Image.Height,
|
||||
}
|
||||
if err != nil {
|
||||
// Bytes are safe, words are not available. The log names the blob and the
|
||||
// reason; it never names what was in the picture.
|
||||
if errors.Is(err, vision.ErrDisabled) {
|
||||
log.Printf("vision: stored %s, no vision model configured", res.Blob)
|
||||
} else {
|
||||
log.Printf("vision: stored %s, describe failed: %v", res.Blob, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
if req.SaveNote {
|
||||
id, werr := v.writeNote(ctx, res)
|
||||
if werr != nil {
|
||||
// The description is still returned: losing the note is worse as a
|
||||
// silent failure than as a log line next to a successful answer.
|
||||
log.Printf("vision: note write for %s failed: %v", res.Blob, werr)
|
||||
} else {
|
||||
resp.NoteID = id
|
||||
}
|
||||
}
|
||||
log.Printf("vision: described %s (%dx%d)", res.Blob, res.Image.Width, res.Image.Height)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// noteMarker prefixes a stored description. Without it the note reads exactly
|
||||
// like something he told her, and it is not: it is a small VLM's guess about a
|
||||
// picture, embedded and recalled as if it were his own words. Four characters
|
||||
// of provenance in the text are cheaper than believing it later.
|
||||
const noteMarker = "Со снимка: "
|
||||
|
||||
// writeNote stores the description as an ordinary note so it is recallable. The
|
||||
// note carries the blob id in its source, which is the only link back to the
|
||||
// bytes — the note text is words about the picture, never the picture.
|
||||
func (v *visionIntake) writeNote(ctx context.Context, res vision.Result) (int64, error) {
|
||||
var vec []float32
|
||||
if v.emb != nil {
|
||||
// EmbedPassage, not Embed: a description is text being searched FOR, and
|
||||
// the e5 embedder is asymmetric. Backwards here makes it unfindable by
|
||||
// the question that should have matched it.
|
||||
var err error
|
||||
vec, err = router.EmbedPassage(ctx, v.emb, res.Description)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("embed: %w", err)
|
||||
}
|
||||
}
|
||||
source := "media:image:" + res.Blob.ID[:12]
|
||||
return v.st.WriteNote(ctx, v.now(), noteMarker+res.Description, vec, source)
|
||||
}
|
||||
|
||||
// sourceOrDefault labels a blob whose sender did not say where it came from.
|
||||
func sourceOrDefault(s string) string {
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// wireVision installs the IPC hook and starts the retention loop, or leaves the
|
||||
// hook nil so ipc.MethodDescribeImage reports ErrUnknownMethod. Called on both
|
||||
// startup paths (unlocked boot and passkey unlock) so vision behaves the same
|
||||
// either way.
|
||||
//
|
||||
// Returns the media keeper so the meeting recorder can share it: one blob store
|
||||
// with one retention loop holds both the images and the audio, which is the
|
||||
// whole point of internal/media being a shared package. nil ⇒ no media block,
|
||||
// and neither capability exists.
|
||||
func wireVision(ctx context.Context, wg *sync.WaitGroup, srv *ipc.Server, st *store.Store, emb router.Embedder, cfg *config.Config) *mediaKeeper {
|
||||
keeper := openMediaStore(cfg)
|
||||
if keeper == nil {
|
||||
return nil
|
||||
}
|
||||
// In the daemon's WaitGroup like every other loop in run: a prune deletes
|
||||
// files, and shutting down in the middle of one was the single loop nobody
|
||||
// waited for.
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
keeper.runPrune(ctx)
|
||||
}()
|
||||
|
||||
vi := newVisionIntake(keeper, st, emb, cfg)
|
||||
if vi == nil {
|
||||
return keeper
|
||||
}
|
||||
srv.DescribeImageFn = vi.describe
|
||||
return keeper
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/media"
|
||||
"github.com/kami/maven/internal/vision"
|
||||
)
|
||||
|
||||
func testIntake(t *testing.T) *visionIntake {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
blobs, err := media.Open(t.TempDir(), 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &visionIntake{
|
||||
in: vision.NewIntake(blobs, vision.Disabled{}, 0),
|
||||
st: st,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// The contract says exactly one of Data or ID. Taking the ID branch and
|
||||
// dropping the bytes silently is the worst of the three possible answers: the
|
||||
// caller believes it sent a new image and nothing says otherwise.
|
||||
func TestDescribeRefusesBothDataAndID(t *testing.T) {
|
||||
v := testIntake(t)
|
||||
_, err := v.describe(context.Background(), ipc.DescribeImageReq{
|
||||
Data: []byte("bytes"), ID: strings.Repeat("a", 64),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("both data and id must be refused")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "send one") {
|
||||
t.Fatalf("err = %v, want it to name the contract", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Vision being off does not remove the method: the bytes are stored and the
|
||||
// answer says she cannot read the picture yet, which is re-runnable by id. That
|
||||
// is the state this box is in today, and three doc comments used to claim the
|
||||
// opposite.
|
||||
func TestVisionOffStillStores(t *testing.T) {
|
||||
v := testIntake(t)
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := v.describe(context.Background(), ipc.DescribeImageReq{Data: buf.Bytes(), Source: "web:upload"})
|
||||
if err != nil {
|
||||
t.Fatalf("storing must succeed even with no vision model: %v", err)
|
||||
}
|
||||
if len(resp.ID) != 64 {
|
||||
t.Fatalf("no blob id came back: %+v", resp)
|
||||
}
|
||||
if resp.Description != "" {
|
||||
t.Errorf("description = %q, want none", resp.Description)
|
||||
}
|
||||
// And with no media block at all the method does not exist.
|
||||
if vi := newVisionIntake(nil, nil, nil, &config.Config{}); vi != nil {
|
||||
t.Fatal("no media block must leave the method nonexistent")
|
||||
}
|
||||
}
|
||||
+124
-1428
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,478 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/delivery/voicesink"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/stt"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/tts"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
"github.com/kami/maven/internal/weather"
|
||||
"github.com/kami/maven/internal/worker"
|
||||
)
|
||||
|
||||
// voiceWiring — everything the daemon needs to run the audio path. Held by
|
||||
// cmd/mavend/main.go alongside the other wirings; closed on shutdown.
|
||||
type voiceWiring struct {
|
||||
server *voice.Server
|
||||
sessions *voice.Sessions
|
||||
voiceSink delivery.Sink
|
||||
embedder router.Embedder
|
||||
handler *reactiveHandler // the reactive handler for IPC Chat
|
||||
// worker clients (set when configured as Remote): closed on shutdown so
|
||||
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
||||
sttClient *worker.Client
|
||||
ttsClient *worker.Client
|
||||
// transcriber — the STT in use, exposed so the meeting recorder
|
||||
// (cmd/mavend/capture.go) can reuse it. Maven has exactly one STT and does
|
||||
// not grow a second one for capture: this is the same whisper.cpp worker the
|
||||
// voice path talks to.
|
||||
transcriber stt.Transcriber
|
||||
// mcp — the MCP client, nil unless the `mcp` block configures an enabled
|
||||
// server (Vikunja #251). Its tools land in the same allowlist as every
|
||||
// other act, so nothing else here has to know about it.
|
||||
mcp *mcpWiring
|
||||
// home — the Home Assistant client, nil unless the `smarthome` block is
|
||||
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
||||
// other act, so nothing else here has to know about it.
|
||||
home *homeWiring
|
||||
// netscan — the LAN scanner, nil unless the `netscan` block is enabled
|
||||
// (Vikunja #257).
|
||||
netscan *netWiring
|
||||
}
|
||||
|
||||
// close releases the listener + worker conns. Safe to call on nil (when
|
||||
// voice is not wired — wireVoice returns nil,nil).
|
||||
func (w *voiceWiring) close() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
if w.embedder != nil {
|
||||
_ = w.embedder.Close()
|
||||
}
|
||||
if w.server != nil {
|
||||
_ = w.server.Close()
|
||||
}
|
||||
if w.sttClient != nil {
|
||||
_ = w.sttClient.Close()
|
||||
}
|
||||
if w.ttsClient != nil {
|
||||
_ = w.ttsClient.Close()
|
||||
}
|
||||
w.mcp.close()
|
||||
}
|
||||
|
||||
// wireVoice builds the audio path from cfg + a CoreAPI + a router. Returns
|
||||
// nil wiring + nil error when voice isn't enabled (the caller's voice sink
|
||||
// stays nil; the dispatcher's ChannelVoice routing drops silently).
|
||||
//
|
||||
// When voice is enabled, MUST wire a voicesink into the dispatcher's Voice
|
||||
// slot using w.sessions (the caller does that — see main.go).
|
||||
func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, memStore memory.Store, dataStore *store.Store, eco *ecosystemWiring) (*voiceWiring, error) {
|
||||
if cfg.Voice == nil || !cfg.Voice.Enabled {
|
||||
return nil, nil
|
||||
}
|
||||
w := &voiceWiring{}
|
||||
|
||||
// ----- stt (Stub in-process OR Remote via worker socket) -----
|
||||
var transcriber stt.Transcriber
|
||||
if cfg.Voice.Stt != nil && cfg.Voice.Stt.Socket != "" {
|
||||
c := worker.Dial(cfg.Voice.Stt.Socket)
|
||||
w.sttClient = c
|
||||
lang := cfg.Voice.Stt.Lang
|
||||
if lang == "" {
|
||||
lang = cfg.Voice.Lang
|
||||
}
|
||||
transcriber = stt.NewRemote(c, lang)
|
||||
} else {
|
||||
transcriber = stt.NewStub()
|
||||
}
|
||||
w.transcriber = transcriber
|
||||
|
||||
// ----- tts (Stub in-process OR Remote) -----
|
||||
var synthesizer tts.Synthesizer
|
||||
if cfg.Voice.Tts != nil && cfg.Voice.Tts.Socket != "" {
|
||||
c := worker.Dial(cfg.Voice.Tts.Socket)
|
||||
w.ttsClient = c
|
||||
lang := cfg.Voice.Tts.Lang
|
||||
if lang == "" {
|
||||
lang = cfg.Voice.Lang
|
||||
}
|
||||
synthesizer = tts.NewRemote(c, lang, cfg.Voice.Tts.Voice)
|
||||
} else {
|
||||
synthesizer = tts.NewStub()
|
||||
}
|
||||
|
||||
// ----- router: embedder (ONNX when configured, floor HashEmbedder otherwise) -----
|
||||
var emb router.Embedder
|
||||
if cfg.Voice.Embedder != nil {
|
||||
onnx, err := router.NewONNXEmbedder(
|
||||
cfg.Voice.Embedder.ModelPath,
|
||||
cfg.Voice.Embedder.TokenizerPath,
|
||||
cfg.Voice.Embedder.LibPath,
|
||||
)
|
||||
if err != nil {
|
||||
w.close()
|
||||
return nil, fmt.Errorf("embedder: %w", err)
|
||||
}
|
||||
log.Printf("voice: onnx embedder loaded (%d dim)", onnx.Dim())
|
||||
emb = onnx
|
||||
} else {
|
||||
log.Printf("voice: embedder not configured, using HashEmbedder floor")
|
||||
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
|
||||
// enabled (editing mavend.json IS the human enable act). Ad-hoc tools are
|
||||
// enabled later through the authed mavweb surface. The executor + matcher
|
||||
// both read the store live, so a newly-enabled tool is runnable without a
|
||||
// daemon restart.
|
||||
seedTools(coreAPI, cfg.Voice.Tools)
|
||||
exec := tool.NewExecutor(coreAPI, time.Duration(cfg.Voice.ToolTimeout))
|
||||
// MCP servers (Vikunja #251): discovery PROPOSES tools into the same
|
||||
// allowlist, so an MCP tool is enabled by hand on /tools like any other and
|
||||
// runs through the same confirm turn. Off unless the `mcp` block configures
|
||||
// an enabled server.
|
||||
w.mcp = wireMCP(cfg, dataStore)
|
||||
if w.mcp != nil {
|
||||
exec = exec.WithMCP(w.mcp.caller())
|
||||
}
|
||||
// The house (Vikunja #256): same story as MCP. Discovery PROPOSES a row per
|
||||
// controllable device, always destructive, and Kami enables the ones he
|
||||
// wants on /tools. Off unless the `smarthome` block is enabled.
|
||||
w.home = wireSmartHome(cfg, dataStore)
|
||||
if w.home != nil {
|
||||
exec = exec.WithHome(w.home.caller())
|
||||
}
|
||||
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
||||
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
||||
w.netscan = wireNetScan(cfg, coreAPI)
|
||||
matcher := tool.NewMatcher(coreAPI)
|
||||
|
||||
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
||||
var weatherProvider weather.Provider
|
||||
var weatherLocation string
|
||||
if cfg.Voice.Weather != nil && cfg.Voice.Weather.Provider == "open-meteo" {
|
||||
weatherProvider = weather.NewOpenMeteoProvider()
|
||||
weatherLocation = cfg.Voice.Weather.DefaultLocation
|
||||
log.Printf("voice: weather provider: open-meteo (default location: %s)", cfg.Voice.Weather.DefaultLocation)
|
||||
} else {
|
||||
weatherProvider = weather.NewStubProvider()
|
||||
log.Printf("voice: weather provider: stub (not configured)")
|
||||
}
|
||||
|
||||
// The replier uses the same llama-server as the phraser.
|
||||
var llmClient *llm.Client
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok {
|
||||
// llmClientFor, not llm.New: this client must follow the phraser onto
|
||||
// the new llama-server when the resident model is swapped (Vikunja #250).
|
||||
llmClient = llmClientFor(lp, 60*time.Second)
|
||||
}
|
||||
// ----- 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).
|
||||
threshold := cfg.Voice.RouterThreshold
|
||||
if threshold <= 0 {
|
||||
threshold = config.DefaultRouterThreshold
|
||||
}
|
||||
// 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()
|
||||
w.sessions = sessions
|
||||
|
||||
// ----- voice sink (proactive nudges: dispatcher → voicesink → tts → push to client) -----
|
||||
w.voiceSink = voicesink.New(synthesizer, sessions)
|
||||
|
||||
// ----- memory (long-term vector storage) -----
|
||||
// Persistent (store-backed, survives restarts) when the daemon passes one;
|
||||
// falls back to the in-memory floor otherwise (tests / no-store paths).
|
||||
if memStore == nil {
|
||||
memStore = memory.NewInMemoryStore()
|
||||
}
|
||||
|
||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||
// 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, contextBlockFn(cfg, time.Now))
|
||||
}
|
||||
|
||||
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
|
||||
h := &reactiveHandler{
|
||||
stt: transcriber,
|
||||
tts: synthesizer,
|
||||
router: rtr,
|
||||
embedder: emb,
|
||||
api: coreAPI,
|
||||
tools: exec,
|
||||
matcher: matcher,
|
||||
replier: replier,
|
||||
phraser: phr,
|
||||
now: time.Now,
|
||||
feedsOn: cfg.Feeds != nil,
|
||||
home: w.home,
|
||||
netscan: w.netscan,
|
||||
// nil unless `crawl.on_demand` is on: reading a page he names is a
|
||||
// capability, and capabilities are off unless configured.
|
||||
crawler: onDemandCrawler(cfg),
|
||||
weatherProvider: weatherProvider,
|
||||
weatherLocation: weatherLocation,
|
||||
memStore: memStore,
|
||||
dataStore: dataStore,
|
||||
dialogueSessions: dialogueSessions,
|
||||
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) -----
|
||||
srv := voice.NewServer(cfg.Voice.Bind, h, sessions)
|
||||
if err := srv.Listen(); err != nil {
|
||||
w.close()
|
||||
return nil, fmt.Errorf("voice listen: %w", err)
|
||||
}
|
||||
w.server = srv
|
||||
w.handler = h
|
||||
|
||||
return w, 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
|
||||
// the enabled tool names (actFns) — the router only matches acts the
|
||||
// executor can run. Empty ⇒ every act refuses at the matcher.
|
||||
// - The embedder is provided by wireVoice: HashEmbedder (floor) when no
|
||||
// embedder config is present, or the ONNX multilingual model when
|
||||
// configured — same interface, one constructor change.
|
||||
// - 6 bootstrap examples covering the 5 intents + one compound-capture
|
||||
// placeholder. Spec calls for ~10 per intent at production; this is the
|
||||
// bootstrapping floor swapped by tuning the seed set later.
|
||||
// - Threshold is from voice.router_threshold config (default 0.55).
|
||||
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, llmR *router.LLMRouter) *router.Router {
|
||||
cls := router.NewClassifier(emb)
|
||||
seedClassifier(cls)
|
||||
grammars := router.DefaultGrammars(acts)
|
||||
grammars = append(grammars, router.SystemTimeDateGrammars()...)
|
||||
grammars = append(grammars, router.ReminderGrammar())
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
Classifier: cls,
|
||||
Extractor: router.Extractor{
|
||||
Time: router.NewPythonDateParser(),
|
||||
Acts: acts,
|
||||
Facts: router.DefaultFactParser{},
|
||||
},
|
||||
Threshold: threshold,
|
||||
LLM: llmR,
|
||||
})
|
||||
}
|
||||
|
||||
// seedDir is the directory containing intent seed files. Each file is named
|
||||
// <intent>.txt and contains one training example per line (blank lines and
|
||||
// lines starting with # are ignored). Relative to the working directory.
|
||||
const seedDir = "models/seeds"
|
||||
|
||||
// seedClassifier floors the embedded examples so the cold-boot path
|
||||
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
|
||||
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
|
||||
// classifier can't decide it falls through to Clarify — the last-resort
|
||||
// path asks the user to rephrase rather than guessing wrong.
|
||||
func seedClassifier(c *router.Classifier) {
|
||||
intents := []router.Intent{
|
||||
router.IntentAct,
|
||||
router.IntentReminder,
|
||||
router.IntentFact,
|
||||
router.IntentNote,
|
||||
router.IntentQuery,
|
||||
router.IntentChat,
|
||||
router.IntentSystem,
|
||||
}
|
||||
total := 0
|
||||
for _, intent := range intents {
|
||||
n, err := loadSeedFile(c, intent)
|
||||
if err != nil {
|
||||
log.Printf("voice: seed %s: %v", intent, err)
|
||||
continue
|
||||
}
|
||||
total += n
|
||||
}
|
||||
log.Printf("voice: loaded %d seed examples from %s", total, seedDir)
|
||||
}
|
||||
|
||||
func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
|
||||
path := filepath.Join(seedDir, string(intent)+".txt")
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var count int
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if err := c.AddExample(context.Background(), intent, line); err != nil {
|
||||
log.Printf("voice: seed %s: skipping %q: %v", intent, line, err)
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return count, fmt.Errorf("scan %s: %w", path, err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// seedTools upserts the config-declared tools into the store as enabled. Editing
|
||||
// mavend.json is a human act, so a config tool is enabled by definition; this
|
||||
// makes the declarative config the reproducible bootstrap while the store stays
|
||||
// the single runtime source of truth (mavweb enables ad-hoc ones on top).
|
||||
func seedTools(api ipc.CoreAPI, tools []config.ToolConfig) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
n := 0
|
||||
for _, tc := range tools {
|
||||
if tc.Name == "" || len(tc.Cmd) == 0 {
|
||||
log.Printf("voice: skipping malformed tool config %+v", tc)
|
||||
continue
|
||||
}
|
||||
if err := api.EnableTool(ctx, tc.Name, tc.Cmd, tc.Destructive, tc.Scope, now); err != nil {
|
||||
log.Printf("voice: seed tool %q: %v", tc.Name, err)
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
log.Printf("voice: seeded %d act tools from config", n)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Package main — weatherq.go holds the weather-query keyword helpers: does
|
||||
// this utterance ask about weather at all, and which city (if any) did it
|
||||
// name. Both are plain substring/lookup matching, not NLU — extend this file
|
||||
// rather than voice.go for anything in that shape.
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// isWeatherQuery returns true if the utterance is about weather.
|
||||
func isWeatherQuery(u string) bool {
|
||||
lower := strings.ToLower(u)
|
||||
return strings.Contains(lower, "погод") ||
|
||||
strings.Contains(lower, "градус") ||
|
||||
strings.Contains(lower, "температур") ||
|
||||
strings.Contains(lower, "дожд") ||
|
||||
strings.Contains(lower, "холод") ||
|
||||
strings.Contains(lower, "тепл") ||
|
||||
strings.Contains(lower, "weather") ||
|
||||
strings.Contains(lower, "temperature")
|
||||
}
|
||||
|
||||
// weatherCities — the city names an utterance may name explicitly, as
|
||||
// lowercase substrings mapped to the provider's spelling. This is a
|
||||
// convenience for "какая погода в Лондоне", NOT a source of default truth:
|
||||
// nothing here is used unless he actually said it.
|
||||
var weatherCities = map[string]string{
|
||||
"москв": "Moscow",
|
||||
"moscow": "Moscow",
|
||||
"питер": "Saint Petersburg",
|
||||
"spb": "Saint Petersburg",
|
||||
"петербур": "Saint Petersburg",
|
||||
"лондон": "London",
|
||||
"london": "London",
|
||||
"париж": "Paris",
|
||||
"paris": "Paris",
|
||||
"берлин": "Berlin",
|
||||
"berlin": "Berlin",
|
||||
"нью-йорк": "New York",
|
||||
"new york": "New York",
|
||||
}
|
||||
|
||||
// extractWeatherLocation returns the city he named, or the configured default
|
||||
// when he named none. It returns "" when he named none AND no default is
|
||||
// configured — the caller must then say it does not know.
|
||||
//
|
||||
// It used to return "Moscow" in that case. That is a made-up answer presented
|
||||
// as fact: reading out Moscow's temperature to someone who is not in Moscow is
|
||||
// wrong in exactly the way maven must never be wrong. voice.weather
|
||||
// .default_location is the only source of an unstated location.
|
||||
func extractWeatherLocation(u, defaultLoc string) string {
|
||||
lower := strings.ToLower(u)
|
||||
for substr, name := range weatherCities {
|
||||
if strings.Contains(lower, substr) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return defaultLoc
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
// mavmaild — the mail reader module (Vikunja #246,
|
||||
// docs/plans/01-email-reader.md).
|
||||
//
|
||||
// Every so often it opens one IMAP mailbox read-only, fetches the messages it
|
||||
// has not read yet, and hands each one to core over ipc.MethodIngestMail. Core
|
||||
// runs the extraction on the resident model and writes what comes back as task
|
||||
// CANDIDATES he reviews on /tasks. Nothing here writes to the store, nothing
|
||||
// here can create a reminder, and nothing here speaks.
|
||||
//
|
||||
// Why a separate daemon rather than a loop inside mavend, when extraction has
|
||||
// to happen in mavend anyway: the credential. mavpoll set the precedent with the
|
||||
// zenmoney token (#125) — the module that talks to a third party holds the
|
||||
// secret, reads it from a FILE so it never appears in `ps`, in
|
||||
// docker-compose.yml or in shell history, and core never sees it. Core learns
|
||||
// that mail exists only as message text on one IPC method; it cannot connect to
|
||||
// the mailbox even if it wanted to, and a compromised core yields no mail
|
||||
// password.
|
||||
//
|
||||
// Off unless configured: without -password-file there is nothing to run, and
|
||||
// the daemon says so and exits. If core has no `email` block the very first
|
||||
// ingest comes back ErrUnknownMethod and this daemon stops polling instead of
|
||||
// hammering a socket that will keep refusing.
|
||||
//
|
||||
// Mail is personal, so the log is counts and UIDs: how many messages were
|
||||
// fetched, how many were bulk, how many candidates came back. No subject, no
|
||||
// sender, no body, ever — reviewing a candidate is what /tasks is for.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "mavmaild:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
fs := flag.NewFlagSet("mavmaild", flag.ContinueOnError)
|
||||
socket := fs.String("socket", "", "core IPC socket path (required)")
|
||||
server := fs.String("imap", "", "IMAP server, host or host:993 (required)")
|
||||
user := fs.String("user", "", "IMAP username (required)")
|
||||
passFile := fs.String("password-file", "", "file holding the IMAP password (required — never passed as a flag value)")
|
||||
mailbox := fs.String("mailbox", "INBOX", "mailbox to read, read-only")
|
||||
interval := fs.Duration("interval", 15*time.Minute, "how often to read the mailbox")
|
||||
lookback := fs.Duration("lookback", 72*time.Hour, "how far back to search on each poll")
|
||||
// -max and -interval are one decision, not two. Every non-bulk message in a
|
||||
// poll is one serialized llama-server call on core's side, and core gates
|
||||
// mail extraction behind voice turns (llm.Gate), so a large batch does not
|
||||
// mute Maven, it just takes a while. Raise -max only alongside whatever
|
||||
// bound core is running.
|
||||
max := fs.Int("max", 25, "most messages to fetch in one poll")
|
||||
timeout := fs.Duration("timeout", 30*time.Second, "IMAP network timeout")
|
||||
statePath := fs.String("state", "", "file remembering which UIDs were read (default: none — every poll re-reads the window)")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if *socket == "" {
|
||||
return fmt.Errorf("-socket is required")
|
||||
}
|
||||
if *server == "" || *user == "" || *passFile == "" {
|
||||
return fmt.Errorf("mail reading is off unless configured: set -imap, -user and -password-file")
|
||||
}
|
||||
|
||||
// The password is read from a file, never taken as a flag value: an argv
|
||||
// secret is visible in `ps` to every user on the box and lands in the compose
|
||||
// file and the shell history. Read once at start — a rotated password means a
|
||||
// restart, which is cheaper than re-reading his credential every quarter hour.
|
||||
raw, err := os.ReadFile(*passFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read password file: %w", err)
|
||||
}
|
||||
password := strings.TrimSpace(string(raw))
|
||||
if password == "" {
|
||||
return fmt.Errorf("password file %s is empty", *passFile)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
core, err := ipc.DialWait(*socket, 60*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer core.Close()
|
||||
|
||||
r := &reader{
|
||||
core: core,
|
||||
addr: *server,
|
||||
user: *user,
|
||||
mailbox: *mailbox,
|
||||
lookback: *lookback,
|
||||
max: *max,
|
||||
timeout: *timeout,
|
||||
state: newSeenState(*statePath),
|
||||
}
|
||||
if err := r.state.load(); err != nil {
|
||||
// A missing or corrupt state file must not stop mail from being read: the
|
||||
// worst case is re-reading the window, and capture dedupes on text.
|
||||
log.Printf("mavmaild: state: %v (starting from an empty seen-set)", err)
|
||||
}
|
||||
|
||||
// The password is never logged, not even its length.
|
||||
log.Printf("mavmaild: reading %s on %s every %s (lookback %s, max %d/poll)",
|
||||
*mailbox, *server, *interval, *lookback, *max)
|
||||
|
||||
r.pollOnce(ctx, password) // don't idle a full interval on start
|
||||
t := time.NewTicker(*interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("mavmaild: bye")
|
||||
return nil
|
||||
case <-t.C:
|
||||
// Core told us mail ingestion is not configured. Nothing will change
|
||||
// without a core restart, and a restart restarts us too, so the
|
||||
// daemon stays up and does nothing at all.
|
||||
//
|
||||
// It does NOT exit. The compose service inherits restart:
|
||||
// unless-stopped, which restarts a clean exit as readily as a crash,
|
||||
// so exiting here produced a loop: log in to IMAP, get refused by
|
||||
// core, exit, restart, log in again. Four IMAP logins an hour
|
||||
// against a mailbox that has nothing to give, and Gmail and Yandex
|
||||
// both rate-limit exactly that.
|
||||
if r.disabled {
|
||||
continue
|
||||
}
|
||||
r.pollOnce(ctx, password)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mailIngester — the slice of core this daemon uses. One method: hand over a
|
||||
// message. It cannot write a fact, create a reminder or read the store, and the
|
||||
// interface says so.
|
||||
type mailIngester interface {
|
||||
IngestMail(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error)
|
||||
}
|
||||
|
||||
type reader struct {
|
||||
core mailIngester
|
||||
addr string
|
||||
user string
|
||||
mailbox string
|
||||
lookback time.Duration
|
||||
max int
|
||||
timeout time.Duration
|
||||
state *seenState
|
||||
|
||||
// fetchMail — the read seam, nil ⇒ the real IMAP read. The tests replace
|
||||
// the whole read rather than the transport: internal/email keeps its dialer
|
||||
// unexported so that no code outside that package can point the reader at a
|
||||
// cleartext socket and hand it the password, and this daemon is code
|
||||
// outside that package.
|
||||
fetchMail func(password string) ([]email.Message, error)
|
||||
|
||||
// disabled — core answered ErrUnknownMethod, i.e. it has no email block.
|
||||
// Written in pollOnce and read in the ticker loop, both on the one
|
||||
// goroutine that run() drives, so it needs no atomic. If a second caller of
|
||||
// pollOnce ever appears, this becomes a race and has to change.
|
||||
disabled bool
|
||||
}
|
||||
|
||||
// pollOnce — one read of the mailbox, then one ingest per message.
|
||||
//
|
||||
// A fetch error aborts this poll and nothing else; the next tick tries again.
|
||||
// An ingest error for one message does not skip the rest — one mail the model
|
||||
// choked on should not hide the four behind it.
|
||||
func (r *reader) pollOnce(ctx context.Context, password string) {
|
||||
msgs, err := r.fetch(password)
|
||||
if err != nil {
|
||||
// The error may name a UID; it never names a subject or a sender.
|
||||
log.Printf("mavmaild: fetch: %v", err)
|
||||
if len(msgs) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
var junk, candidates, created int
|
||||
for _, m := range msgs {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
req := ipc.IngestMailReq{
|
||||
Mailbox: r.mailbox,
|
||||
UID: m.UID,
|
||||
From: m.From,
|
||||
Subject: m.Subject,
|
||||
Date: m.Date,
|
||||
Body: m.Body,
|
||||
}
|
||||
if m.Junk {
|
||||
junk++
|
||||
// Core is TOLD, which is what its wire doc says: it counts the bulk
|
||||
// message and answers Skipped without spending the model. The header
|
||||
// filter already decided, so no content is sent with the verdict —
|
||||
// nothing will read it.
|
||||
req = ipc.IngestMailReq{Mailbox: r.mailbox, UID: m.UID, Junk: true}
|
||||
}
|
||||
resp, err := r.core.IngestMail(ctx, req)
|
||||
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||
log.Printf("mavmaild: core has no email block configured — mail ingestion is off; idling until a restart")
|
||||
r.disabled = true
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
// Not marked seen: an ingest that failed should be retried next poll.
|
||||
log.Printf("mavmaild: ingest uid %d: %v", m.UID, err)
|
||||
continue
|
||||
}
|
||||
r.state.mark(m.UID)
|
||||
candidates += len(resp.TaskIDs)
|
||||
created += resp.Created
|
||||
}
|
||||
if err := r.state.save(); err != nil {
|
||||
log.Printf("mavmaild: state: %v", err)
|
||||
}
|
||||
log.Printf("mavmaild: %s: %d read, %d bulk, %d candidate(s), %d new", r.mailbox, len(msgs), junk, candidates, created)
|
||||
}
|
||||
|
||||
// fetch reads the mailbox. Messages already in the seen-set are not fetched at
|
||||
// all, so a steady mailbox costs one SEARCH per poll and nothing else.
|
||||
func (r *reader) fetch(password string) ([]email.Message, error) {
|
||||
if r.fetchMail != nil {
|
||||
return r.fetchMail(password)
|
||||
}
|
||||
f := email.FetchSince{
|
||||
Addr: r.addr,
|
||||
User: r.user,
|
||||
Mailbox: r.mailbox,
|
||||
Timeout: r.timeout,
|
||||
Since: time.Now().Add(-r.lookback),
|
||||
Max: r.max,
|
||||
Skip: r.state.seen,
|
||||
// Everything below the oldest searchable UID has aged out of the
|
||||
// lookback window and can never be read again. Retiring it is what keeps
|
||||
// one permanently failing message from pinning the high-water mark
|
||||
// forever. See seenState.retire.
|
||||
OnSearch: func(uids []uint32) {
|
||||
if len(uids) == 0 {
|
||||
return
|
||||
}
|
||||
low := uids[0]
|
||||
for _, u := range uids {
|
||||
if u < low {
|
||||
low = u
|
||||
}
|
||||
}
|
||||
r.state.retire(low)
|
||||
},
|
||||
}
|
||||
return f.Run(password)
|
||||
}
|
||||
|
||||
// ---- seen state ------------------------------------------------------------
|
||||
|
||||
// seenState — the UIDs already handed to core, persisted so a restart does not
|
||||
// re-read (and re-extract, at multi-second LLM cost) the whole lookback window.
|
||||
//
|
||||
// Correctness does not depend on it: ipc.CaptureTask dedupes on normalised text
|
||||
// among live tasks, so a re-read produces no duplicate rows. This exists to save
|
||||
// the model's time, which is why a broken state file is a log line rather than a
|
||||
// failure.
|
||||
//
|
||||
// UIDs are per-mailbox and monotonic, so the set is kept as a high-water mark
|
||||
// plus the stragglers above it. If the server ever changes UIDVALIDITY, UIDs
|
||||
// reset and the window is simply re-read once — dedupe absorbs it.
|
||||
//
|
||||
// The high-water mark only advances through a CONTIGUOUS run, so a UID that
|
||||
// never ingests successfully would pin it forever: everything above stays in
|
||||
// the explicit set, and save rewrites all of it every poll. A year of that is
|
||||
// a few hundred thousand entries written every quarter hour, which breaks
|
||||
// nothing loudly and is exactly why it is worth catching. retire is the answer:
|
||||
// a UID that has fallen out of the SEARCH SINCE window can never be fetched
|
||||
// again, so there is nothing left to wait for.
|
||||
type seenState struct {
|
||||
path string
|
||||
high uint32
|
||||
set map[uint32]bool
|
||||
dirty bool
|
||||
}
|
||||
|
||||
func newSeenState(path string) *seenState {
|
||||
return &seenState{path: path, set: map[uint32]bool{}}
|
||||
}
|
||||
|
||||
type seenFile struct {
|
||||
High uint32 `json:"high"`
|
||||
UIDs []uint32 `json:"uids,omitempty"`
|
||||
}
|
||||
|
||||
func (s *seenState) seen(uid uint32) bool {
|
||||
return uid <= s.high || s.set[uid]
|
||||
}
|
||||
|
||||
func (s *seenState) mark(uid uint32) {
|
||||
if s.seen(uid) {
|
||||
return
|
||||
}
|
||||
s.set[uid] = true
|
||||
s.dirty = true
|
||||
// Advance the high-water mark through any contiguous run, so the explicit set
|
||||
// stays small on a mailbox read in order.
|
||||
for {
|
||||
next := s.high + 1
|
||||
if !s.set[next] {
|
||||
break
|
||||
}
|
||||
delete(s.set, next)
|
||||
s.high = next
|
||||
}
|
||||
}
|
||||
|
||||
// retire records that no UID below floor is reachable any more — they have
|
||||
// aged out of the lookback window, so no poll will ever fetch them. The
|
||||
// high-water mark can jump past the gap they were holding open, and the
|
||||
// stragglers below it leave the explicit set.
|
||||
//
|
||||
// It never moves backwards, so a UIDVALIDITY reset (UIDs restarting low) makes
|
||||
// this a no-op rather than a way to un-see a mailbox.
|
||||
func (s *seenState) retire(floor uint32) {
|
||||
if floor == 0 || floor-1 <= s.high {
|
||||
return
|
||||
}
|
||||
s.high = floor - 1
|
||||
for u := range s.set {
|
||||
if u <= s.high {
|
||||
delete(s.set, u)
|
||||
}
|
||||
}
|
||||
// The run above the new mark may now be contiguous with it.
|
||||
for s.set[s.high+1] {
|
||||
delete(s.set, s.high+1)
|
||||
s.high++
|
||||
}
|
||||
s.dirty = true
|
||||
}
|
||||
|
||||
func (s *seenState) load() error {
|
||||
if s.path == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := os.ReadFile(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil // first run
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var f seenFile
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return fmt.Errorf("parse %s: %w", s.path, err)
|
||||
}
|
||||
s.high = f.High
|
||||
for _, u := range f.UIDs {
|
||||
s.set[u] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// save writes the state atomically (temp file + rename), 0600: it is a list of
|
||||
// message ids from his mailbox, which is metadata about his mail.
|
||||
func (s *seenState) save() error {
|
||||
if s.path == "" || !s.dirty {
|
||||
return nil
|
||||
}
|
||||
uids := make([]uint32, 0, len(s.set))
|
||||
for u := range s.set {
|
||||
uids = append(uids, u)
|
||||
}
|
||||
sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] })
|
||||
b, err := json.Marshal(seenFile{High: s.high, UIDs: uids})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.dirty = false
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/email"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// ---- a fake mailbox -------------------------------------------------------
|
||||
//
|
||||
// It fakes the READ, not the protocol: internal/email owns the IMAP tests, and
|
||||
// its dialer is unexported precisely so this package cannot substitute a
|
||||
// transport.
|
||||
|
||||
type fakeIMAP struct {
|
||||
msgs map[uint32]string
|
||||
uids []uint32
|
||||
cmds []string
|
||||
}
|
||||
|
||||
// fetch is the read seam the reader exposes: the daemon cannot reach
|
||||
// internal/email's dialer (it is unexported so nothing outside that package can
|
||||
// point the reader at a cleartext transport), so a test fakes the whole read.
|
||||
// The IMAP protocol itself is covered by internal/email's own tests.
|
||||
func (f *fakeIMAP) fetch(r *reader) func(string) ([]email.Message, error) {
|
||||
return func(string) ([]email.Message, error) {
|
||||
var out []email.Message
|
||||
var low uint32
|
||||
for _, uid := range f.uids {
|
||||
if low == 0 || uid < low {
|
||||
low = uid
|
||||
}
|
||||
}
|
||||
if low > 0 {
|
||||
r.state.retire(low)
|
||||
}
|
||||
for i := len(f.uids) - 1; i >= 0; i-- {
|
||||
uid := f.uids[i]
|
||||
if r.state.seen(uid) {
|
||||
continue
|
||||
}
|
||||
raw, ok := f.msgs[uid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
f.cmds = append(f.cmds, fmt.Sprintf("UID FETCH %d", uid))
|
||||
msg, err := email.ParseMessage(uid, []byte(raw))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, msg)
|
||||
if r.max > 0 && len(out) >= r.max {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ---- a fake core -----------------------------------------------------------
|
||||
|
||||
type fakeCore struct {
|
||||
got []ipc.IngestMailReq
|
||||
resp ipc.IngestMailResp
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *fakeCore) IngestMail(_ context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
|
||||
c.got = append(c.got, req)
|
||||
if c.err != nil {
|
||||
return ipc.IngestMailResp{}, c.err
|
||||
}
|
||||
return c.resp, nil
|
||||
}
|
||||
|
||||
func mail(subject, body string, extraHeaders ...string) string {
|
||||
h := "Subject: " + subject + "\r\nFrom: a@b.c\r\nContent-Type: text/plain; charset=utf-8\r\n"
|
||||
for _, e := range extraHeaders {
|
||||
h += e + "\r\n"
|
||||
}
|
||||
return h + "\r\n" + body + "\r\n"
|
||||
}
|
||||
|
||||
func newTestReader(t *testing.T, f *fakeIMAP, core *fakeCore, statePath string) *reader {
|
||||
t.Helper()
|
||||
r := &reader{
|
||||
core: core, addr: "mail.example:993", user: "kami", mailbox: "INBOX",
|
||||
lookback: 72 * time.Hour, max: 25, timeout: 5 * time.Second,
|
||||
state: newSeenState(statePath),
|
||||
}
|
||||
r.fetchMail = f.fetch(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestPollHandsMessagesToCore(t *testing.T) {
|
||||
f := &fakeIMAP{
|
||||
uids: []uint32{1, 2},
|
||||
msgs: map[uint32]string{
|
||||
1: mail("Счёт", "Оплатить до 5 августа."),
|
||||
2: mail("Скидки", "Sale!", "List-Unsubscribe: <mailto:u@x>"),
|
||||
},
|
||||
}
|
||||
core := &fakeCore{resp: ipc.IngestMailResp{TaskIDs: []int64{1}, Created: 1}}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
|
||||
// Two calls: the real mail with its text, and the newsletter as a verdict
|
||||
// with no content at all. Core is told about bulk rather than asked, so it
|
||||
// can count it without spending the model.
|
||||
if len(core.got) != 2 {
|
||||
t.Fatalf("core saw %d messages, want 2: %+v", len(core.got), core.got)
|
||||
}
|
||||
var got, bulk ipc.IngestMailReq
|
||||
for _, r := range core.got {
|
||||
if r.Junk {
|
||||
bulk = r
|
||||
} else {
|
||||
got = r
|
||||
}
|
||||
}
|
||||
if bulk.UID != 2 || !bulk.Junk {
|
||||
t.Errorf("bulk req = %+v, want uid 2 flagged junk", bulk)
|
||||
}
|
||||
if bulk.Subject != "" || bulk.Body != "" || bulk.From != "" {
|
||||
t.Errorf("a bulk verdict must carry no mail content: %+v", bulk)
|
||||
}
|
||||
if got.UID != 1 || got.Mailbox != "INBOX" || got.Subject != "Счёт" {
|
||||
t.Errorf("ingest req = %+v", got)
|
||||
}
|
||||
if !strings.Contains(got.Body, "Оплатить") {
|
||||
t.Errorf("body = %q", got.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// A second poll must not re-send what core already saw — extraction is a
|
||||
// multi-second LLM call per message.
|
||||
func TestPollSkipsSeenUIDs(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
|
||||
core := &fakeCore{}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
if len(core.got) != 1 {
|
||||
t.Errorf("core saw %d messages over two polls, want 1", len(core.got))
|
||||
}
|
||||
}
|
||||
|
||||
// An ingest that failed is NOT marked seen: the next poll retries it.
|
||||
func TestPollRetriesFailedIngest(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
|
||||
core := &fakeCore{err: fmt.Errorf("llama-server is warming up")}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
core.err = nil
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
if len(core.got) != 2 {
|
||||
t.Errorf("core saw %d attempts, want 2 (a failed ingest is retried)", len(core.got))
|
||||
}
|
||||
}
|
||||
|
||||
// Core without an email block ⇒ stop, don't hammer the socket.
|
||||
func TestPollStopsWhenCoreRefusesMail(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{1, 2}, msgs: map[uint32]string{1: mail("a", "b"), 2: mail("c", "d")}}
|
||||
core := &fakeCore{err: fmt.Errorf("call: %w", ipc.ErrUnknownMethod)}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
if !r.disabled {
|
||||
t.Error("ErrUnknownMethod must disable the reader")
|
||||
}
|
||||
if len(core.got) != 1 {
|
||||
t.Errorf("core saw %d messages, want 1 — stop at the first refusal", len(core.got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeenStatePersists(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "state", "seen.json")
|
||||
f := &fakeIMAP{uids: []uint32{9}, msgs: map[uint32]string{9: mail("Счёт", "текст")}}
|
||||
core := &fakeCore{}
|
||||
r := newTestReader(t, f, core, path)
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("state file: %v", err)
|
||||
}
|
||||
// A list of message ids from his mailbox is metadata about his mail.
|
||||
if perm := fi.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("state file mode = %v, want 0600", perm)
|
||||
}
|
||||
|
||||
// A fresh reader with the same state file must not re-read the message.
|
||||
core2 := &fakeCore{}
|
||||
r2 := newTestReader(t, f, core2, path)
|
||||
if err := r2.state.load(); err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
r2.pollOnce(context.Background(), "secret")
|
||||
if len(core2.got) != 0 {
|
||||
t.Errorf("after a restart core saw %d messages, want 0", len(core2.got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeenStateHighWaterMark(t *testing.T) {
|
||||
s := newSeenState("")
|
||||
s.mark(1)
|
||||
s.mark(3)
|
||||
s.mark(2)
|
||||
if s.high != 3 {
|
||||
t.Errorf("high = %d, want 3 (contiguous run collapses)", s.high)
|
||||
}
|
||||
if len(s.set) != 0 {
|
||||
t.Errorf("explicit set = %v, want empty", s.set)
|
||||
}
|
||||
if !s.seen(2) || s.seen(4) {
|
||||
t.Errorf("seen(2)=%v seen(4)=%v", s.seen(2), s.seen(4))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeenStateCorruptFileIsNotFatal(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "seen.json")
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := newSeenState(path)
|
||||
if err := s.load(); err == nil {
|
||||
t.Error("a corrupt state file should report an error the caller logs")
|
||||
}
|
||||
if s.seen(1) {
|
||||
t.Error("a corrupt state file must leave an empty seen-set, not a poisoned one")
|
||||
}
|
||||
}
|
||||
|
||||
// Off unless configured, and the credential is never a flag value.
|
||||
func TestRunRequiresConfig(t *testing.T) {
|
||||
if err := run([]string{}); err == nil {
|
||||
t.Error("no -socket must be an error")
|
||||
}
|
||||
if err := run([]string{"-socket", "/tmp/nope.sock"}); err == nil {
|
||||
t.Error("no mailbox configuration must be an error, not a default mailbox")
|
||||
}
|
||||
// There is no -password flag at all: only -password-file.
|
||||
if err := run([]string{"-socket", "/x", "-imap", "h", "-user", "u", "-password", "p"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "flag provided but not defined") {
|
||||
t.Errorf("a -password flag must not exist; err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRejectsEmptyPasswordFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pass")
|
||||
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := run([]string{"-socket", "/x/y.sock", "-imap", "h", "-user", "u", "-password-file", path})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("an empty password file must be refused before dialling; err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A UID that never ingests pinned the high-water mark forever, because the mark
|
||||
// only advances through a contiguous run. Once that UID falls out of the
|
||||
// lookback window it can never be fetched again, so there is nothing left to
|
||||
// wait for and everything above it can leave the explicit set.
|
||||
func TestSeenStateRetiresAgedOutUIDs(t *testing.T) {
|
||||
s := newSeenState("")
|
||||
s.mark(1000) // 999 failed and was deliberately not marked
|
||||
s.mark(1001)
|
||||
if s.high != 0 || len(s.set) != 2 {
|
||||
t.Fatalf("high = %d, set = %v; want the mark pinned below the gap", s.high, s.set)
|
||||
}
|
||||
// The next SEARCH window starts at 1000: 999 has aged out.
|
||||
s.retire(1000)
|
||||
if s.high != 1001 {
|
||||
t.Errorf("high = %d, want 1001 once the gap is unreachable", s.high)
|
||||
}
|
||||
if len(s.set) != 0 {
|
||||
t.Errorf("explicit set = %v, want empty", s.set)
|
||||
}
|
||||
if !s.seen(999) || !s.seen(1001) || s.seen(1002) {
|
||||
t.Errorf("seen(999)=%v seen(1001)=%v seen(1002)=%v", s.seen(999), s.seen(1001), s.seen(1002))
|
||||
}
|
||||
}
|
||||
|
||||
// retire never moves the mark backwards: a UIDVALIDITY reset restarts UIDs low,
|
||||
// and that must not un-see a mailbox or re-see one.
|
||||
func TestSeenStateRetireNeverGoesBackwards(t *testing.T) {
|
||||
s := newSeenState("")
|
||||
s.mark(1)
|
||||
s.mark(2)
|
||||
s.retire(1)
|
||||
if s.high != 2 {
|
||||
t.Errorf("high = %d, want 2 unchanged", s.high)
|
||||
}
|
||||
}
|
||||
|
||||
// A poll must not leave the state file growing with UIDs that are already
|
||||
// covered by the high-water mark.
|
||||
func TestPollRetiresThroughTheSearchWindow(t *testing.T) {
|
||||
f := &fakeIMAP{uids: []uint32{100, 101}, msgs: map[uint32]string{100: mail("a", "b"), 101: mail("c", "d")}}
|
||||
core := &fakeCore{}
|
||||
r := newTestReader(t, f, core, "")
|
||||
r.pollOnce(context.Background(), "secret")
|
||||
if r.state.high != 101 {
|
||||
t.Errorf("high = %d, want 101 — everything below the search window is unreachable", r.state.high)
|
||||
}
|
||||
if len(r.state.set) != 0 {
|
||||
t.Errorf("explicit set = %v, want empty", r.state.set)
|
||||
}
|
||||
}
|
||||
+155
-16
@@ -9,6 +9,12 @@
|
||||
// Two sources, each its own provenance (the loop's rules trust source):
|
||||
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
||||
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
|
||||
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
|
||||
//
|
||||
// The zenmoney source is why the token lives HERE and not in core: the poller
|
||||
// already owns every other third-party credential, it holds no store key, and
|
||||
// core never needs to know an account exists to answer a question about a fact
|
||||
// the poller wrote. It is off unless -zenmoney-token-file is given.
|
||||
//
|
||||
// Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API
|
||||
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
|
||||
@@ -22,6 +28,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -37,6 +44,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -52,6 +60,9 @@ func run(args []string) error {
|
||||
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
|
||||
kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)")
|
||||
kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)")
|
||||
zenTokenFile := fs.String("zenmoney-token-file", "", "file holding the zenmoney API token ('' disables money tracking)")
|
||||
zenURL := fs.String("zenmoney-url", zenmoney.DefaultBaseURL, "zenmoney API base URL (tests/self-hosted proxies)")
|
||||
zenInterval := fs.Duration("zenmoney-interval", time.Hour, "how often to read zenmoney (money does not move every minute)")
|
||||
wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)")
|
||||
wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)")
|
||||
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
|
||||
@@ -62,8 +73,24 @@ func run(args []string) error {
|
||||
if *socket == "" {
|
||||
return fmt.Errorf("-socket is required")
|
||||
}
|
||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" {
|
||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma and/or -wg")
|
||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
|
||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
|
||||
}
|
||||
|
||||
// The token is read from a file, never taken as a flag value: an argv token
|
||||
// is visible in `ps` to every user on the box and lands in the compose file
|
||||
// and the shell history. Read once at start — a rotated token means a
|
||||
// restart, which is cheaper than re-reading his credential every hour.
|
||||
var zen *zenmoney.Client
|
||||
if *zenTokenFile != "" {
|
||||
raw, err := os.ReadFile(*zenTokenFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read zenmoney token: %w", err)
|
||||
}
|
||||
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
@@ -83,9 +110,13 @@ func run(args []string) error {
|
||||
kumaKey: *kumaKey,
|
||||
wgIface: *wgIface,
|
||||
wgCmd: *wgCmd,
|
||||
zen: zen,
|
||||
zenEvery: *zenInterval,
|
||||
}
|
||||
|
||||
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q)", *interval, *netdataURL, *kumaURL, *wgIface)
|
||||
// The token is never logged, not even its length.
|
||||
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
|
||||
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
|
||||
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
|
||||
t := time.NewTicker(*interval)
|
||||
defer t.Stop()
|
||||
@@ -108,6 +139,12 @@ type poller struct {
|
||||
kumaKey string
|
||||
wgIface string
|
||||
wgCmd string
|
||||
|
||||
// zen is nil unless a token file was configured — money tracking is a
|
||||
// capability, off by default like weather and telegram.
|
||||
zen *zenmoney.Client
|
||||
zenEvery time.Duration
|
||||
zenLast time.Time
|
||||
}
|
||||
|
||||
// pollOnce — one sweep of both sources. A failure in one source logs and does
|
||||
@@ -129,6 +166,76 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
log.Printf("mavpoll: wg: %v", err)
|
||||
}
|
||||
}
|
||||
// Money on its own, much slower cadence: a bank feed that updates hourly
|
||||
// polled every minute is 60 pointless reads of his financial history.
|
||||
if p.zen != nil && now.Sub(p.zenLast) >= p.zenEvery {
|
||||
p.zenLast = now
|
||||
if err := p.pollZenmoney(ctx, now); err != nil {
|
||||
log.Printf("mavpoll: zenmoney: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- zenmoney: spending/income totals → money facts ------------------------
|
||||
|
||||
// pollZenmoney reads today's and this month's totals and writes them as
|
||||
// facts(kind=env, source=poll:zenmoney) (Vikunja #125).
|
||||
//
|
||||
// Two properties this function exists to hold:
|
||||
//
|
||||
// - An empty or failed read writes NOTHING. zenmoney.Summary.Value() refuses
|
||||
// to encode a summary built from zero transactions, so a poller that cannot
|
||||
// reach the API leaves the last good fact in place rather than overwriting
|
||||
// it with a zero Maven would then recite as fact.
|
||||
// - Nothing about the money leaves the box except the diff request itself, to
|
||||
// the service that already holds his bank sessions. The totals are written
|
||||
// to the store and read back only when he asks; they are never search input
|
||||
// and no tick rule fires on them.
|
||||
//
|
||||
// Both windows are read from one diff call each. Two calls an hour against an
|
||||
// API whose whole job is this is not worth caching.
|
||||
//
|
||||
// The write is UNCONDITIONAL, unlike every other poll in this file. The
|
||||
// value-dedupe in writeIfChangedRaw only advances ts when the number moves, and
|
||||
// for money that made ts mean "last changed" while the reader was asking it "as
|
||||
// of when". A quiet 27 hours had core prefixing "данные от 30.07" to a figure
|
||||
// that was current. The value now carries its own read stamp, so it differs
|
||||
// every poll anyway and there is nothing left for the dedupe to catch.
|
||||
|
||||
// moneyWindow — one fact key and the period it covers.
|
||||
type moneyWindow struct {
|
||||
key string
|
||||
from, to time.Time
|
||||
}
|
||||
|
||||
func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||
dFrom, dTo := zenmoney.DayWindow(now)
|
||||
mFrom, mTo := zenmoney.MonthWindow(now)
|
||||
windows := []moneyWindow{
|
||||
{zenmoney.KeySpentToday, dFrom, dTo},
|
||||
{zenmoney.KeySpentMonth, mFrom, mTo},
|
||||
}
|
||||
var firstErr error
|
||||
for _, w := range windows {
|
||||
sum, err := p.zen.Since(ctx, w.from, w.to)
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
val, ok := sum.Value(now)
|
||||
if !ok {
|
||||
// Nothing read. Silence, not a zero. The last good fact stays, and
|
||||
// the window stamp inside it is what stops core reciting yesterday's
|
||||
// day total as today's after midnight.
|
||||
continue
|
||||
}
|
||||
if err := p.writeMoneyFact(ctx, w.key, val, now); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// ---- wireguard: latest handshake → presence signal -------------------------
|
||||
@@ -301,20 +408,52 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
||||
// errors.Is is the right check; keep a helper so the switch above reads clean.
|
||||
func isNoFact(err error) bool {
|
||||
for e := err; e != nil; {
|
||||
if e == ipc.ErrNoFact {
|
||||
return true
|
||||
}
|
||||
u, ok := e.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
e = u.Unwrap()
|
||||
// writeIfChangedRaw is writeIfChanged for values that are already JSON (the
|
||||
// money facts store an object, not a string). Kept separate rather than
|
||||
// generalising writeIfChanged, because the string-valued env facts encoding
|
||||
// their own value is the convention the rules rely on.
|
||||
//
|
||||
// The log line names the key and the source, never the figures: mavpoll's log
|
||||
// is not the place his spending ends up.
|
||||
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == jsonVal:
|
||||
return nil
|
||||
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
|
||||
return fmt.Errorf("read %s: %w", key, err)
|
||||
}
|
||||
return false
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||
Source: source, Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
}
|
||||
log.Printf("mavpoll: %s updated (%s)", key, source)
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeMoneyFact writes a money fact every poll, with no value comparison. See
|
||||
// the comment above pollZenmoney for why this one does not go through
|
||||
// writeIfChangedRaw.
|
||||
//
|
||||
// The log line names the key only, never the figures: mavpoll's log is not the
|
||||
// place his spending ends up.
|
||||
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||
Source: zenmoney.Source, Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
}
|
||||
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
|
||||
return nil
|
||||
}
|
||||
|
||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
||||
// errors.Is is the right check.
|
||||
func isNoFact(err error) bool {
|
||||
return errors.Is(err, ipc.ErrNoFact)
|
||||
}
|
||||
|
||||
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/zenmoney"
|
||||
)
|
||||
|
||||
func TestMaxSeverity(t *testing.T) {
|
||||
@@ -62,3 +71,120 @@ func TestParseMaxHandshake(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- zenmoney (Vikunja #125) ----------------------------------------------
|
||||
|
||||
// factCore records the facts the poller wrote and answers "no fact yet".
|
||||
type factCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
written []ipc.WriteFactReq
|
||||
prev map[string]string
|
||||
}
|
||||
|
||||
func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||
if v, ok := c.prev[key+"|"+source]; ok {
|
||||
return ipc.Fact{Key: key, Source: source, Value: v}, nil
|
||||
}
|
||||
return ipc.Fact{}, ipc.ErrNoFact
|
||||
}
|
||||
|
||||
func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
c.written = append(c.written, req)
|
||||
return int64(len(c.written)), nil
|
||||
}
|
||||
|
||||
func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
return
|
||||
}
|
||||
w.Write(body)
|
||||
}))
|
||||
}
|
||||
|
||||
func TestPollZenmoneyWritesMoneyFacts(t *testing.T) {
|
||||
body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := zenFixtureServer(t, body, http.StatusOK)
|
||||
defer srv.Close()
|
||||
zen, err := zenmoney.New("tok", srv.URL, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
core := &factCore{}
|
||||
p := &poller{core: core, zen: zen}
|
||||
now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC)
|
||||
if err := p.pollZenmoney(context.Background(), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(core.written) != 2 {
|
||||
t.Fatalf("wrote %d facts, want today + month", len(core.written))
|
||||
}
|
||||
for _, f := range core.written {
|
||||
if f.Kind != "env" || f.Source != zenmoney.Source {
|
||||
t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source)
|
||||
}
|
||||
if _, err := zenmoney.ParseFactValue(f.Value); err != nil {
|
||||
t.Errorf("fact value %q does not decode: %v", f.Value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A read that returns nothing for the window writes NOTHING. Silence, not a
|
||||
// zero: an invented 0 would be recited back to him as fact.
|
||||
func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) {
|
||||
srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK)
|
||||
defer srv.Close()
|
||||
zen, _ := zenmoney.New("tok", srv.URL, time.Second)
|
||||
core := &factCore{}
|
||||
p := &poller{core: core, zen: zen}
|
||||
if err := p.pollZenmoney(context.Background(), time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(core.written) != 0 {
|
||||
t.Errorf("wrote %+v, want no fact at all", core.written)
|
||||
}
|
||||
}
|
||||
|
||||
// An API failure must not overwrite the last good total either.
|
||||
func TestPollZenmoneyFailureWritesNothing(t *testing.T) {
|
||||
srv := zenFixtureServer(t, nil, http.StatusUnauthorized)
|
||||
defer srv.Close()
|
||||
zen, _ := zenmoney.New("bad", srv.URL, time.Second)
|
||||
core := &factCore{}
|
||||
p := &poller{core: core, zen: zen}
|
||||
if err := p.pollZenmoney(context.Background(), time.Now()); err == nil {
|
||||
t.Error("want the 401 reported")
|
||||
}
|
||||
if len(core.written) != 0 {
|
||||
t.Errorf("wrote %+v on a failed read", core.written)
|
||||
}
|
||||
}
|
||||
|
||||
// Unchanged totals do not churn the facts table.
|
||||
func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) {
|
||||
core := &factCore{prev: map[string]string{
|
||||
zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`,
|
||||
}}
|
||||
p := &poller{core: core}
|
||||
if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(core.written) != 0 {
|
||||
t.Errorf("wrote %+v for an unchanged value", core.written)
|
||||
}
|
||||
}
|
||||
|
||||
// Money tracking is off unless configured: no token file, no zenmoney client,
|
||||
// and the poller still refuses to start with nothing at all to poll.
|
||||
func TestRunRequiresSomethingToPoll(t *testing.T) {
|
||||
err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""})
|
||||
if err == nil || !strings.Contains(err.Error(), "nothing to poll") {
|
||||
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
package main
|
||||
|
||||
// Golden-audio STT tests (Vikunja #288).
|
||||
//
|
||||
// These push real audio through the real whisper.cpp binding. What that
|
||||
// covers, precisely, is two things: the model still transcribes known speech
|
||||
// well enough for the router to act on it, and the silence gate still lets real
|
||||
// speech through. A regression in either shows up in `make test` rather than in
|
||||
// the owner talking to a daemon that mishears him.
|
||||
//
|
||||
// It is worth being exact about what is NOT covered, because this comment used
|
||||
// to claim more. Nothing here resamples: audio.PCMFromWAV refuses anything that
|
||||
// is not 16 kHz mono s16, the fixtures arrive at 16 kHz from ffmpeg, and there
|
||||
// is no conversion step between the WAV and whisper_full. Nothing here
|
||||
// exercises language selection either: the hint comes out of the manifest
|
||||
// already correct and goes straight into the request, so how mavsttd chooses a
|
||||
// language is untested. And a wrong model path is not caught when it is the
|
||||
// default one, because a box without the model skips; an explicitly set
|
||||
// MAVEN_WHISPER_MODEL that does not exist is a failure, since that is a
|
||||
// mistake and not an absence.
|
||||
//
|
||||
// The fixtures are piper-synthesised, not recorded — see
|
||||
// scripts/gen-stt-fixtures.sh. Nothing of the owner's voice is committed, and
|
||||
// any fixture can be rebuilt from the script plus a voice model.
|
||||
//
|
||||
// Matching is deliberately tolerant. Golden transcripts are model-dependent:
|
||||
// swapping ggml-small for a different whisper build moves punctuation, casing
|
||||
// and the odd word ending, and an exact-string assertion would turn every
|
||||
// model swap into a fixture rewrite. Each case therefore asserts two things —
|
||||
// the words that carry the intent are present, and the word error rate
|
||||
// against the reference stays under a per-case ceiling.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/worker"
|
||||
)
|
||||
|
||||
// goldenModelPath — the whisper model the golden tests run against. Same file
|
||||
// the Makefile's run-stt target uses. Overridable so a box that keeps its
|
||||
// models elsewhere can still run these.
|
||||
func goldenModelPath() string {
|
||||
if p := os.Getenv("MAVEN_WHISPER_MODEL"); p != "" {
|
||||
return p
|
||||
}
|
||||
return filepath.Join("..", "..", "models", "stt", "ggml-small.bin")
|
||||
}
|
||||
|
||||
type goldenCase struct {
|
||||
Name string `json:"name"`
|
||||
WAV string `json:"wav"`
|
||||
Lang string `json:"lang"`
|
||||
Text string `json:"text"`
|
||||
Keywords []string `json:"keywords"`
|
||||
// MeasuredWER is what this case scored when the ceiling was last set, so
|
||||
// a model swap is a diff to a recorded number rather than silence.
|
||||
MeasuredWER float64 `json:"measured_wer"`
|
||||
MaxWER float64 `json:"max_wer"`
|
||||
}
|
||||
|
||||
type goldenManifest struct {
|
||||
Cases []goldenCase `json:"cases"`
|
||||
}
|
||||
|
||||
func loadGoldenManifest(t *testing.T) goldenManifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "golden_v1.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("read golden manifest: %v", err)
|
||||
}
|
||||
var m goldenManifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("parse golden manifest: %v", err)
|
||||
}
|
||||
if len(m.Cases) == 0 {
|
||||
t.Fatal("golden manifest has no cases")
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// normalizeTranscript lowercases, drops punctuation, folds the Russian ё onto
|
||||
// е (whisper is inconsistent about it and the router does not care), and
|
||||
// collapses whitespace. Everything the comparison does happens on this form.
|
||||
func normalizeTranscript(s string) []string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r == 'ё':
|
||||
b.WriteRune('е')
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
return strings.Fields(b.String())
|
||||
}
|
||||
|
||||
// wordErrorRate is the Levenshtein distance between two word sequences,
|
||||
// divided by the length of the reference. 0 means identical; it can exceed 1
|
||||
// when the hypothesis is much longer than the reference.
|
||||
func wordErrorRate(ref, hyp []string) float64 {
|
||||
if len(ref) == 0 {
|
||||
if len(hyp) == 0 {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
prev := make([]int, len(hyp)+1)
|
||||
cur := make([]int, len(hyp)+1)
|
||||
for j := range prev {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= len(ref); i++ {
|
||||
cur[0] = i
|
||||
for j := 1; j <= len(hyp); j++ {
|
||||
cost := 1
|
||||
if ref[i-1] == hyp[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
cur[j] = min(prev[j]+1, min(cur[j-1]+1, prev[j-1]+cost))
|
||||
}
|
||||
prev, cur = cur, prev
|
||||
}
|
||||
return float64(prev[len(hyp)]) / float64(len(ref))
|
||||
}
|
||||
|
||||
// missingKeywords returns the keywords absent from the hypothesis. A keyword
|
||||
// matches on prefix, so a different case ending ("воды" vs "воду") does not
|
||||
// fail the assertion — the router's stage-0 grammar is stem-shaped too.
|
||||
func missingKeywords(keywords []string, hyp []string) []string {
|
||||
var missing []string
|
||||
for _, kw := range keywords {
|
||||
want := normalizeTranscript(kw)
|
||||
if len(want) == 0 {
|
||||
continue
|
||||
}
|
||||
if !containsSeq(hyp, want) {
|
||||
missing = append(missing, kw)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func containsSeq(hyp, want []string) bool {
|
||||
for i := 0; i+len(want) <= len(hyp); i++ {
|
||||
ok := true
|
||||
for j, w := range want {
|
||||
// Prefix match, so inflection differences pass but
|
||||
// distinct words do not.
|
||||
if !looseWordMatch(hyp[i+j], w) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// looseWordMatch reports whether got is want, or an inflection of it.
|
||||
//
|
||||
// A shared prefix alone is not enough. "воды" retains three runes, so "водка"
|
||||
// used to satisfy the ru_fact keyword and the test passed on whisper hearing
|
||||
// "выпил водки". "disk" retains "dis", which "display", "distance" and
|
||||
// "discuss" all match. So the hypothesis is also capped in length: a case
|
||||
// ending adds a rune or two, it does not add a syllable. Short words get no
|
||||
// slack at all, because there is nothing left of them after a prefix cut.
|
||||
func looseWordMatch(got, want string) bool {
|
||||
if got == want {
|
||||
return true
|
||||
}
|
||||
g, w := []rune(got), []rune(want)
|
||||
n := len(w) - 1
|
||||
if len(w) > 6 {
|
||||
n = len(w) - 2
|
||||
}
|
||||
// Words of three runes or fewer have no room for a safe prefix: require
|
||||
// an exact match rather than letting "час" pass for "часть".
|
||||
if n < 3 || len(g) < n {
|
||||
return false
|
||||
}
|
||||
extra := 2
|
||||
if len(w) <= 4 {
|
||||
extra = 0
|
||||
}
|
||||
if len(g) > len(w)+extra {
|
||||
return false
|
||||
}
|
||||
return string(g[:n]) == string(w[:n])
|
||||
}
|
||||
|
||||
// --- the model-backed test -------------------------------------------------
|
||||
|
||||
func TestGoldenAudioTranscription(t *testing.T) {
|
||||
m := loadGoldenManifest(t)
|
||||
|
||||
model := goldenModelPath()
|
||||
if _, err := os.Stat(model); err != nil {
|
||||
// An explicit override that points at nothing is a mistake, not a box
|
||||
// without the model. Skipping there made a typo look like a pass.
|
||||
if os.Getenv("MAVEN_WHISPER_MODEL") != "" {
|
||||
t.Fatalf("MAVEN_WHISPER_MODEL=%s does not exist: %v", model, err)
|
||||
}
|
||||
t.Skipf("whisper model %s absent (%v) — set MAVEN_WHISPER_MODEL or see AGENTS.md", model, err)
|
||||
}
|
||||
|
||||
// Same gate thresholds as mavsttd's defaults, so a regression in the
|
||||
// silence gate shows up here as an empty transcript.
|
||||
h, err := newWhisperHandler(model, 300, 0.01)
|
||||
if err != nil {
|
||||
t.Fatalf("load whisper model %s: %v", model, err)
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
for _, c := range m.Cases {
|
||||
t.Run(c.Name, func(t *testing.T) {
|
||||
path := filepath.Join("testdata", c.WAV)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// Not a skip. A fixture the generator failed to write is a
|
||||
// broken checkout, and skipping made `make test` green on one.
|
||||
t.Fatalf("fixture %s absent (%v) — run scripts/gen-stt-fixtures.sh", path, err)
|
||||
}
|
||||
format, pcm, err := audio.PCMFromWAV(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("%s is not canonical 16k mono PCM: %v", path, err)
|
||||
}
|
||||
|
||||
resp, err := h.Transcribe(context.Background(), worker.TranscribeReq{
|
||||
Audio: audio.Audio{Format: format, Bytes: pcm},
|
||||
Lang: c.Lang,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("transcribe %s: %v", c.WAV, err)
|
||||
}
|
||||
t.Logf("%s → %q (confidence %.3f)", c.WAV, resp.Text, resp.Confidence)
|
||||
|
||||
if strings.TrimSpace(resp.Text) == "" {
|
||||
t.Fatalf("%s transcribed to empty text — the silence gate ate real speech", c.WAV)
|
||||
}
|
||||
if resp.Confidence <= 0 {
|
||||
t.Errorf("%s: confidence %v, want > 0", c.WAV, resp.Confidence)
|
||||
}
|
||||
|
||||
hyp := normalizeTranscript(resp.Text)
|
||||
ref := normalizeTranscript(c.Text)
|
||||
|
||||
if missing := missingKeywords(c.Keywords, hyp); len(missing) > 0 {
|
||||
t.Errorf("%s: missing keywords %v in %q", c.WAV, missing, resp.Text)
|
||||
}
|
||||
wer := wordErrorRate(ref, hyp)
|
||||
if wer > c.MaxWER {
|
||||
t.Errorf("%s: WER %.2f > %.2f (measured %.2f when the ceiling was set)\n want: %q\n got: %q",
|
||||
c.WAV, wer, c.MaxWER, c.MeasuredWER, c.Text, resp.Text)
|
||||
}
|
||||
t.Logf("%s: WER %.2f (ceiling %.2f, was %.2f)", c.WAV, wer, c.MaxWER, c.MeasuredWER)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGoldenFixturesAreCanonical checks the committed audio without needing a
|
||||
// model, so a fixture regenerated at the wrong sample rate fails on every box.
|
||||
func TestGoldenFixturesAreCanonical(t *testing.T) {
|
||||
m := loadGoldenManifest(t)
|
||||
for _, c := range m.Cases {
|
||||
path := filepath.Join("testdata", c.WAV)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Errorf("fixture %s missing: %v", path, err)
|
||||
continue
|
||||
}
|
||||
format, pcm, err := audio.PCMFromWAV(raw)
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", path, err)
|
||||
continue
|
||||
}
|
||||
if !format.IsValid() {
|
||||
t.Errorf("%s: format %+v is not canonical", path, format)
|
||||
}
|
||||
a := audio.Audio{Format: format, Bytes: pcm}
|
||||
if d := a.Duration(); d < 0.5 || d > 10 {
|
||||
t.Errorf("%s: duration %.2fs outside the sane 0.5–10s fixture range", path, d)
|
||||
}
|
||||
// The fixture must clear mavsttd's own silence gate, otherwise the
|
||||
// model test below would be asserting on a gated empty string.
|
||||
if reason := gateReason(pcmSamples(pcm), whisperSampleRate, 300, 0.01); reason != "" {
|
||||
t.Errorf("%s: would be gated as %s", path, reason)
|
||||
}
|
||||
if len(c.Keywords) == 0 {
|
||||
t.Errorf("%s: manifest case has no keywords", c.Name)
|
||||
}
|
||||
// An empty reference makes wordErrorRate return 1 for every
|
||||
// hypothesis, so the WER assertion fires with nothing useful to say.
|
||||
if len(normalizeTranscript(c.Text)) == 0 {
|
||||
t.Errorf("%s: manifest case has no reference text", c.Name)
|
||||
}
|
||||
if c.Lang != "ru" && c.Lang != "en" {
|
||||
t.Errorf("%s: lang %q is not one of the two languages mavsttd is run with", c.Name, c.Lang)
|
||||
}
|
||||
if c.MaxWER <= 0 || c.MaxWER > 1 {
|
||||
t.Errorf("%s: max_wer %v outside (0,1]", c.Name, c.MaxWER)
|
||||
}
|
||||
if c.MeasuredWER > c.MaxWER {
|
||||
t.Errorf("%s: measured_wer %v is above max_wer %v, so the ceiling was never met", c.Name, c.MeasuredWER, c.MaxWER)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- matcher unit tests (no model, no fixtures) ----------------------------
|
||||
|
||||
func TestNormalizeTranscript(t *testing.T) {
|
||||
got := normalizeTranscript(" Ещё, Раз... ")
|
||||
want := []string{"еще", "раз"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("normalizeTranscript = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordErrorRate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ref, hyp string
|
||||
want float64
|
||||
}{
|
||||
{"identical", "напомни мне через час", "Напомни мне через час.", 0},
|
||||
{"one substitution", "напомни мне через час", "напомни мне через день", 0.25},
|
||||
{"one deletion", "напомни мне через час", "напомни мне час", 0.25},
|
||||
{"empty hypothesis", "напомни мне", "", 1},
|
||||
{"both empty", "", "", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := wordErrorRate(normalizeTranscript(c.ref), normalizeTranscript(c.hyp))
|
||||
if got != c.want {
|
||||
t.Fatalf("WER = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingKeywords(t *testing.T) {
|
||||
hyp := normalizeTranscript("Отметь, что я выпил воду.")
|
||||
if got := missingKeywords([]string{"воды", "отметь"}, hyp); len(got) != 0 {
|
||||
t.Fatalf("missingKeywords = %v, want none (inflection must not fail the match)", got)
|
||||
}
|
||||
if got := missingKeywords([]string{"календарю"}, hyp); len(got) != 1 {
|
||||
t.Fatalf("missingKeywords = %v, want the absent keyword reported", got)
|
||||
}
|
||||
// A short word must match exactly — no 4-rune prefix shortcut that would
|
||||
// let "час" pass for "часть".
|
||||
hyp2 := normalizeTranscript("через час")
|
||||
if got := missingKeywords([]string{"часть"}, hyp2); len(got) != 1 {
|
||||
t.Fatalf("missingKeywords = %v, want %q reported missing", got, "часть")
|
||||
}
|
||||
// A prefix is not a word. These are different words that share one, and
|
||||
// each of them used to satisfy the keyword it is paired with.
|
||||
different := [][2]string{
|
||||
{"воды", "Я выпил водки."},
|
||||
{"disk", "check the display"},
|
||||
{"disk", "we should discuss it"},
|
||||
{"server", "a serverless function"},
|
||||
}
|
||||
for _, d := range different {
|
||||
if got := missingKeywords([]string{d[0]}, normalizeTranscript(d[1])); len(got) != 1 {
|
||||
t.Errorf("keyword %q was satisfied by %q", d[0], d[1])
|
||||
}
|
||||
}
|
||||
// And the inflections still pass, which is the whole point of the loose
|
||||
// match.
|
||||
same := [][2]string{
|
||||
{"воды", "выпил воду"},
|
||||
{"напомни", "напомните мне"},
|
||||
{"календарю", "по календаре"},
|
||||
{"restart", "restarted the server"},
|
||||
}
|
||||
for _, d := range same {
|
||||
if got := missingKeywords([]string{d[0]}, normalizeTranscript(d[1])); len(got) != 0 {
|
||||
t.Errorf("keyword %q was not matched by %q", d[0], d[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"note": "Golden STT fixtures. Audio is piper-synthesised, not recorded — see scripts/gen-stt-fixtures.sh, which reads `text` from this file and synthesises from it. This is the only source of the spoken words; regenerate with that script and do not hand-edit `wav`.",
|
||||
"wer_note": "max_wer is set just above what each case actually measures against ggml-small, recorded in `measured_wer` on 2026-08-01. A flat 0.34 over a five-word reference tolerated two wrong words and left most of the range unguarded. A model swap should show up as a diff to these numbers, not as silence: rerun `make test-stt-golden`, read the logged transcript, and move both fields together.",
|
||||
"cases": [
|
||||
{
|
||||
"name": "ru_reminder",
|
||||
"wav": "ru_reminder.wav",
|
||||
"lang": "ru",
|
||||
"text": "напомни мне через час позвонить маме",
|
||||
"keywords": ["напомни", "час", "позвонить"],
|
||||
"measured_wer": 0.0,
|
||||
"max_wer": 0.1
|
||||
},
|
||||
{
|
||||
"name": "ru_fact",
|
||||
"wav": "ru_fact.wav",
|
||||
"lang": "ru",
|
||||
"text": "отметь что я выпил воды",
|
||||
"keywords": ["отметь", "воды"],
|
||||
"measured_wer": 0.2,
|
||||
"max_wer": 0.25
|
||||
},
|
||||
{
|
||||
"name": "ru_query",
|
||||
"wav": "ru_query.wav",
|
||||
"lang": "ru",
|
||||
"text": "что у меня сегодня по календарю",
|
||||
"keywords": ["сегодня", "календарю"],
|
||||
"measured_wer": 0.0,
|
||||
"max_wer": 0.1
|
||||
},
|
||||
{
|
||||
"name": "en_act",
|
||||
"wav": "en_act.wav",
|
||||
"lang": "en",
|
||||
"text": "restart the web server and check the disk space",
|
||||
"keywords": ["restart", "server", "disk"],
|
||||
"measured_wer": 0.0,
|
||||
"max_wer": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -66,6 +66,19 @@ func gateReason(samples []float32, rate, minMs int, minRMS float64) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// pcmSamples converts canonical s16le little-endian PCM to the float32 range
|
||||
// whisper wants. Shared with the golden tests: they used to carry their own
|
||||
// copy, so a regression here (a /32767 divisor, a byte order slip) left the
|
||||
// assertion that the fixtures clear the silence gate green.
|
||||
func pcmSamples(b []byte) []float32 {
|
||||
out := make([]float32, len(b)/2)
|
||||
for i := range out {
|
||||
s := int16(b[i*2]) | int16(b[i*2+1])<<8
|
||||
out[i] = float32(s) / 32768.0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeReq) (worker.TranscribeResp, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return worker.TranscribeResp{}, fmt.Errorf("whisper: context done before transcribe: %w", err)
|
||||
@@ -75,12 +88,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
return worker.TranscribeResp{}, fmt.Errorf("whisper: empty audio")
|
||||
}
|
||||
|
||||
nSamples := len(a.Bytes) / 2
|
||||
samples := make([]float32, nSamples)
|
||||
for i := 0; i < nSamples; i++ {
|
||||
s := int16(a.Bytes[i*2]) | int16(a.Bytes[i*2+1])<<8
|
||||
samples[i] = float32(s) / 32768.0
|
||||
}
|
||||
samples := pcmSamples(a.Bytes)
|
||||
|
||||
// Silence gate: drop non-speech before whisper hallucinates on it.
|
||||
if reason := gateReason(samples, whisperSampleRate, h.minMs, h.minRMS); reason != "" {
|
||||
@@ -111,7 +119,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
ch := make(chan result, 1)
|
||||
cSamples := (*C.float)(unsafe.Pointer(&samples[0]))
|
||||
go func() {
|
||||
ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(nSamples)))}
|
||||
ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(len(samples))))}
|
||||
}()
|
||||
select {
|
||||
case r := <-ch:
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Command mavupdate deploys a new build of Maven to the box she runs on, with
|
||||
// an automatic rollback when the new build does not come up (Vikunja #249).
|
||||
//
|
||||
// It is a CLI on purpose, and it is the ONLY trigger for the update path.
|
||||
//
|
||||
// The obvious design — an IPC method plus a button on the web UI behind the
|
||||
// step-up passkey gate, the way /tools works — was considered and refused. A
|
||||
// step-up gate protects against the wrong person clicking; it does not change
|
||||
// the fact that anything reachable over the network becomes, in the event of a
|
||||
// mavweb bug, a remote arbitrary-code path with a build system attached. An
|
||||
// update needs shell access on the host, which is a strictly higher bar than
|
||||
// the gate that guards the tool allowlist. That is deliberate and it is the
|
||||
// reason there is no MethodApplyUpdate anywhere in internal/ipc.
|
||||
//
|
||||
// Consequently: mavend never constructs an update.Updater and nothing in the
|
||||
// daemon can call Apply, nothing runs on a timer, nothing checks a release
|
||||
// server, and no act, intent, tool or LLM output can reach any of this. The
|
||||
// package is linked into mavend through internal/config, which validates the
|
||||
// update block at startup; the guarantee is the absent caller, not an absent
|
||||
// import. She cannot update herself. She can be updated, by him.
|
||||
//
|
||||
// mavupdate -config deploy/mavend.json list # snapshots available to roll back to
|
||||
// mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
|
||||
// mavupdate -config deploy/mavend.json apply -yes # the whole thing
|
||||
// mavupdate -config deploy/mavend.json rollback [id] # restore + restart (default: newest)
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/update"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := flag.String("config", "deploy/mavend.json", "path to mavend.json (the update block is read from it)")
|
||||
yes := flag.Bool("yes", false, "required by `apply` and `rollback`: yes, restart the daemon")
|
||||
flag.Usage = usage
|
||||
flag.Parse()
|
||||
|
||||
// The stdlib flag package stops parsing at the first non-flag argument, so a
|
||||
// `-yes` written after the subcommand (which is how anyone would type it, and
|
||||
// how the usage text shows it) lands in Args instead of the flag. Pick it out
|
||||
// by hand rather than silently treating "apply -yes" as an unconfirmed apply.
|
||||
var args []string
|
||||
for _, a := range flag.Args() {
|
||||
if a == "-yes" || a == "--yes" {
|
||||
*yes = true
|
||||
continue
|
||||
}
|
||||
args = append(args, a)
|
||||
}
|
||||
if len(args) == 0 {
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
die("config: %v", err)
|
||||
}
|
||||
if cfg.Update == nil {
|
||||
die("no `update` block in %s — the update capability is off unless configured.\nSee the package comment in internal/update for what it does and does not do.", *cfgPath)
|
||||
}
|
||||
|
||||
logf := func(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", time.Now().Format("15:04:05"), fmt.Sprintf(format, a...))
|
||||
}
|
||||
u, err := update.New(*cfg.Update, update.WithLogger(logf))
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
|
||||
// Ctrl-C cancels the build or the health wait. It cannot cancel a rollback
|
||||
// midway into leaving the box in an unknown state, because the rollback runs
|
||||
// on its own context — see cmdApply.
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
switch args[0] {
|
||||
case "list":
|
||||
cmdList(u)
|
||||
case "verify":
|
||||
cmdVerify(ctx, u)
|
||||
case "apply":
|
||||
if !*yes {
|
||||
die("apply restarts mavend and can roll her back. Re-run with -yes if that is what you want.")
|
||||
}
|
||||
cmdApply(ctx, u)
|
||||
case "rollback":
|
||||
if !*yes {
|
||||
die("rollback restores the previous artifacts and restarts mavend. Re-run with -yes.")
|
||||
}
|
||||
id := ""
|
||||
if len(args) > 1 {
|
||||
id = args[1]
|
||||
}
|
||||
cmdRollback(ctx, u, id)
|
||||
default:
|
||||
usage()
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdList(u *update.Updater) {
|
||||
snaps, err := u.Snapshots()
|
||||
if err != nil {
|
||||
die("snapshots: %v", err)
|
||||
}
|
||||
if len(snaps) == 0 {
|
||||
fmt.Println("no snapshots yet — the first `apply` takes one before it builds anything")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-18s %-12s %s\n", "SNAPSHOT", "COMMIT", "FILES")
|
||||
for _, s := range snaps {
|
||||
commit := s.Commit
|
||||
if len(commit) > 12 {
|
||||
commit = commit[:12]
|
||||
}
|
||||
if commit == "" {
|
||||
commit = "-"
|
||||
}
|
||||
fmt.Printf("%-18s %-12s %d\n", s.ID, commit, len(s.Files))
|
||||
}
|
||||
fmt.Printf("\nrollback to the newest with: mavupdate rollback -yes\n")
|
||||
}
|
||||
|
||||
func cmdVerify(ctx context.Context, u *update.Updater) {
|
||||
steps, err := u.Verify(ctx)
|
||||
report(steps)
|
||||
if err != nil {
|
||||
die("%v", err)
|
||||
}
|
||||
fmt.Println("verified: the tree builds and passes its own tests. Nothing was deployed — run `apply -yes` for that.")
|
||||
}
|
||||
|
||||
func cmdApply(ctx context.Context, u *update.Updater) {
|
||||
res, err := u.Apply(ctx)
|
||||
report(res.Steps)
|
||||
summarize(res)
|
||||
switch {
|
||||
case err == nil:
|
||||
fmt.Println("\nupdate committed: she answers on the new build.")
|
||||
case errors.Is(err, update.ErrRollbackFailed):
|
||||
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
|
||||
case errors.Is(err, update.ErrRolledBack):
|
||||
die("\n%v\n\nShe is answering again on the previous build. Nothing was lost; fix the change and retry.", err)
|
||||
default:
|
||||
die("\n%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdRollback(ctx context.Context, u *update.Updater, id string) {
|
||||
res, err := u.Rollback(ctx, id)
|
||||
report(res.Steps)
|
||||
summarize(res)
|
||||
// The standalone rollback is what he reaches for when something is already
|
||||
// wrong, so a failed one needs the loud paragraph more than apply does, not
|
||||
// less.
|
||||
if errors.Is(err, update.ErrRollbackFailed) {
|
||||
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
|
||||
}
|
||||
if err != nil && !errors.Is(err, update.ErrRolledBack) {
|
||||
die("\n%v", err)
|
||||
}
|
||||
fmt.Printf("\nrolled back to %s; she answers on it.\n", res.SnapshotID)
|
||||
}
|
||||
|
||||
func report(steps []update.Step) {
|
||||
for _, s := range steps {
|
||||
status := "ok"
|
||||
if s.Err != nil {
|
||||
status = "FAILED: " + s.Err.Error()
|
||||
}
|
||||
fmt.Printf(" %-8s %-8s %s\n", s.Name, s.Took.Round(time.Second), status)
|
||||
if s.Output != "" {
|
||||
fmt.Printf("---- %s output ----\n%s\n-------------------\n", s.Name, s.Output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func summarize(res update.Result) {
|
||||
fmt.Printf("\nverified=%v snapshot=%s installed=%d restarted=%v healthy=%v rolled_back=%v rollback_healthy=%v took=%s\n",
|
||||
res.Verified, res.SnapshotID, len(res.Installed), res.Restarted, res.Healthy, res.RolledBack, res.RollbackHealthy, res.Took.Round(time.Second))
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprint(os.Stderr, `mavupdate — deploy a new build of Maven, with rollback.
|
||||
|
||||
mavupdate [-config path] list
|
||||
mavupdate [-config path] verify
|
||||
mavupdate [-config path] apply -yes
|
||||
mavupdate [-config path] rollback [snapshot-id] -yes
|
||||
|
||||
apply is: health-check the running daemon, snapshot the deployed artifacts,
|
||||
make build, make test, install, restart, health-check — and restore the
|
||||
snapshot if any of that fails. It never fetches code and never runs by itself.
|
||||
|
||||
`)
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
func die(format string, a ...any) {
|
||||
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
||||
os.Exit(1)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user