Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ee3e6a9eaf | |||
| 8acb8a97c6 | |||
| 0b3b8d0a9e | |||
| fe0e654ab1 | |||
| a54ebac0cb |
@@ -6,6 +6,8 @@
|
|||||||
/mavweb
|
/mavweb
|
||||||
/mavpoll
|
/mavpoll
|
||||||
/mavcaldav
|
/mavcaldav
|
||||||
|
/mavwaked
|
||||||
|
/mavmaild
|
||||||
|
|
||||||
# Certs (private keys, don't commit)
|
# Certs (private keys, don't commit)
|
||||||
certs/
|
certs/
|
||||||
@@ -33,6 +35,10 @@ deps
|
|||||||
deploy/db_key.env
|
deploy/db_key.env
|
||||||
# Deploy secret (telegram bot token + chat id) — never commit
|
# Deploy secret (telegram bot token + chat id) — never commit
|
||||||
deploy/telegram.env
|
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
|
# Temp files
|
||||||
/tmp/
|
/tmp/
|
||||||
|
|||||||
@@ -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
|
(Russian recall rarely clears the confidence gate, many commands fall to
|
||||||
"clarify").
|
"clarify").
|
||||||
|
|
||||||
**Download the embedder** (ONNX, ~90 MB):
|
**Download the embedder** (ONNX, ~120 MB):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make download-embedder
|
make download-embedder
|
||||||
```
|
```
|
||||||
|
|
||||||
This fetches `paraphrase-multilingual-MiniLM-L12-v2` (384-dim, 12-layer,
|
This fetches `multilingual-e5-small` (384-dim, 12-layer, Russian and English)
|
||||||
supports 50+ languages including Russian) to `models/embedder/`.
|
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`):
|
**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
|
```json
|
||||||
"voice": {
|
"voice": {
|
||||||
"embedder": {
|
"embedder": {
|
||||||
"model_path": "models/embedder/model_quantized.onnx",
|
"model_path": "models/embedder/multilingual-e5-small/model_quantized.onnx",
|
||||||
"tokenizer_path": "models/embedder/tokenizer.json",
|
"tokenizer_path": "models/embedder/multilingual-e5-small/tokenizer.json",
|
||||||
"lib_path": "/usr/local/lib/libonnxruntime.so"
|
"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`,
|
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`,
|
||||||
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
||||||
|
|
||||||
**Resident model:** currently **Qwen3.5-0.8B** (`Q4_K_M`), the smallest checkpoint in the gguf
|
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
||||||
library, picked for CPU/iGPU latency. The **target** is the locally CPT'd **Qwen3-1.7B**; that
|
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
|
||||||
training is still in flight (Vikunja #122), so no such gguf exists yet. Model files live in
|
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
|
`/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's
|
||||||
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
||||||
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
||||||
@@ -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`:
|
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||||||
|
|
||||||
```sh
|
```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 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
|
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). |
|
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
||||||
| `mavpoll` | Telegram long-poll reach. |
|
| `mavpoll` | Telegram long-poll reach. |
|
||||||
| `mavcaldav` | CalDAV calendar sync. |
|
| `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
|
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
|
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
|
## Routing — read this before touching the router
|
||||||
|
|
||||||
`internal/router/` has TWO layered engines and the committed default is an **interim
|
`internal/router/` has TWO layered engines. **The LLM router is now the default and it is
|
||||||
stopgap, not the intended design** (see memory `routing-architecture-target`):
|
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
|
- **LLM router (the intended design, REARCH.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
||||||
GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is demoted
|
emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is
|
||||||
from a routing gate to a RAG hint.
|
demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via
|
||||||
- **Current stopgap:** `llmrouter` is wired `nil` (around `voice.go`), so the
|
`pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router`
|
||||||
`classifier.go` + `embedder.go` nearest-neighbour cascade actually runs. It routes by
|
(`config.go`), `DefaultLLMRouter` is **on**, and `deploy/mavend.json` sets it `true`.
|
||||||
similarity to frozen seed phrases — the known cause of weak RU query handling.
|
- **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
|
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.
|
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
|
## LLM output contract
|
||||||
|
|
||||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
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)
|
## Non-goals (hard constraints)
|
||||||
|
|
||||||
Never phones home. Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||||
self-reference must use feminine forms (the user is male; see memory `maven-persona-gender`).
|
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
|
## Web UI conventions
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
|
|
||||||
**Maven** — self-hosted personal assistant. Manages your day, acts on your
|
**Maven** — self-hosted personal assistant. Manages your day, acts on your
|
||||||
homelab. One daemon on homesrv (always-on, not the workstation), multiple
|
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
|
Primary name is "Maven", with feminine-gendered Russian self-reference
|
||||||
("она", "меня", "помогла"). Clients may choose their own UI label. Consistent
|
("она", "меня", "помогла"). 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.
|
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
|
- **Not a nag** — she'd rather miss a nudge than be mutable. Shuts up when
|
||||||
uncertain. Load-bearing.
|
uncertain. Load-bearing.
|
||||||
- **Not a stranger** — runs on your stuff, your model, your data. Never
|
- **Not a stranger** — runs on your stuff, your model, your data. No
|
||||||
phones home.
|
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
|
- **Not a relationship** — mom-tone is a function that makes nudges land, not
|
||||||
emotional company. Names the drift a warm small model falls into.
|
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
|
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
|
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
|
body** — "disk low on homesrv," not detail; don't make notifications a
|
||||||
shoulder-surf exfil surface.
|
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/mavttsd ./cmd/mavttsd && \
|
||||||
go build -o /out/mavweb ./cmd/mavweb && \
|
go build -o /out/mavweb ./cmd/mavweb && \
|
||||||
go build -o /out/mavpoll ./cmd/mavpoll && \
|
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
|
# 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
|
# 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`.
|
||||||
@@ -20,7 +20,7 @@ PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
|||||||
|
|
||||||
all: build
|
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-stt:
|
build-stt:
|
||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
@@ -50,6 +50,9 @@ build-poll:
|
|||||||
build-caldav:
|
build-caldav:
|
||||||
$(GO) build $(GOFLAGS) -o mavcaldav ./cmd/mavcaldav/
|
$(GO) build $(GOFLAGS) -o mavcaldav ./cmd/mavcaldav/
|
||||||
|
|
||||||
|
build-mail:
|
||||||
|
$(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/
|
||||||
|
|
||||||
run-web: build-web
|
run-web: build-web
|
||||||
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
./mavweb -addr :9200 -voice 127.0.0.1:9100
|
||||||
|
|
||||||
@@ -103,14 +106,17 @@ eval-router:
|
|||||||
eval-recall:
|
eval-recall:
|
||||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/memory/recalleval/
|
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
|
# report and every generated message land in the terminal. With no environment
|
||||||
# it scores the deterministic Stub only, which is what CI runs. Set
|
# it scores the deterministic Stub only, which is what CI runs. Set
|
||||||
# MAVEN_LLM_URL to add the resident model:
|
# MAVEN_LLM_URL to add the resident model:
|
||||||
# MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing
|
# 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:
|
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
|
# 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,
|
# resident-model bake-off (#278, #250). Start a server with the gguf you want,
|
||||||
@@ -152,9 +158,13 @@ deps-piper:
|
|||||||
-o /tmp/piper.tar.gz
|
-o /tmp/piper.tar.gz
|
||||||
tar -xzf /tmp/piper.tar.gz -C deps/
|
tar -xzf /tmp/piper.tar.gz -C deps/
|
||||||
|
|
||||||
EMBEDDER_DIR := $(shell pwd)/models/embedder
|
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
|
||||||
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2/resolve/main/onnx/model_quantized.onnx
|
# a short question against a longer passage, which is what note recall is.
|
||||||
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/paraphrase-multilingual-MiniLM-L12-v2/resolve/main/tokenizer.json
|
# 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:
|
download-embedder:
|
||||||
mkdir -p $(EMBEDDER_DIR)
|
mkdir -p $(EMBEDDER_DIR)
|
||||||
@@ -178,4 +188,4 @@ download-embedder:
|
|||||||
@echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/'
|
@echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/'
|
||||||
|
|
||||||
clean:
|
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.
|
4. **Deferred work** — larger reasoner, custom Piper voice and other expansions.
|
||||||
|
|
||||||
## Non-goals (unchanged)
|
## 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
|
`QueryNotes` it fails again here — the branch can only ever return a **fact**. Its comment calls it
|
||||||
"additive"; for notes it is not.
|
"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
|
### 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
|
`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
|
(`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.
|
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
|
## 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
|
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.
|
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
|
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.
|
`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
|
3. ~~**Replace the absolute-score gate with a margin gate**~~ — done, see the section above.
|
||||||
concluded, absolute cosine cannot see a flat distribution.
|
δ=0.008, false recall 5/5 → 1/5.
|
||||||
4. **Delete or repair the dead `memStore` branch** at `voice.go:776` — search before the gate,
|
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.
|
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.
|
5. **Add a mild time decay to ranking** — the newest statement of a preference is the true one.
|
||||||
|
|||||||
+108
-10
@@ -66,15 +66,113 @@ Three things this run settles:
|
|||||||
`запиши что…` phrasings toward fact, and that suspicion stands — all five `ru-note-*`
|
`запиши что…` phrasings toward fact, and that suspicion stands — all five `ru-note-*`
|
||||||
cases now land on fact. Tracked as Vikunja #375.
|
cases now land on fact. Tracked as Vikunja #375.
|
||||||
|
|
||||||
**Thinking off is the best configuration measured so far**, on both accuracy and latency
|
The `thinking off` column above read as the best configuration measured so far (Vikunja #376).
|
||||||
(Vikunja #376). That is worth understanding before flipping: routing is a short
|
**It was wrong** — see the controlled re-run below. Ignore that column.
|
||||||
classification into a fixed enum with grammar-constrained output, so there is little to
|
|
||||||
reason about, and the thinking trace mostly gives a small model room to talk itself out of
|
|
||||||
the right answer. Phrasing is a different job and needs measuring separately.
|
|
||||||
|
|
||||||
Still `6 / 6` missed clarify — the router has no way to say "I don't know" (Vikunja #359).
|
Still `6 / 6` missed clarify — the router has no way to say "I don't know" (Vikunja #359).
|
||||||
That is unchanged by anything here.
|
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
|
## Findings
|
||||||
|
|
||||||
### 1. The resident model does route better — 50.0% vs 36.8%
|
### 1. The resident model does route better — 50.0% vs 36.8%
|
||||||
@@ -145,11 +243,11 @@ Note the grammar's `string ::= "\"" ([^"\\] | "\\" .)* "\""` is unbounded, so no
|
|||||||
|
|
||||||
### 7. Two hypotheses tested and closed
|
### 7. Two hypotheses tested and closed
|
||||||
|
|
||||||
- **Thinking mode is a non-issue.** Qwen3.5's template defaults `thinking = 1`, so
|
- **Thinking mode is a non-issue.** Confirmed twice now, the second time properly — see the
|
||||||
grammar-constrained JSON lands in `reasoning_content` with `content` empty —
|
controlled re-run section. Grammar-constrained JSON lands in `reasoning_content` with
|
||||||
`llm.Client`'s fallback handles it. A `thinking off` run scored *identically* (18/76,
|
`content` empty and `llm.Client`'s fallback handles it; the request-level switch does
|
||||||
48.7%, same p50). `internal/llm` deliberately does **not** grow a `chat_template_kwargs`
|
nothing on this build. `internal/llm` deliberately does **not** grow a
|
||||||
field.
|
`chat_template_kwargs` field.
|
||||||
- **Runaway array repetition does not reproduce.** An isolated smoke test with a stripped
|
- **Runaway array repetition does not reproduce.** An isolated smoke test with a stripped
|
||||||
grammar emitted `{"intent":"reminder"}` until `MaxTokens`; under the real `routeSystem`
|
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.
|
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.
|
||||||
+75
-144
@@ -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
|
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
|
||||||
// `facts (kind=env, source=poll:caldav)` through core's IPC socket.
|
// today's events and writes `facts (kind=env, source=poll:caldav)` through
|
||||||
// Key-free, restart-free, fail-independent — crashes can't touch the
|
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
|
||||||
// store key, worst case a stale calendar_busy fact until the next poll.
|
// 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
|
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
||||||
// nudges during meetings
|
// nudges during meetings
|
||||||
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
||||||
//
|
//
|
||||||
// Append-only discipline: a fact is written only when its value CHANGED
|
// Append-only discipline: a fact is written only when its value CHANGED vs the
|
||||||
// vs the latest for that key+source.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -27,6 +34,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"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)")
|
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||||
pass := fs.String("pass", "", "CalDAV basic-auth password (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")
|
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
@@ -54,6 +66,9 @@ func run(args []string) error {
|
|||||||
if *url == "" || *user == "" || *pass == "" {
|
if *url == "" || *user == "" || *pass == "" {
|
||||||
return fmt.Errorf("-url, -user, -pass are required")
|
return fmt.Errorf("-url, -user, -pass are required")
|
||||||
}
|
}
|
||||||
|
if err := checkRenderTarget(*url, *renderURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
@@ -64,16 +79,36 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
defer core.Close()
|
defer core.Close()
|
||||||
|
|
||||||
|
hc := &http.Client{Timeout: *timeout}
|
||||||
p := &poller{
|
p := &poller{
|
||||||
core: core,
|
core: core,
|
||||||
http: &http.Client{Timeout: *timeout},
|
http: hc,
|
||||||
url: strings.TrimRight(*url, "/"),
|
url: strings.TrimRight(*url, "/"),
|
||||||
user: *user,
|
user: *user,
|
||||||
pass: *pass,
|
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)
|
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)
|
t := time.NewTicker(*interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for {
|
for {
|
||||||
@@ -82,11 +117,30 @@ func run(args []string) error {
|
|||||||
log.Printf("mavcaldav: bye")
|
log.Printf("mavcaldav: bye")
|
||||||
return nil
|
return nil
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
p.pollOnce(ctx)
|
tick()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkRenderTarget refuses a render URL that is also a read URL. 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 the one calendar maven
|
||||||
|
// is known to only read is rejected as a target at startup rather than trusted
|
||||||
|
// at runtime.
|
||||||
|
func checkRenderTarget(readURL, renderURL string) error {
|
||||||
|
if renderURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if sameCollection(readURL, renderURL) {
|
||||||
|
return fmt.Errorf("-render-url must differ from -url: maven renders into a calendar she owns, never into one she reads")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameCollection(a, b string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/"))
|
||||||
|
}
|
||||||
|
|
||||||
type poller struct {
|
type poller struct {
|
||||||
core ipc.CoreAPI
|
core ipc.CoreAPI
|
||||||
http *http.Client
|
http *http.Client
|
||||||
@@ -95,12 +149,6 @@ type poller struct {
|
|||||||
pass string
|
pass string
|
||||||
}
|
}
|
||||||
|
|
||||||
type icalEvent struct {
|
|
||||||
start time.Time
|
|
||||||
end time.Time
|
|
||||||
summary string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *poller) pollOnce(ctx context.Context) {
|
func (p *poller) pollOnce(ctx context.Context) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
events, err := p.fetchEvents(ctx, now)
|
events, err := p.fetchEvents(ctx, now)
|
||||||
@@ -109,38 +157,30 @@ func (p *poller) pollOnce(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
busy := false
|
|
||||||
for _, e := range events {
|
|
||||||
if !now.Before(e.start) && now.Before(e.end) {
|
|
||||||
busy = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
busyVal := "false"
|
busyVal := "false"
|
||||||
if busy {
|
if calendar.Busy(events, now) {
|
||||||
busyVal = "true"
|
busyVal = "true"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write calendar_busy on change.
|
// 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, 1.0); err != nil {
|
||||||
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||||
return
|
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
|
// This lets the note RAG path answer "what's on my calendar" without
|
||||||
// reaching back to Radicale.
|
// reaching back to Radicale.
|
||||||
for _, e := range events {
|
for _, e := range events {
|
||||||
val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04"))
|
key := calendar.FactKey(e)
|
||||||
eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary))
|
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
||||||
if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil {
|
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||||
log.Printf("mavcaldav: write %s: %v", eventKey, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
// 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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -162,120 +202,11 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, e
|
|||||||
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
return nil, fmt.Errorf("GET %s: %s", p.url, resp.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseICal(body, now), nil
|
return calendar.ParseICalDay(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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeIfChanged writes a fact only when the value differs from the latest.
|
// writeIfChanged writes a fact only when the value differs from the latest.
|
||||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time) error {
|
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts time.Time, confidence float64) error {
|
||||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && prev.Value == val:
|
case err == nil && prev.Value == val:
|
||||||
@@ -289,7 +220,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
|
|||||||
Key: key,
|
Key: key,
|
||||||
Value: val,
|
Value: val,
|
||||||
Source: source,
|
Source: source,
|
||||||
Confidence: 1.0,
|
Confidence: confidence,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write %s: %w", key, err)
|
return fmt.Errorf("write %s: %w", key, err)
|
||||||
|
|||||||
+6
-165
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeCore struct {
|
type fakeCore struct {
|
||||||
ipc.CoreAPI
|
ipc.UnimplementedCoreAPI
|
||||||
facts map[string]ipc.Fact // composite key "key|source" → Fact
|
facts map[string]ipc.Fact // composite key "key|source" → Fact
|
||||||
writeLog []ipc.WriteFactReq
|
writeLog []ipc.WriteFactReq
|
||||||
writeErr error
|
writeErr error
|
||||||
@@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er
|
|||||||
return int64(len(f.writeLog)), nil
|
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
|
// Core logic tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -221,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
t.Run("no previous fact writes", func(t *testing.T) {
|
t.Run("no previous fact writes", func(t *testing.T) {
|
||||||
fc := &fakeCore{}
|
fc := &fakeCore{}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -249,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -265,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "new", now, 1.0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
@@ -280,7 +121,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
t.Run("read error other than ErrNoFact returns error", func(t *testing.T) {
|
||||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now)
|
err := p.writeIfChanged(ctx, "fail_key", "poll:caldav", "x", now, 1.0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
@@ -292,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
|||||||
writeErr: fmt.Errorf("disk full"),
|
writeErr: fmt.Errorf("disk full"),
|
||||||
}
|
}
|
||||||
p := &poller{core: fc}
|
p := &poller{core: fc}
|
||||||
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now)
|
err := p.writeIfChanged(ctx, "test_key", "poll:caldav", "hello", now, 1.0)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 != "pending" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
for id := range r.published {
|
||||||
|
if live[id] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"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.
|
||||||
|
type calSrv struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
puts map[string]string
|
||||||
|
dels []string
|
||||||
|
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/"))
|
||||||
|
}
|
||||||
|
w.WriteHeader(s.status)
|
||||||
|
}))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
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(read, ""); err != nil {
|
||||||
|
t.Fatalf("rendering off must be fine: %v", err)
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, "http://localhost:5232/kami/maven"); err != nil {
|
||||||
|
t.Fatalf("a distinct collection must be accepted: %v", err)
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, read); err == nil {
|
||||||
|
t.Error("rendering into the read calendar must be refused")
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, read+"/"); err == nil {
|
||||||
|
t.Error("a trailing slash must not defeat the check")
|
||||||
|
}
|
||||||
|
if err := checkRenderTarget(read, strings.ToUpper(read)); err == nil {
|
||||||
|
t.Error("case must not defeat the check")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,66 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
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,78 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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) {
|
||||||
|
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
reply := val.FormatRU(phrase)
|
||||||
|
if reply == "" {
|
||||||
|
return "по тратам пока нечего сказать.", true
|
||||||
|
}
|
||||||
|
// A stale fact is reported as stale rather than spoken as today's number.
|
||||||
|
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
|
||||||
|
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
|
||||||
|
func isNoFactErr(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()
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,383 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/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},
|
||||||
|
{"calendar", (*reactiveHandler).queryCalendar},
|
||||||
|
{"weather", (*reactiveHandler).queryWeather},
|
||||||
|
{"embed", (*reactiveHandler).queryEmbed},
|
||||||
|
{"memory", (*reactiveHandler).queryMemory},
|
||||||
|
{"notes", (*reactiveHandler).queryNotes},
|
||||||
|
{"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 any morning checklist still outstanding.
|
||||||
|
//
|
||||||
|
// 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.
|
||||||
|
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 !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
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||||||
|
// that means "from now on" rather than "the whole day".
|
||||||
|
func isRestOfDayQuery(text string) bool {
|
||||||
|
s := strings.ToLower(text)
|
||||||
|
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
// habitFactWindow — how many recent 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.
|
||||||
|
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.RecentFacts(ctx, 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
|
||||||
|
}
|
||||||
|
return profile.FormatOverallRU(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedNoteWindow — how many recent notes are scanned for feed items, 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
|
||||||
|
}
|
||||||
|
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: feeds: recent notes: %v", err)
|
||||||
|
return "не получилось посмотреть ленты.", true
|
||||||
|
}
|
||||||
|
var picked []string
|
||||||
|
for _, n := range notes {
|
||||||
|
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !router.CategoryMatches(n.Text, q.Category) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// The note carries title, summary and link; she reads the title.
|
||||||
|
title := n.Text
|
||||||
|
if i := strings.IndexByte(title, '\n'); i > 0 {
|
||||||
|
title = title[:i]
|
||||||
|
}
|
||||||
|
picked = append(picked, strings.TrimSpace(title))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,83 @@
|
|||||||
|
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.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,227 @@
|
|||||||
|
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
|
||||||
|
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}, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
-19
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
@@ -21,8 +23,12 @@ const clarifyTTL = 90 * time.Second
|
|||||||
// raw utterance, chat and system have nothing to fill in. For those a clarify
|
// raw utterance, chat and system have nothing to fill in. For those a clarify
|
||||||
// decision keeps the canned "не поняла" reply — inventing a question for noise
|
// decision keeps the canned "не поняла" reply — inventing a question for noise
|
||||||
// is worse than admitting she missed it.
|
// is worse than admitting she missed it.
|
||||||
|
// A reminder wants BOTH what to remind about and when. Subject first: "напомни
|
||||||
|
// в 11" has a time and nothing to say at 11, and a reminder with no subject is
|
||||||
|
// not worth setting. Order here is the order she asks in — she still only asks
|
||||||
|
// about the first one missing.
|
||||||
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||||
router.IntentReminder: {dialogue.SlotTime},
|
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
|
||||||
router.IntentFact: {dialogue.SlotKey},
|
router.IntentFact: {dialogue.SlotKey},
|
||||||
router.IntentAct: {dialogue.SlotFn},
|
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
|
// 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.
|
// self-reference lives in the reply she gives when she drops the request.
|
||||||
var clarifyQuestions = map[dialogue.Slot]string{
|
var clarifyQuestions = map[dialogue.Slot]string{
|
||||||
dialogue.SlotTime: "На когда напомнить?",
|
dialogue.SlotTime: "Когда?",
|
||||||
|
dialogue.SlotText: "О чём напомнить?",
|
||||||
dialogue.SlotKey: "Что записать?",
|
dialogue.SlotKey: "Что записать?",
|
||||||
dialogue.SlotFn: "Что сделать?",
|
dialogue.SlotFn: "Что сделать?",
|
||||||
}
|
}
|
||||||
|
|
||||||
// clarifyDropped — she asked once, the answer still did not fill the gap, so
|
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
||||||
// the request is gone. Said plainly, once, with no second question.
|
// says so out loud: dropping the request in silence would leave him thinking it
|
||||||
const clarifyDropped = "Не разобрала — скажи целиком, пожалуйста."
|
// 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.
|
// missingFor returns the slots a decision still needs, most important first.
|
||||||
// Empty ⇒ there is nothing identifiable to ask about.
|
// Empty ⇒ there is nothing identifiable to ask about.
|
||||||
@@ -79,13 +165,14 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
||||||
Intent: dialogue.Intent(dec.Intent),
|
Intent: dialogue.Intent(dec.Intent),
|
||||||
Slots: toDialogueSlots(dec.Slots),
|
Slots: toDialogueSlots(dec.Slots),
|
||||||
Missing: []dialogue.Slot{slot},
|
Missing: []dialogue.Slot{slot},
|
||||||
Utterance: dec.Utterance,
|
Utterance: dec.Utterance,
|
||||||
Asked: h.now(),
|
Asked: h.now(),
|
||||||
TTL: clarifyTTL,
|
TTL: clarifyTTL,
|
||||||
Attempts: 1, // asked once; MaxAttempts is 1, so there is no second ask
|
Attempts: 1, // this ask
|
||||||
|
MaxAttempts: h.clarifyMaxAttempts,
|
||||||
})
|
})
|
||||||
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
|
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
|
||||||
return question, true
|
return question, true
|
||||||
@@ -97,8 +184,9 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
|||||||
// resolveConfirm and checked in the same place.
|
// resolveConfirm and checked in the same place.
|
||||||
//
|
//
|
||||||
// The answer is parsed with the same extractor the router uses, for the intent
|
// 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
|
// she parked — no second parser. If it still does not fill the gap she asks
|
||||||
// is dropped: she does not ask again.
|
// 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) {
|
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -107,17 +195,14 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
if q == nil {
|
if q == nil {
|
||||||
return "", false
|
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)
|
intent := router.Intent(q.Intent)
|
||||||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
||||||
merged := q.Answer(text, toDialogueSlots(answer))
|
merged := q.Answer(text, toDialogueSlots(answer))
|
||||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||||
log.Printf("voice: clarify — answer %q did not fill %v, dropping", text, q.Missing)
|
return h.reaskOrGiveUp(q, merged, text), true
|
||||||
return clarifyDropped, true
|
|
||||||
}
|
}
|
||||||
|
h.clarifyStore.Delete(voiceDialogueID)
|
||||||
|
|
||||||
// Rebuild the decision as if it had routed cleanly, then run it down the
|
// Rebuild the decision as if it had routed cleanly, then run it down the
|
||||||
// normal path. Clarify is deliberately false and the intent is unchanged:
|
// normal path. Clarify is deliberately false and the intent is unchanged:
|
||||||
@@ -133,6 +218,29 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
return h.finishClarified(ctx, dec), true
|
return h.finishClarified(ctx, dec), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||||
|
// again while she has attempts left, otherwise say she did not understand and
|
||||||
|
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||||
|
func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||||
|
question := ""
|
||||||
|
if len(q.Missing) > 0 {
|
||||||
|
question = clarifyQuestions[q.Missing[0]]
|
||||||
|
}
|
||||||
|
if question == "" || !q.CanAsk() {
|
||||||
|
h.clarifyStore.Delete(voiceDialogueID)
|
||||||
|
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||||||
|
return clarifyGaveUp
|
||||||
|
}
|
||||||
|
// Re-park with whatever the answer DID give, the clock restarted and one
|
||||||
|
// more question spent.
|
||||||
|
q.Slots = merged
|
||||||
|
q.Attempts++
|
||||||
|
q.Asked = h.now()
|
||||||
|
h.clarifyStore.Put(voiceDialogueID, q)
|
||||||
|
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
||||||
|
return question
|
||||||
|
}
|
||||||
|
|
||||||
// finishClarified runs a completed decision through the same steps a freshly
|
// finishClarified runs a completed decision through the same steps a freshly
|
||||||
// routed one takes: remember the turn, act, then phrase.
|
// routed one takes: remember the turn, act, then phrase.
|
||||||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
||||||
@@ -146,6 +254,10 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
reply = h.replier.Reply(dec)
|
reply = h.replier.Reply(dec)
|
||||||
}
|
}
|
||||||
|
if reply == "" {
|
||||||
|
// Belt: an empty reply here would be a silent drop.
|
||||||
|
reply = clarifyGaveUp
|
||||||
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+108
-14
@@ -56,10 +56,13 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
asked bool
|
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},
|
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||||
{"act without a fn", clarifyDec(router.IntentAct, 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},
|
{"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},
|
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||||||
}
|
}
|
||||||
@@ -78,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
|||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
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)
|
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,7 +89,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
|||||||
if !handled {
|
if !handled {
|
||||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
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)
|
t.Fatalf("a good answer must not drop the request: %q", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +114,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
|||||||
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||||
t.Fatal("a fact with no key should be asked about")
|
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)
|
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" {
|
if fact, err := st.LatestFact(ctx, "water"); err != nil || fact.Key != "water" {
|
||||||
@@ -137,26 +140,87 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClarifyUnclearAnswerDropsWithoutAskingAgain — MaxAttempts is 1.
|
// TestClarifyAsksThreeTimesThenSaysSo — three questions are allowed, the fourth
|
||||||
func TestClarifyUnclearAnswerDropsWithoutAskingAgain(t *testing.T) {
|
// is not, and running out is SPOKEN. Silence would read as "handled".
|
||||||
|
func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
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, "ну не знаю")
|
reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю")
|
||||||
if !handled || reply != clarifyDropped {
|
if !handled || reply != clarifyGaveUp {
|
||||||
t.Fatalf("an unclear answer should drop the request, handled=%v reply=%q", handled, reply)
|
t.Fatalf("the fourth try must give up out loud, handled=%v reply=%q", handled, reply)
|
||||||
}
|
}
|
||||||
if strings.Contains(reply, "?") {
|
if reply == "" || strings.Contains(reply, "?") {
|
||||||
t.Fatalf("she must not ask a second question: %q", reply)
|
t.Fatalf("giving up must be spoken and must not be another question: %q", reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
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 {
|
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 +292,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
|
// TestNoPendingQuestionFallsThrough — with nothing parked, an utterance routes
|
||||||
// normally.
|
// normally.
|
||||||
func TestNoPendingQuestionFallsThrough(t *testing.T) {
|
func TestNoPendingQuestionFallsThrough(t *testing.T) {
|
||||||
|
|||||||
@@ -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,227 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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{
|
||||||
|
"что у меня сегодня?",
|
||||||
|
"какие планы на завтра?",
|
||||||
|
"когда планёрка?",
|
||||||
|
"какая погода?",
|
||||||
|
"",
|
||||||
|
} {
|
||||||
|
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 RecentFacts — the whole input the behaviour profile
|
||||||
|
// needs (Vikunja #254). Nothing is asked of the LLM, so nothing else is wired.
|
||||||
|
type habitAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
facts []ipc.Fact
|
||||||
|
err error
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *habitAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
||||||
|
a.calls++
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ""
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if err := a.call(ctx, px, id); err != nil {
|
||||||
|
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||||||
|
return a.failure
|
||||||
|
}
|
||||||
|
h.recordPraxisTrace(ctx, a.op, 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 {
|
||||||
|
items, err := px.ListAttention(ctx, 20)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ecosystem: praxis attention: %v", err)
|
||||||
|
return "не могу сейчас узнать, что требует внимания."
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "ничего не требует внимания."
|
||||||
|
}
|
||||||
|
h.recordPraxisTrace(ctx, "list_attention", 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 {
|
||||||
|
changes, err := px.ListChanges(ctx, 20)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ecosystem: praxis changes: %v", err)
|
||||||
|
return "не могу сейчас узнать об изменениях."
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return "нет изменений."
|
||||||
|
}
|
||||||
|
h.recordPraxisTrace(ctx, "list_changes", 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, "; ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordPraxisTrace — writes a fact recording a cross-service ecosystem call.
|
||||||
|
// The fact is stored with source "praxis:trace" so the proactive loop can
|
||||||
|
// reference it and the dashboard can display recent ecosystem activity.
|
||||||
|
func (h *reactiveHandler) recordPraxisTrace(ctx context.Context, operation string, details map[string]any) {
|
||||||
|
now := h.now()
|
||||||
|
value := operation
|
||||||
|
if len(details) > 0 {
|
||||||
|
if b, err := json.Marshal(details); err == nil {
|
||||||
|
value = operation + " " + string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, _ = h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
|
Ts: now,
|
||||||
|
Kind: "system",
|
||||||
|
Key: "praxis:" + operation,
|
||||||
|
Value: value,
|
||||||
|
Source: "praxis:trace",
|
||||||
|
Confidence: 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the utterance text as an entity reference through Nexus. An
|
||||||
|
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||||
|
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
||||||
|
if err != nil {
|
||||||
|
// 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 {
|
||||||
|
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
||||||
|
}
|
||||||
|
if entityID == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
caps, err := h.ecosystem.discoverCapabilities(ctx, entityID)
|
||||||
|
if err != nil {
|
||||||
|
return "экосистема недоступна, попробуй ещё раз."
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
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 {
|
||||||
|
correlationID, err := h.ecosystem.executeCapability(ctx, capID, entityID, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ecosystem: hexis execute error (cor=%s): %v", correlationID, err)
|
||||||
|
return "не получилось выполнить команду для " + displayName + "."
|
||||||
|
}
|
||||||
|
h.recordPraxisTrace(ctx, "hexis:"+capName, map[string]any{
|
||||||
|
"entity_id": entityID,
|
||||||
|
"entity_name": displayName,
|
||||||
|
"capability": capName,
|
||||||
|
"correlation_id": correlationID,
|
||||||
|
})
|
||||||
|
return "команда выполнена для " + displayName + "."
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
// 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/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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,177 @@
|
|||||||
|
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 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,158 @@
|
|||||||
|
// 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"
|
||||||
|
"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/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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) *mailIntake {
|
||||||
|
if cfg.Email == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lp, ok := phr.(*phraser.LLMPhraser)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("mail intake: configured but no llama-server phraser — mail ingestion disabled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
timeout := time.Duration(cfg.Email.Timeout)
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = config.DefaultEmailTimeout
|
||||||
|
}
|
||||||
|
ex := email.NewExtractor(llm.New(lp.BaseURL(), timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
|
||||||
|
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", cfg.Email.MaxTasks, timeout)
|
||||||
|
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. CaptureTask dedupes on normalised text among
|
||||||
|
// live rows, so a mailbox re-read after a restart produces Created=0 rather
|
||||||
|
// than a second copy of every task.
|
||||||
|
func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, m.timeout)
|
||||||
|
defer cancel()
|
||||||
|
cands, err := m.ex.Extract(ctx, msg)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
source := email.SourcePrefix + req.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,
|
||||||
|
}
|
||||||
|
if due, ok := email.ParseDue(c.Due); ok {
|
||||||
|
t.Due = &due
|
||||||
|
}
|
||||||
|
id, created, err := m.st.CaptureTask(ctx, t)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("mail intake: capture: %w", err)
|
||||||
|
}
|
||||||
|
resp.TaskIDs = append(resp.TaskIDs, id)
|
||||||
|
if created {
|
||||||
|
resp.Created++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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) {
|
||||||
|
mi := newMailIntake(st, phr, cfg)
|
||||||
|
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]) + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
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{}); 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{}}); mi != nil {
|
||||||
|
t.Error("without a llama-server phraser there is nothing to extract with")
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
-117
@@ -58,6 +58,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/persona"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
@@ -96,100 +97,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 {
|
func run(args []string) error {
|
||||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
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)")
|
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||||
|
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap)")
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
|
reembedOnStart = *reembed
|
||||||
cfg, err := config.Load(*cfgPath)
|
cfg, err := config.Load(*cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -256,6 +169,8 @@ func run(args []string) error {
|
|||||||
coreAPI ipc.CoreAPI
|
coreAPI ipc.CoreAPI
|
||||||
eco *ecosystemWiring
|
eco *ecosystemWiring
|
||||||
factWorker *factEnrichmentWorker
|
factWorker *factEnrichmentWorker
|
||||||
|
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||||
|
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
||||||
)
|
)
|
||||||
|
|
||||||
if !locked {
|
if !locked {
|
||||||
@@ -269,13 +184,14 @@ func run(args []string) error {
|
|||||||
phr = phraser.NewStub()
|
phr = phraser.NewStub()
|
||||||
if cfg.Phraser != nil {
|
if cfg.Phraser != nil {
|
||||||
pc := phraser.Config{
|
pc := phraser.Config{
|
||||||
ModelPath: cfg.Phraser.ModelPath,
|
ModelPath: cfg.Phraser.ModelPath,
|
||||||
BinPath: cfg.Phraser.BinPath,
|
BinPath: cfg.Phraser.BinPath,
|
||||||
Listen: cfg.Phraser.Listen,
|
Listen: cfg.Phraser.Listen,
|
||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
Persona: personaFromCfg(cfg),
|
LLMNudges: cfg.Phraser.LLMNudges,
|
||||||
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
pc.BinPath = "llama-server"
|
pc.BinPath = "llama-server"
|
||||||
@@ -347,21 +263,29 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
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))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
|
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), embedderOf(voiceW), cfg)
|
||||||
|
|
||||||
coreAPI = &daemonAPI{
|
coreAPI = &daemonAPI{
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: ipc.NewStoreAPI(st),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
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()) },
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
api := coreAPI.(*daemonAPI)
|
api := coreAPI.(*daemonAPI)
|
||||||
api.chatFn = voiceW.handler.handleText
|
api.chatFn = voiceW.handler.handleText
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// locked mode: dummy CoreAPI that returns errLocked for everything
|
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
||||||
coreAPI = &lockedAPI{}
|
// 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) -----
|
// ----- IPC boundary (core ↔ modules) -----
|
||||||
@@ -372,7 +296,17 @@ func run(args []string) error {
|
|||||||
|
|
||||||
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
|
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 {
|
if locked {
|
||||||
srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error {
|
srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error {
|
||||||
switch m {
|
switch m {
|
||||||
@@ -388,6 +322,13 @@ func run(args []string) error {
|
|||||||
|
|
||||||
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
// WrapKeyFn — wraps the env key with a passkey credential public key and
|
// 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
|
// persists the wrapped blob. Only wired when the daemon has the key in
|
||||||
// memory (env key mode). Called by mavweb after passkey enrollment.
|
// memory (env key mode). Called by mavweb after passkey enrollment.
|
||||||
@@ -439,13 +380,14 @@ func run(args []string) error {
|
|||||||
phr = phraser.NewStub()
|
phr = phraser.NewStub()
|
||||||
if cfg.Phraser != nil {
|
if cfg.Phraser != nil {
|
||||||
pc := phraser.Config{
|
pc := phraser.Config{
|
||||||
ModelPath: cfg.Phraser.ModelPath,
|
ModelPath: cfg.Phraser.ModelPath,
|
||||||
BinPath: cfg.Phraser.BinPath,
|
BinPath: cfg.Phraser.BinPath,
|
||||||
Listen: cfg.Phraser.Listen,
|
Listen: cfg.Phraser.Listen,
|
||||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||||
NCtx: cfg.Phraser.NCtx,
|
NCtx: cfg.Phraser.NCtx,
|
||||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||||
Persona: personaFromCfg(cfg),
|
LLMNudges: cfg.Phraser.LLMNudges,
|
||||||
|
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||||
}
|
}
|
||||||
if pc.BinPath == "" {
|
if pc.BinPath == "" {
|
||||||
pc.BinPath = "llama-server"
|
pc.BinPath = "llama-server"
|
||||||
@@ -508,20 +450,24 @@ func run(args []string) error {
|
|||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tickInterval := time.Duration(cfg.TickInterval)
|
||||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
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))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
|
feedWkr = newFeedWorker(ipc.NewStoreAPI(st), 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{
|
newAPI := &daemonAPI{
|
||||||
CoreAPI: ipc.NewStoreAPI(st),
|
CoreAPI: ipc.NewStoreAPI(st),
|
||||||
getTrace: tl.trace,
|
getTrace: tl.trace,
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
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()) },
|
||||||
}
|
}
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
if voiceW != nil && voiceW.handler != nil {
|
||||||
newAPI.chatFn = voiceW.handler.handleText
|
newAPI.chatFn = voiceW.handler.handleText
|
||||||
}
|
}
|
||||||
srv.SetAPI(newAPI)
|
srv.SetAPI(newAPI)
|
||||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||||
|
wireMailIntake(srv, st, phr, cfg)
|
||||||
|
|
||||||
// Start voice server.
|
// Start voice server.
|
||||||
if voiceW != nil {
|
if voiceW != nil {
|
||||||
@@ -546,6 +492,20 @@ func run(args []string) error {
|
|||||||
factWorker.run(ctx)
|
factWorker.run(ctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
dl.unlock()
|
dl.unlock()
|
||||||
log.Printf("mavend: unlocked via passkey assertion")
|
log.Printf("mavend: unlocked via passkey assertion")
|
||||||
return nil
|
return nil
|
||||||
@@ -584,6 +544,20 @@ func run(args []string) error {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
factWorker.run(ctx)
|
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)
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
@@ -599,12 +573,29 @@ func run(args []string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// personaFromCfg extracts the voice persona from the config, or returns ""
|
// personaFacts reads the optional, deployment-specific facts (his name, his
|
||||||
// when voice isn't configured. Used to pass a character prompt into the
|
// city, the free-text persona string) out of the config. Everything here may
|
||||||
// LLM phraser without requiring voice to be enabled.
|
// be empty — the context block is correct without any of it.
|
||||||
func personaFromCfg(cfg *config.Config) string {
|
func personaFacts(cfg *config.Config) persona.Facts {
|
||||||
if cfg.Voice != nil {
|
f := persona.Facts{
|
||||||
return cfg.Voice.Persona
|
// 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,88 @@
|
|||||||
|
// 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/llm"
|
||||||
|
"github.com/kami/maven/internal/memeval"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
// A generous per-request timeout: this is a long prompt to a Thinking model
|
||||||
|
// and nobody is waiting on the answer.
|
||||||
|
client := llm.New(lp.BaseURL(), 5*time.Minute)
|
||||||
|
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,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,144 @@
|
|||||||
|
// 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.
|
||||||
|
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (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: "tap:voice",
|
||||||
|
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.
|
||||||
|
var (
|
||||||
|
quietOffPhrases = [][]string{
|
||||||
|
{"quiet", "off"}, {"quiet", "end"},
|
||||||
|
{"громк", "режим"}, {"шумн", "режим"},
|
||||||
|
{"отмен", "тих"}, {"выключ", "тих"}, {"не", "тих"},
|
||||||
|
}
|
||||||
|
quietOnPhrases = [][]string{
|
||||||
|
{"quiet", "on"}, {"quiet", "mode"},
|
||||||
|
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
||||||
|
{"тих"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// classifyQuietToggle reads an utterance as a quiet-mode command. OFF is
|
||||||
|
// resolved before ON for the same reason classifyConfirm checks negatives
|
||||||
|
// first: the OFF phrases are built out of the ON words ("выключи тихий"
|
||||||
|
// contains "тихий"), so scanning ON first would shadow them and "выключи
|
||||||
|
// тихий режим" would turn quiet mode on. Negation wins.
|
||||||
|
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) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-18
@@ -2,24 +2,24 @@ package main
|
|||||||
|
|
||||||
import "github.com/kami/maven/internal/memory"
|
import "github.com/kami/maven/internal/memory"
|
||||||
|
|
||||||
// bestRecall is the read side of the long-term memory store: the top hit's
|
// bestRecall is the read side of the long-term memory store: the top hit when
|
||||||
// stored text when it clears the confidence gate. This recalls across BOTH
|
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
||||||
// notes and facts (facts aren't in the notes table, so this is the only path
|
// either can win — the caller looks at the returned hit's meta["type"] to see
|
||||||
// that can answer "when did I last …?" from a captured fact). A note hit here
|
// which. Facts aren't in the notes table, so this is the only path that can
|
||||||
// is redundant with the notes-RAG path — by design; the two indexes can diverge
|
// answer "when did I last …?" from a captured fact.
|
||||||
// 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.
|
// The whole hit is returned, not just its text, because "which memory answered"
|
||||||
func bestRecall(results []memory.Result, min float64) (string, bool) {
|
// decides how the answer is said: a note gets phrased in Maven's voice, a fact
|
||||||
if len(results) == 0 {
|
// is read back as stored.
|
||||||
return "", false
|
//
|
||||||
|
// 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 results[0].Meta["text"] == "" {
|
||||||
if top.Score < min {
|
return memory.Result{}, false
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
text := top.Meta["text"]
|
return results[0], true
|
||||||
if text == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return text, true
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,24 @@ import (
|
|||||||
|
|
||||||
func TestBestRecall(t *testing.T) {
|
func TestBestRecall(t *testing.T) {
|
||||||
const min = 0.55
|
const min = 0.55
|
||||||
|
const margin = 0.008
|
||||||
|
|
||||||
t.Run("empty results", func(t *testing.T) {
|
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.Error("empty results returned ok")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("top below threshold", func(t *testing.T) {
|
t.Run("top below threshold", func(t *testing.T) {
|
||||||
res := []memory.Result{{Score: 0.4, Meta: map[string]string{"text": "выпил воды"}}}
|
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.Error("below-threshold hit returned ok")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("hit without text meta", func(t *testing.T) {
|
t.Run("hit without text meta", func(t *testing.T) {
|
||||||
res := []memory.Result{{Score: 0.9, Meta: map[string]string{"type": "fact"}}}
|
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")
|
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.82, Meta: map[string]string{"text": "выпил воды в три часа", "type": "fact"}},
|
||||||
{Score: 0.60, Meta: map[string]string{"text": "другое"}},
|
{Score: 0.60, Meta: map[string]string{"text": "другое"}},
|
||||||
}
|
}
|
||||||
got, ok := bestRecall(res, min)
|
got, ok := bestRecall(res, min, margin)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("clearing hit not returned")
|
t.Fatal("clearing hit not returned")
|
||||||
}
|
}
|
||||||
if got != "выпил воды в три часа" {
|
if got.Meta["text"] != "выпил воды в три часа" {
|
||||||
t.Errorf("wrong text: %q", got)
|
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"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/llm"
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/persona"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
@@ -23,13 +24,19 @@ type completer interface {
|
|||||||
type llmReplier struct {
|
type llmReplier struct {
|
||||||
c completer
|
c completer
|
||||||
stub *voice.StubReplier
|
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 {
|
func newLLMReplier(c completer, block func() string) *llmReplier {
|
||||||
return &llmReplier{c: c, stub: voice.NewStubReplier()}
|
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 {
|
func (r *llmReplier) Reply(d router.Decision) string {
|
||||||
if d.Clarify {
|
if d.Clarify {
|
||||||
@@ -38,7 +45,7 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
out, err := r.c.Complete(ctx, llm.Req{
|
out, err := r.c.Complete(ctx, llm.Req{
|
||||||
System: replySystem,
|
System: persona.Prepend(r.block, replySystem),
|
||||||
User: replyContext(d),
|
User: replyContext(d),
|
||||||
MaxTokens: 512,
|
MaxTokens: 512,
|
||||||
RepeatPenalty: 1.3,
|
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 (m mockCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return m.out, m.err }
|
||||||
|
|
||||||
func TestLLMReplierReturnsLLMReply(t *testing.T) {
|
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: "кофе закончился"}})
|
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||||
if got != "записала, кофе закончился" {
|
if got != "записала, кофе закончился" {
|
||||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||||
@@ -25,7 +25,7 @@ func TestLLMReplierReturnsLLMReply(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierFallsBackToPlainText(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: "кофе закончился"}})
|
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||||
if got != "записала, кофе закончился" {
|
if got != "записала, кофе закончился" {
|
||||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||||
@@ -33,7 +33,7 @@ func TestLLMReplierFallsBackToPlainText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnError(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}
|
noteDec := router.Decision{Intent: router.IntentNote}
|
||||||
got := r.Reply(noteDec)
|
got := r.Reply(noteDec)
|
||||||
want := voice.NewStubReplier().Reply(noteDec)
|
want := voice.NewStubReplier().Reply(noteDec)
|
||||||
@@ -43,7 +43,7 @@ func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||||
r := newLLMReplier(mockCompleter{out: ""})
|
r := newLLMReplier(mockCompleter{out: ""}, nil)
|
||||||
noteDec := router.Decision{Intent: router.IntentNote}
|
noteDec := router.Decision{Intent: router.IntentNote}
|
||||||
got := r.Reply(noteDec)
|
got := r.Reply(noteDec)
|
||||||
want := voice.NewStubReplier().Reply(noteDec)
|
want := voice.NewStubReplier().Reply(noteDec)
|
||||||
@@ -53,7 +53,7 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||||
r := newLLMReplier(mockCompleter{out: "я всё поняла"})
|
r := newLLMReplier(mockCompleter{out: "я всё поняла"}, nil)
|
||||||
clarifyDec := router.Decision{Clarify: true}
|
clarifyDec := router.Decision{Clarify: true}
|
||||||
got := r.Reply(clarifyDec)
|
got := r.Reply(clarifyDec)
|
||||||
want := voice.NewStubReplier().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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/routine"
|
"github.com/kami/maven/internal/routine"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -67,6 +68,14 @@ type tickLoop struct {
|
|||||||
morningRoutines []morning.Routine
|
morningRoutines []morning.Routine
|
||||||
morningLast map[string]time.Time
|
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.
|
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
||||||
// populated when digestCfg != nil && digestCfg.Enabled.
|
// populated when digestCfg != nil && digestCfg.Enabled.
|
||||||
digestQ []QueuedNudge
|
digestQ []QueuedNudge
|
||||||
@@ -92,6 +101,7 @@ func newTickLoop(
|
|||||||
digestCfg *config.DigestConfig,
|
digestCfg *config.DigestConfig,
|
||||||
routines []routine.Routine,
|
routines []routine.Routine,
|
||||||
morningRoutines []morning.Routine,
|
morningRoutines []morning.Routine,
|
||||||
|
proposalCfg *config.PatternProposalConfig,
|
||||||
) *tickLoop {
|
) *tickLoop {
|
||||||
return &tickLoop{
|
return &tickLoop{
|
||||||
store: st,
|
store: st,
|
||||||
@@ -108,6 +118,7 @@ func newTickLoop(
|
|||||||
routineLast: make(map[string]time.Time),
|
routineLast: make(map[string]time.Time),
|
||||||
morningRoutines: morningRoutines,
|
morningRoutines: morningRoutines,
|
||||||
morningLast: make(map[string]time.Time),
|
morningLast: make(map[string]time.Time),
|
||||||
|
proposalCfg: proposalCfg,
|
||||||
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,6 +191,17 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
|||||||
// (with dedup) avoids re-queueing the same rule after a flush.
|
// (with dedup) avoids re-queueing the same rule after a flush.
|
||||||
t.maybeFlush(ctx, now, state)
|
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
|
// routines: operator-declared scheduled behaviors. fire the ones whose cron
|
||||||
// crossed since last fire, delivered through the normal routing (voice when
|
// crossed since last fire, delivered through the normal routing (voice when
|
||||||
// present, away channels otherwise). bodies are literal operator text — not
|
// present, away channels otherwise). bodies are literal operator text — not
|
||||||
@@ -195,6 +217,14 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
|||||||
// nudge time. See internal/morning for the "why not four timers" rationale.
|
// nudge time. See internal/morning for the "why not four timers" rationale.
|
||||||
t.fireMorningRoutines(ctx, now, state)
|
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
|
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||||
// delivery. a failed send leaves the reminder pending — the next tick
|
// delivery. a failed send leaves the reminder pending — the next tick
|
||||||
// re-gathers and re-attempts.
|
// re-gathers and re-attempts.
|
||||||
@@ -342,6 +372,235 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
|
|||||||
t.digestQ = nil
|
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.
|
||||||
|
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.
|
||||||
|
//
|
||||||
|
// 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.
|
// routinesFromConfig maps the config's routine blocks to the engine type.
|
||||||
// Validation (cron parses, name/body present, severity defaulted) already ran
|
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||||
// in config.Load, so this is a pure field copy.
|
// in config.Load, so this is a pure field copy.
|
||||||
@@ -530,6 +789,77 @@ func (t *tickLoop) morningStatus(ctx context.Context, now time.Time) []ipc.Morni
|
|||||||
return out
|
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,
|
||||||
|
Text: 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.ListReminders(ctx, dayPlanMaxReminders)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tick: day plan: list reminders: %v", err)
|
||||||
|
}
|
||||||
|
for _, r := range rems {
|
||||||
|
if r.Status != "pending" {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// dayPlanMaxReminders bounds the reminder scan. The plan covers one day; a
|
||||||
|
// pending queue longer than this is a bug elsewhere, not a plan to recite.
|
||||||
|
const dayPlanMaxReminders = 500
|
||||||
|
|
||||||
// tune — the feedback auto-tuner's impure step. runs on a slow cadence
|
// 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
|
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||||
// rule:
|
// rule:
|
||||||
@@ -609,6 +939,7 @@ type daemonAPI struct {
|
|||||||
ipc.CoreAPI
|
ipc.CoreAPI
|
||||||
getTrace func() *loop.TickTrace
|
getTrace func() *loop.TickTrace
|
||||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||||
|
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||||
chatFn func(ctx context.Context, text string) string
|
chatFn func(ctx context.Context, text string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +965,13 @@ func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat
|
|||||||
return d.getMorningStatus(ctx), nil
|
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 {
|
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||||
for i, r := range 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,
|
Nudges: st,
|
||||||
Reminders: 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) {
|
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||||
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
|||||||
sink := &fakeSink{}
|
sink := &fakeSink{}
|
||||||
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
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}}
|
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.
|
// first tick: seeds, does not fire the routine.
|
||||||
tl.tick(ctx, now)
|
tl.tick(ctx, now)
|
||||||
|
|||||||
+86
-1427
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,435 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 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))
|
||||||
|
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 {
|
||||||
|
llmClient = llm.New(lp.BaseURL(), 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,
|
||||||
|
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,50 @@
|
|||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractWeatherLocation parses a location from the utterance, or falls back
|
||||||
|
// to the configured default. Very basic: just checks for known city names.
|
||||||
|
func extractWeatherLocation(u, defaultLoc string) string {
|
||||||
|
lower := strings.ToLower(u)
|
||||||
|
cities := 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",
|
||||||
|
}
|
||||||
|
for substr, name := range cities {
|
||||||
|
if strings.Contains(lower, substr) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if defaultLoc != "" {
|
||||||
|
return defaultLoc
|
||||||
|
}
|
||||||
|
return "Moscow"
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// 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 := 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:
|
||||||
|
if r.disabled {
|
||||||
|
// Core told us mail ingestion is not configured. Nothing will change
|
||||||
|
// without a core restart, and a restart restarts us too.
|
||||||
|
log.Printf("mavmaild: core does not accept mail — idling")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
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
|
||||||
|
|
||||||
|
// dial — connection seam for the tests; nil ⇒ implicit TLS.
|
||||||
|
dial func(addr string, timeout time.Duration) (*email.Conn, error)
|
||||||
|
|
||||||
|
// disabled — core answered ErrUnknownMethod, i.e. it has no email block.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if m.Junk {
|
||||||
|
junk++
|
||||||
|
// Marked seen without a model call: the header filter already decided,
|
||||||
|
// and re-classifying it every quarter hour would be pure waste.
|
||||||
|
r.state.mark(m.UID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp, err := r.core.IngestMail(ctx, ipc.IngestMailReq{
|
||||||
|
Mailbox: r.mailbox,
|
||||||
|
UID: m.UID,
|
||||||
|
From: m.From,
|
||||||
|
Subject: m.Subject,
|
||||||
|
Date: m.Date,
|
||||||
|
Body: m.Body,
|
||||||
|
})
|
||||||
|
if errors.Is(err, ipc.ErrUnknownMethod) {
|
||||||
|
log.Printf("mavmaild: core has no email block configured — mail ingestion is off; stopping")
|
||||||
|
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) {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
return f.RunWith(password, r.dial)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,254 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/email"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- a scripted IMAP server, same shape internal/email's tests use ---------
|
||||||
|
|
||||||
|
type fakeIMAP struct {
|
||||||
|
msgs map[uint32]string
|
||||||
|
uids []uint32
|
||||||
|
cmds []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIMAP) serve(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
fmt.Fprint(c, "* OK fake ready\r\n")
|
||||||
|
r := bufio.NewReader(c)
|
||||||
|
for {
|
||||||
|
line, err := r.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(strings.TrimRight(line, "\r\n"), " ", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tag, cmd := parts[0], parts[1]
|
||||||
|
f.cmds = append(f.cmds, cmd)
|
||||||
|
upper := strings.ToUpper(cmd)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
|
||||||
|
fmt.Fprintf(c, "%s OK\r\n", tag)
|
||||||
|
case strings.HasPrefix(upper, "UID SEARCH"):
|
||||||
|
var ids []string
|
||||||
|
for _, u := range f.uids {
|
||||||
|
ids = append(ids, strconv.FormatUint(uint64(u), 10))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c, "* SEARCH %s\r\n%s OK\r\n", strings.Join(ids, " "), tag)
|
||||||
|
case strings.HasPrefix(upper, "UID FETCH"):
|
||||||
|
uid, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
|
||||||
|
if raw, ok := f.msgs[uint32(uid)]; ok {
|
||||||
|
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n%s)\r\n", uid, len(raw), raw)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(c, "%s OK\r\n", tag)
|
||||||
|
case strings.HasPrefix(upper, "LOGOUT"):
|
||||||
|
fmt.Fprintf(c, "* BYE\r\n%s OK\r\n", tag)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(c, "%s BAD\r\n", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeIMAP) dial(_ string, timeout time.Duration) (*email.Conn, error) {
|
||||||
|
cli, srv := net.Pipe()
|
||||||
|
go f.serve(srv)
|
||||||
|
return email.NewConn(cli, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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()
|
||||||
|
return &reader{
|
||||||
|
core: core, addr: "mail.example:993", user: "kami", mailbox: "INBOX",
|
||||||
|
lookback: 72 * time.Hour, max: 25, timeout: 5 * time.Second,
|
||||||
|
state: newSeenState(statePath),
|
||||||
|
dial: f.dial,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
// The newsletter is filtered before core is asked: only the real mail crosses.
|
||||||
|
if len(core.got) != 1 {
|
||||||
|
t.Fatalf("core saw %d messages, want 1 (the bulk one must not cross): %+v", len(core.got), core.got)
|
||||||
|
}
|
||||||
|
got := core.got[0]
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+124
-3
@@ -9,6 +9,12 @@
|
|||||||
// Two sources, each its own provenance (the loop's rules trust source):
|
// Two sources, each its own provenance (the loop's rules trust source):
|
||||||
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
||||||
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
|
// - 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
|
// 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
|
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
|
||||||
@@ -37,6 +43,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -52,6 +59,9 @@ func run(args []string) error {
|
|||||||
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
|
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)")
|
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)")
|
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)")
|
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)")
|
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")
|
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
|
||||||
@@ -62,8 +72,24 @@ func run(args []string) error {
|
|||||||
if *socket == "" {
|
if *socket == "" {
|
||||||
return fmt.Errorf("-socket is required")
|
return fmt.Errorf("-socket is required")
|
||||||
}
|
}
|
||||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" {
|
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
|
||||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma and/or -wg")
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -83,9 +109,13 @@ func run(args []string) error {
|
|||||||
kumaKey: *kumaKey,
|
kumaKey: *kumaKey,
|
||||||
wgIface: *wgIface,
|
wgIface: *wgIface,
|
||||||
wgCmd: *wgCmd,
|
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
|
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
|
||||||
t := time.NewTicker(*interval)
|
t := time.NewTicker(*interval)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -108,6 +138,12 @@ type poller struct {
|
|||||||
kumaKey string
|
kumaKey string
|
||||||
wgIface string
|
wgIface string
|
||||||
wgCmd 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
|
// pollOnce — one sweep of both sources. A failure in one source logs and does
|
||||||
@@ -129,6 +165,66 @@ func (p *poller) pollOnce(ctx context.Context) {
|
|||||||
log.Printf("mavpoll: wg: %v", err)
|
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.
|
||||||
|
// 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()
|
||||||
|
if !ok {
|
||||||
|
// Nothing read. Silence, not a zero.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- wireguard: latest handshake → presence signal -------------------------
|
// ---- wireguard: latest handshake → presence signal -------------------------
|
||||||
@@ -301,6 +397,31 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
|
// 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.
|
// errors.Is is the right check; keep a helper so the switch above reads clean.
|
||||||
func isNoFact(err error) bool {
|
func isNoFact(err error) bool {
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/zenmoney"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaxSeverity(t *testing.T) {
|
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,135 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// POST /api/ambient — the work calendar read (Vikunja #126).
|
||||||
|
//
|
||||||
|
// Maven does not hold a work credential. A corp mail or calendar session on the
|
||||||
|
// homelab ties the box's blast radius to the employer's data, so the work
|
||||||
|
// calendar is read as a SIGNAL instead: an Android notification-listener on the
|
||||||
|
// owner's phone posts meeting notifications here over wg/LAN, and the ones that
|
||||||
|
// clearly describe a meeting become calendar events at source=ambient:notif,
|
||||||
|
// confidence below 1.0. Mail as a notification signal, not a mailbox.
|
||||||
|
//
|
||||||
|
// Off unless configured: no -ambient-token, no route. The token is a shared
|
||||||
|
// secret because the poster is a phone service, not a browser — WebAuthn has no
|
||||||
|
// answer for a background Android service. The endpoint is write-only and
|
||||||
|
// accepts exactly one shape of write; it cannot read anything back out.
|
||||||
|
//
|
||||||
|
// A notification with no recognisable clock reading stores NOTHING. Maven is
|
||||||
|
// not a guesser-of-truth, and a mailbox of noise rendered as invented meetings
|
||||||
|
// is worse than a gap.
|
||||||
|
|
||||||
|
// ambientMaxBody bounds the request. A notification is two short lines.
|
||||||
|
const ambientMaxBody = 8 << 10
|
||||||
|
|
||||||
|
type ambientResp struct {
|
||||||
|
Stored bool `json:"stored"`
|
||||||
|
Key string `json:"key,omitempty"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAmbient ingests one relayed notification. token is the configured
|
||||||
|
// shared secret; an empty token means the capability is off and the handler is
|
||||||
|
// never registered, so it is treated as a hard failure here too.
|
||||||
|
func handleAmbient(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, token string) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
http.Error(w, "ambient ingest disabled (no -ambient-token)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ambientAuthorized(r, token) {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "ambient ingest disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var n calendar.Notification
|
||||||
|
body, err := io.ReadAll(io.LimitReader(r.Body, ambientMaxBody))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &n); err != nil {
|
||||||
|
http.Error(w, "bad json", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n.Posted.IsZero() {
|
||||||
|
writeAmbient(w, http.StatusBadRequest, ambientResp{Reason: "posted_at is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ev, ok := calendar.EventFromNotification(n)
|
||||||
|
if !ok {
|
||||||
|
// Not an event. 202: the relay did its job, there is just nothing here
|
||||||
|
// worth remembering, and it must not retry.
|
||||||
|
writeAmbient(w, http.StatusAccepted, ambientResp{Reason: "no meeting time in notification"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key := calendar.FactKey(ev)
|
||||||
|
val := calendar.FactValue(ev)
|
||||||
|
|
||||||
|
// Append-only discipline, same as cmd/mavcaldav: a phone reposts the same
|
||||||
|
// notification many times, and each repost is the same event.
|
||||||
|
if prev, err := core.LatestFactBySource(r.Context(), key, calendar.SourceAmbient); err == nil && prev.Value == val {
|
||||||
|
writeAmbient(w, http.StatusOK, ambientResp{Stored: false, Key: key, Reason: "unchanged"})
|
||||||
|
return
|
||||||
|
} else if err != nil && !errors.Is(err, ipc.ErrNoFact) {
|
||||||
|
log.Printf("ambient: read %s: %v", key, err)
|
||||||
|
http.Error(w, "read failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// kind=env: an observation about the world, never a self-fact — a passive
|
||||||
|
// signal does not write truth about the owner. Confidence below 1.0 is the
|
||||||
|
// honest part: this is a notification about a meeting, not a reading of a
|
||||||
|
// calendar, and the query path hedges when it recites one.
|
||||||
|
if _, err := core.WriteFact(r.Context(), ipc.WriteFactReq{
|
||||||
|
Ts: ev.Start,
|
||||||
|
Kind: "env",
|
||||||
|
Key: key,
|
||||||
|
Value: val,
|
||||||
|
Source: calendar.SourceAmbient,
|
||||||
|
Confidence: calendar.AmbientConfidence,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("ambient: write %s: %v", key, err)
|
||||||
|
http.Error(w, "write failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("ambient: %s=%s (%s, pkg=%s)", key, val, calendar.SourceAmbient, n.Package)
|
||||||
|
writeAmbient(w, http.StatusCreated, ambientResp{Stored: true, Key: key})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ambientAuthorized accepts the token as a bearer header or as an X-Maven-Token
|
||||||
|
// header, compared in constant time.
|
||||||
|
func ambientAuthorized(r *http.Request, token string) bool {
|
||||||
|
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
|
||||||
|
if got == "" {
|
||||||
|
got = strings.TrimSpace(r.Header.Get("X-Maven-Token"))
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(got), []byte(token)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeAmbient(w http.ResponseWriter, code int, resp ambientResp) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/calendar"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ambientTestToken = "s3cret"
|
||||||
|
|
||||||
|
// ambientCore adds provenance-scoped reads to fakeCore, which the dedupe path
|
||||||
|
// needs.
|
||||||
|
type ambientCore struct {
|
||||||
|
fakeCore
|
||||||
|
latest map[string]ipc.Fact // "key|source" → fact
|
||||||
|
readErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ambientCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
|
||||||
|
if c.readErr != nil {
|
||||||
|
return ipc.Fact{}, c.readErr
|
||||||
|
}
|
||||||
|
f, ok := c.latest[key+"|"+source]
|
||||||
|
if !ok {
|
||||||
|
return ipc.Fact{}, ipc.ErrNoFact
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func postAmbient(t *testing.T, core ipc.CoreAPI, token string, n calendar.Notification) (*httptest.ResponseRecorder, ambientResp) {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(n)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(string(body)))
|
||||||
|
req.Header.Set("Authorization", "Bearer "+ambientTestToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, req, core, token)
|
||||||
|
var resp ambientResp
|
||||||
|
json.Unmarshal(rr.Body.Bytes(), &resp)
|
||||||
|
return rr, resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func meetingNotification() calendar.Notification {
|
||||||
|
return calendar.Notification{
|
||||||
|
Package: "com.google.android.gm",
|
||||||
|
Title: "Планёрка",
|
||||||
|
Text: "10:00-10:30",
|
||||||
|
Posted: time.Date(2026, 8, 3, 9, 40, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAmbientStoresMeeting(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
if !resp.Stored {
|
||||||
|
t.Errorf("resp = %+v, want stored", resp)
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Fatalf("expected 1 fact write, got %d", len(core.writeLog))
|
||||||
|
}
|
||||||
|
got := core.writeLog[0]
|
||||||
|
if got.Source != calendar.SourceAmbient {
|
||||||
|
t.Errorf("source = %q, want %q", got.Source, calendar.SourceAmbient)
|
||||||
|
}
|
||||||
|
if got.Confidence >= 1.0 {
|
||||||
|
t.Errorf("confidence = %v — a notification is not a calendar read", got.Confidence)
|
||||||
|
}
|
||||||
|
if got.Confidence != calendar.AmbientConfidence {
|
||||||
|
t.Errorf("confidence = %v, want %v", got.Confidence, calendar.AmbientConfidence)
|
||||||
|
}
|
||||||
|
if got.Kind != "env" {
|
||||||
|
t.Errorf("kind = %q — a passive signal never writes a self-fact", got.Kind)
|
||||||
|
}
|
||||||
|
if want := "calendar_event_20260803_"; !strings.HasPrefix(got.Key, want) {
|
||||||
|
t.Errorf("key = %q, want prefix %q", got.Key, want)
|
||||||
|
}
|
||||||
|
if got.Value != "Планёрка @ 10:00-10:30" {
|
||||||
|
t.Errorf("value = %q", got.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A phone reposts the same notification many times. Each repost is the same
|
||||||
|
// event, and the append-only log must not fill with duplicates.
|
||||||
|
func TestHandleAmbientDedupesReposts(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Fatalf("first post did not write")
|
||||||
|
}
|
||||||
|
w := core.writeLog[0]
|
||||||
|
core.latest = map[string]ipc.Fact{w.Key + "|" + w.Source: {Value: w.Value}}
|
||||||
|
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200 for an unchanged repost", rr.Code)
|
||||||
|
}
|
||||||
|
if resp.Stored {
|
||||||
|
t.Error("a repost must not be stored again")
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 1 {
|
||||||
|
t.Errorf("wrote %d facts, want 1", len(core.writeLog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The conservative half: noise stores nothing at all.
|
||||||
|
func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr, resp := postAmbient(t, core, ambientTestToken, calendar.Notification{
|
||||||
|
Package: "com.google.android.gm",
|
||||||
|
Title: "3 новых письма",
|
||||||
|
Posted: time.Now(),
|
||||||
|
})
|
||||||
|
if rr.Code != http.StatusAccepted {
|
||||||
|
t.Errorf("status = %d, want 202 (accepted, nothing to store — the relay must not retry)", rr.Code)
|
||||||
|
}
|
||||||
|
if resp.Stored {
|
||||||
|
t.Error("a notification with no meeting time must store nothing")
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 0 {
|
||||||
|
t.Fatalf("wrote %d facts for a non-meeting", len(core.writeLog))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAmbientAuth(t *testing.T) {
|
||||||
|
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
||||||
|
|
||||||
|
newReq := func(hdr, val string) *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||||
|
if hdr != "" {
|
||||||
|
r.Header.Set(hdr, val)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("no token rejected", func(t *testing.T) {
|
||||||
|
core := &ambientCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("", ""), core, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
if len(core.writeLog) != 0 {
|
||||||
|
t.Error("an unauthorized post must not write")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrong token rejected", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("Authorization", "Bearer nope"), &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("X-Maven-Token accepted", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("X-Maven-Token", ambientTestToken), &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Errorf("status = %d, want 201: %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("capability off", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, newReq("Authorization", "Bearer "+ambientTestToken), &ambientCore{}, "")
|
||||||
|
if rr.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503 when no token is configured", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("GET rejected", func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/ambient", nil)
|
||||||
|
handleAmbient(rr, r, &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status = %d, want 405 — the ingest is write-only", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAmbientBadInput(t *testing.T) {
|
||||||
|
t.Run("bad json", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader("{nope"))
|
||||||
|
req.Header.Set("X-Maven-Token", ambientTestToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing posted_at", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(`{"title":"Планёрка 10:00"}`))
|
||||||
|
req.Header.Set("X-Maven-Token", ambientTestToken)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleAmbient(rr, req, &ambientCore{}, ambientTestToken)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("read error surfaces", func(t *testing.T) {
|
||||||
|
core := &ambientCore{readErr: fmt.Errorf("socket closed")}
|
||||||
|
rr, _ := postAmbient(t, core, ambientTestToken, meetingNotification())
|
||||||
|
if rr.Code != http.StatusBadGateway {
|
||||||
|
t.Errorf("status = %d, want 502", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -17,11 +17,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// fakeCore records the mutating calls handleTools makes and returns canned
|
// fakeCore records the mutating calls handleTools makes and returns canned
|
||||||
// tool lists / errors. Embedding ipc.CoreAPI (nil) satisfies the large
|
// tool lists / errors. Embedding ipc.UnimplementedCoreAPI satisfies the large
|
||||||
// interface — only the methods the handlers touch are overridden; any other
|
// interface — only the methods the handlers touch are overridden; any other
|
||||||
// call would nil-panic, which is fine since the handlers never make them.
|
// call returns ipc.ErrNotImplemented instead of nil-panicking, so a test that
|
||||||
|
// accidentally exercises an undeclared method fails loudly.
|
||||||
type fakeCore struct {
|
type fakeCore struct {
|
||||||
ipc.CoreAPI
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
proposed, enabled []ipc.Tool
|
proposed, enabled []ipc.Tool
|
||||||
listErr error
|
listErr error
|
||||||
@@ -62,6 +63,18 @@ type fakeCore struct {
|
|||||||
// for handleTrace tests
|
// for handleTrace tests
|
||||||
tickTrace ipc.TickTrace
|
tickTrace ipc.TickTrace
|
||||||
traceErr error
|
traceErr error
|
||||||
|
|
||||||
|
// for handleChatAPI tests
|
||||||
|
chatText string
|
||||||
|
chatErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
|
||||||
|
f.chatText = text
|
||||||
|
if f.chatErr != nil {
|
||||||
|
return "", f.chatErr
|
||||||
|
}
|
||||||
|
return "поняла", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
func (f *fakeCore) EnableTool(_ context.Context, name string, cmd []string, destructive bool, scope string, _ time.Time) error {
|
||||||
@@ -1044,3 +1057,64 @@ func TestHandleRoutines_NilCore_503(t *testing.T) {
|
|||||||
t.Fatalf("status = %d, want 503", rr.Code)
|
t.Fatalf("status = %d, want 503", rr.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- handleChatAPI step-up gate (Vikunja #317) ---
|
||||||
|
//
|
||||||
|
// POST /api/chat reaches the router, the LLM and the act path, so it carries
|
||||||
|
// the same gate as POST /tools and POST /api/revert.
|
||||||
|
|
||||||
|
func postChat(text string) *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/chat", strings.NewReader("text="+url.QueryEscape(text)))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatAPI_RequireStepUp_FailsClosed(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("выключи свет"), core, nil, true)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.chatText != "" {
|
||||||
|
t.Errorf("core.Chat called with %q, but -require-stepup should deny", core.chatText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatAPI_UnassertedSession_Denied(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("выключи свет"), core, webauthn.NewPasskeySession(5*time.Minute), false)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
if core.chatText != "" {
|
||||||
|
t.Errorf("core.Chat called with %q despite an unasserted session", core.chatText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleChatAPI_AssertedSession_PassesGate(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("привет"), core, stepUpSession(), true)
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.chatText != "привет" {
|
||||||
|
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default deploy: WebAuthn unconfigured and -require-stepup off ⇒ chat keeps
|
||||||
|
// working, resting on the transport-level auth in front of mavweb.
|
||||||
|
func TestHandleChatAPI_FailOpenByDefault(t *testing.T) {
|
||||||
|
core := &fakeCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleChatAPI(rr, postChat("привет"), core, nil, false)
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status = %d, want 303", rr.Code)
|
||||||
|
}
|
||||||
|
if core.chatText != "привет" {
|
||||||
|
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+262
-10
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/pattern"
|
"github.com/kami/maven/internal/pattern"
|
||||||
|
"github.com/kami/maven/internal/tasks"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
@@ -58,6 +59,9 @@ var notificationsHTML string
|
|||||||
//go:embed reminders.html
|
//go:embed reminders.html
|
||||||
var remindersHTML string
|
var remindersHTML string
|
||||||
|
|
||||||
|
//go:embed tasks.html
|
||||||
|
var tasksHTML string
|
||||||
|
|
||||||
//go:embed voice.html
|
//go:embed voice.html
|
||||||
var voiceHTML string
|
var voiceHTML string
|
||||||
|
|
||||||
@@ -97,6 +101,7 @@ var sidebarSections = []struct {
|
|||||||
Pages: []struct{ Label, URL, Key string }{
|
Pages: []struct{ Label, URL, Key string }{
|
||||||
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
{Label: "Rule Trace", URL: "/trace", Key: "trace"},
|
||||||
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
{Label: "Notifications", URL: "/notifications", Key: "notifications"},
|
||||||
|
{Label: "Tasks", URL: "/tasks", Key: "tasks"},
|
||||||
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
|
||||||
{Label: "Routines", URL: "/routines", Key: "routines"},
|
{Label: "Routines", URL: "/routines", Key: "routines"},
|
||||||
{Label: "Morning", URL: "/morning", Key: "morning"},
|
{Label: "Morning", URL: "/morning", Key: "morning"},
|
||||||
@@ -170,6 +175,8 @@ func pageIcon(key string) string {
|
|||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-bell"/></svg>`
|
||||||
|
case "tasks":
|
||||||
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-calendar"/></svg>`
|
||||||
case "routines":
|
case "routines":
|
||||||
@@ -202,6 +209,8 @@ func pageTitle(key string) string {
|
|||||||
return "Rule Trace"
|
return "Rule Trace"
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return "Notifications"
|
return "Notifications"
|
||||||
|
case "tasks":
|
||||||
|
return "Tasks"
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return "Reminders"
|
return "Reminders"
|
||||||
case "routines":
|
case "routines":
|
||||||
@@ -314,7 +323,7 @@ func noCache(h http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := flag.String("addr", ":9200", "HTTP listen address")
|
addr := flag.String("addr", "127.0.0.1:9200", "HTTP listen address (loopback by default; pass e.g. \":9200\" or a LAN IP deliberately for wider exposure — POST /chat and /routines are state-changing)")
|
||||||
voiceAddr := flag.String("voice", "127.0.0.1:9100", "voice server TCP addr (host:port)")
|
voiceAddr := flag.String("voice", "127.0.0.1:9100", "voice server TCP addr (host:port)")
|
||||||
// ntfyWS: the ntfy WebSocket subscribe URL the PWA connects to for in-app
|
// ntfyWS: the ntfy WebSocket subscribe URL the PWA connects to for in-app
|
||||||
// nudge delivery, e.g. wss://ntfy.kvmx.ru/maven/ws?auth=<base64-token>. The
|
// nudge delivery, e.g. wss://ntfy.kvmx.ru/maven/ws?auth=<base64-token>. The
|
||||||
@@ -329,11 +338,15 @@ func main() {
|
|||||||
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
coreSock := flag.String("core", "", "mavend IPC socket path for presence-signal ingest (empty = disabled)")
|
||||||
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
pkOrigin := flag.String("webauthn-origin", "", "WebAuthn origin URL (e.g. https://maven.kvmx.ru)")
|
||||||
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
pkRPID := flag.String("webauthn-rpid", "", "WebAuthn RP ID (e.g. maven.kvmx.ru)")
|
||||||
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (/tools POST, /api/revert) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
requireStepUp := flag.Bool("require-stepup", false, "fail closed on step-up-gated actions (POST /tools, /routines, /api/revert, /api/chat) when WebAuthn step-up cannot be asserted; default false preserves the historical fail-open behaviour")
|
||||||
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
pkFile := flag.String("passkey-file", "./passkeys.json", "path to WebAuthn credential store (JSON)")
|
||||||
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
nexusURL := flag.String("nexus", "", "Nexus base URL for the /ecosystem panel (empty = not configured)")
|
||||||
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
praxisURL := flag.String("praxis", "", "Praxis base URL for the /ecosystem panel (empty = not configured)")
|
||||||
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
hexisURL := flag.String("hexis", "", "Hexis base URL for the /ecosystem panel (empty = not configured)")
|
||||||
|
// Shared secret for POST /api/ambient, the notification-relay ingest that
|
||||||
|
// reads the work calendar as a signal instead of holding a work credential
|
||||||
|
// (see ambient.go). Empty ⇒ the route is not registered at all.
|
||||||
|
ambientToken := flag.String("ambient-token", "", "shared secret for POST /api/ambient notification ingest (empty = ingest disabled, route not registered)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
var core ipc.CoreAPI
|
var core ipc.CoreAPI
|
||||||
@@ -381,6 +394,14 @@ func main() {
|
|||||||
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleSignal(w, r, core)
|
handleSignal(w, r, core)
|
||||||
})
|
})
|
||||||
|
// Off unless configured: no token, no route — an unconfigured ingest is not
|
||||||
|
// a 503 waiting to be probed, it does not exist.
|
||||||
|
if *ambientToken != "" {
|
||||||
|
mux.HandleFunc("/api/ambient", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleAmbient(w, r, core, *ambientToken)
|
||||||
|
})
|
||||||
|
log.Printf("mavweb: ambient notification ingest enabled at POST /api/ambient")
|
||||||
|
}
|
||||||
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/dash", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleDash(w, r, core)
|
handleDash(w, r, core)
|
||||||
})
|
})
|
||||||
@@ -396,6 +417,11 @@ func main() {
|
|||||||
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/reminders", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleReminders(w, r, core)
|
handleReminders(w, r, core)
|
||||||
})
|
})
|
||||||
|
// /tasks — capture + review. POST is not step-up gated; see handleTasks for
|
||||||
|
// why a task write is not in the same class as /tools or /routines.
|
||||||
|
mux.HandleFunc("/tasks", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
handleTasks(w, r, core)
|
||||||
|
})
|
||||||
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleMorning(w, r, core)
|
handleMorning(w, r, core)
|
||||||
})
|
})
|
||||||
@@ -434,9 +460,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
if stepUpSession == nil {
|
if stepUpSession == nil {
|
||||||
if *requireStepUp {
|
if *requireStepUp {
|
||||||
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv) and POST /api/revert will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
log.Printf("SECURITY: step-up verification is DISABLED (-webauthn-origin/-webauthn-rpid unset) and -require-stepup is set: POST /tools (tool enable/disable/dismiss — defines and executes arbitrary argv), POST /routines (accepting schedules recurring firing), POST /api/revert and POST /api/chat (reaches the router, the LLM and the act path) will be DENIED (403). Set -webauthn-origin and -webauthn-rpid to enable passkey step-up.")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES) and POST /api/revert (voids the latest fact for a key). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
log.Printf("SECURITY WARNING: step-up verification is DISABLED because -webauthn-origin/-webauthn-rpid are unset. UNGUARDED SURFACES: POST /tools (defines arbitrary argv via name+cmd, which internal/tool then EXECUTES), POST /routines (accepting schedules recurring firing), POST /api/revert (voids the latest fact for a key) and POST /api/chat (reaches the router, the LLM and, through applyAction, the act path). These are protected only by whatever transport-level auth sits in front of mavweb (wg+nginx+auth) — do NOT expose -addr on a public interface. Set -webauthn-origin and -webauthn-rpid to require passkey step-up, or pass -require-stepup to fail closed instead.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,14 +480,26 @@ func main() {
|
|||||||
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
|
|
||||||
// /api/revert voids the latest fact for a key — a store mutation, so it
|
// State-changing routes on this server, and their gate (Vikunja #317):
|
||||||
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
//
|
||||||
// WebAuthn unconfigured ⇒ transport-level auth only, same as /tools).
|
// POST /tools step-up — defines argv that internal/tool executes
|
||||||
|
// POST /routines step-up — accepting schedules recurring firing
|
||||||
|
// POST /api/revert step-up — voids the latest fact for a key
|
||||||
|
// POST /api/chat step-up — reaches the router, LLM and the act path
|
||||||
|
// POST /api/signal none — appends a presence fact, no argv, no act
|
||||||
|
// POST /api/ptt, /ws none — proxy audio to mavend's voice port, which
|
||||||
|
// is itself only reachable inside the deploy
|
||||||
|
//
|
||||||
|
// "step-up" means stepUpOK: asserted passkey when WebAuthn is configured,
|
||||||
|
// otherwise fail-open unless -require-stepup, which denies.
|
||||||
|
//
|
||||||
|
// GET /chat only renders the page and echoes back the q/r query params the
|
||||||
|
// POST redirect set — nothing to gate.
|
||||||
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleChatPage(w, r, core)
|
handleChatPage(w, r, core)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleChatAPI(w, r, core)
|
handleChatAPI(w, r, core, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/revert", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
||||||
@@ -720,6 +758,8 @@ var passkeyTmpl = template.Must(template.New("passkey").Funcs(shellFuncs()).Pars
|
|||||||
|
|
||||||
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
var voiceTmpl = template.Must(template.New("voice").Funcs(shellFuncs()).Parse(shellTopHTML + voiceHTML + shellBottomHTML))
|
||||||
|
|
||||||
|
var tasksTmpl = template.Must(template.New("tasks").Funcs(shellFuncs()).Parse(shellTopHTML + tasksHTML + shellBottomHTML))
|
||||||
|
|
||||||
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
var routinesTmpl = template.Must(template.New("routines").Funcs(shellFuncs()).Parse(shellTopHTML + routinesHTML + shellBottomHTML))
|
||||||
|
|
||||||
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
var traceTmpl = template.Must(template.New("trace").Funcs(func() template.FuncMap {
|
||||||
@@ -790,6 +830,187 @@ func handleReminders(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// taskRow is one line on /tasks, with every timestamp already formatted so the
|
||||||
|
// template holds no date logic.
|
||||||
|
type taskRow struct {
|
||||||
|
ID int64
|
||||||
|
Text string
|
||||||
|
Source string
|
||||||
|
Evidence string
|
||||||
|
Status string
|
||||||
|
Due string
|
||||||
|
Created string
|
||||||
|
Resolved string
|
||||||
|
// Why — the ranker's reason for this row's position (Vikunja #129), in
|
||||||
|
// Russian, empty when nothing distinguished the task. Blank is the honest
|
||||||
|
// rendering: he never said this one mattered more.
|
||||||
|
Why string
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTasks serves the task review surface (GET) and the four writes it
|
||||||
|
// offers (POST): add, confirm, done, drop.
|
||||||
|
//
|
||||||
|
// Not step-up gated, unlike /tools and /routines, and the difference is the
|
||||||
|
// point: enabling a tool defines argv Maven will execute, and accepting a
|
||||||
|
// routine hands the tick loop a new standing reason to interrupt him. A task is
|
||||||
|
// neither — nothing in the tick loop reads the tasks table, so the worst a
|
||||||
|
// weaker caller can do here is write a line onto a list he reads himself. It
|
||||||
|
// still sits behind whatever transport auth fronts mavweb, like every other
|
||||||
|
// page.
|
||||||
|
//
|
||||||
|
// "confirm" is the only interesting move: it promotes a candidate Maven derived
|
||||||
|
// from something she read into work he owns. That review step is why derived
|
||||||
|
// tasks are captured as candidates in the first place.
|
||||||
|
func handleTasks(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||||
|
if core == nil {
|
||||||
|
http.Error(w, "tasks disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
var msg, errMsg string
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
var err error
|
||||||
|
msg, err = applyTaskPost(ctx, core, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tasks: %v", err)
|
||||||
|
errMsg = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := core.ListTasks(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("tasks: list: %v", err)
|
||||||
|
http.Error(w, "tasks error: "+err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Live rows are ordered by the same ranker the spoken list uses, so the page
|
||||||
|
// and the voice reply can never disagree about what comes first. Resolved
|
||||||
|
// rows keep store order (newest first) — ranking finished work is pointless.
|
||||||
|
var live []tasks.Item
|
||||||
|
var resolved []taskRow
|
||||||
|
for _, t := range all {
|
||||||
|
switch t.Status {
|
||||||
|
case "candidate", "open":
|
||||||
|
live = append(live, tasks.Item{
|
||||||
|
ID: t.ID, Text: t.Text, Status: t.Status,
|
||||||
|
Created: t.CreatedTs, Due: t.Due, Weight: t.Weight,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
resolved = append(resolved, taskRow{
|
||||||
|
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||||
|
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||||
|
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byID := make(map[int64]ipc.Task, len(all))
|
||||||
|
for _, t := range all {
|
||||||
|
byID[t.ID] = t
|
||||||
|
}
|
||||||
|
var cands, open []taskRow
|
||||||
|
for _, r := range tasks.Rank(live, time.Now()) {
|
||||||
|
t := byID[r.ID]
|
||||||
|
row := taskRow{
|
||||||
|
ID: t.ID, Text: t.Text, Source: t.Source, Evidence: t.Evidence,
|
||||||
|
Status: t.Status, Created: fmtTaskTime(&t.CreatedTs),
|
||||||
|
Due: fmtTaskDate(t.Due), Resolved: fmtTaskTime(t.Resolved),
|
||||||
|
Why: r.Reason,
|
||||||
|
}
|
||||||
|
if t.Status == "candidate" {
|
||||||
|
// A candidate's due date is Maven's reading of a mail, so its
|
||||||
|
// ranking reason is not shown as if he had set a priority.
|
||||||
|
row.Why = ""
|
||||||
|
cands = append(cands, row)
|
||||||
|
} else {
|
||||||
|
open = append(open, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := tasksTmpl.Execute(w, struct {
|
||||||
|
Msg, Err string
|
||||||
|
Candidates []taskRow
|
||||||
|
Open []taskRow
|
||||||
|
Resolved []taskRow
|
||||||
|
}{msg, errMsg, cands, open, resolved}); err != nil {
|
||||||
|
log.Printf("tasks render: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTaskPost performs one write and returns the message to show. A bad
|
||||||
|
// request returns an error, which the page renders inline rather than as a
|
||||||
|
// bare 400 — this is a form surface, not an API.
|
||||||
|
func applyTaskPost(ctx context.Context, core ipc.CoreAPI, r *http.Request) (string, error) {
|
||||||
|
action := r.FormValue("action")
|
||||||
|
if action == "add" {
|
||||||
|
text := strings.TrimSpace(r.FormValue("text"))
|
||||||
|
if text == "" {
|
||||||
|
return "", errors.New("empty task text")
|
||||||
|
}
|
||||||
|
req := ipc.CaptureTaskReq{Text: text, Source: "tap:web", Status: "open", Ts: time.Now()}
|
||||||
|
// Importance is his, stated on the form. Out-of-range values are
|
||||||
|
// clamped rather than rejected — a bad select is not worth a 400.
|
||||||
|
if v := r.FormValue("weight"); v != "" {
|
||||||
|
var wgt int
|
||||||
|
if n, _ := fmt.Sscanf(v, "%d", &wgt); n != 1 || wgt < 0 {
|
||||||
|
return "", fmt.Errorf("bad weight %q", v)
|
||||||
|
}
|
||||||
|
if wgt > tasks.MaxWeight {
|
||||||
|
wgt = tasks.MaxWeight
|
||||||
|
}
|
||||||
|
req.Weight = wgt
|
||||||
|
}
|
||||||
|
if d := r.FormValue("due"); d != "" {
|
||||||
|
due, err := time.ParseInLocation("2006-01-02", d, time.Local)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("bad due date %q", d)
|
||||||
|
}
|
||||||
|
req.Due = &due
|
||||||
|
}
|
||||||
|
resp, err := core.CaptureTask(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !resp.Created {
|
||||||
|
return "already on the list", nil
|
||||||
|
}
|
||||||
|
return "added task", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
if n, _ := fmt.Sscanf(r.FormValue("id"), "%d", &id); n != 1 {
|
||||||
|
return "", errors.New("invalid id")
|
||||||
|
}
|
||||||
|
var status, msg string
|
||||||
|
switch action {
|
||||||
|
case "confirm":
|
||||||
|
status, msg = "open", "confirmed task"
|
||||||
|
case "done":
|
||||||
|
status, msg = "done", "task done"
|
||||||
|
case "drop":
|
||||||
|
status, msg = "dropped", "dropped task"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unknown action %q", action)
|
||||||
|
}
|
||||||
|
if err := core.SetTaskStatus(ctx, id, status, time.Now()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return msg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtTaskTime(t *time.Time) string {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return t.Local().Format("02 Jan 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fmtTaskDate(t *time.Time) string {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return t.Local().Format("02 Jan")
|
||||||
|
}
|
||||||
|
|
||||||
// routineRow is one line on the page: what maven noticed, in her words, and
|
// routineRow is one line on the page: what maven noticed, in her words, and
|
||||||
// how long ago she noticed it.
|
// how long ago she noticed it.
|
||||||
type routineRow struct {
|
type routineRow struct {
|
||||||
@@ -935,12 +1156,32 @@ func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
view := morningView{Routines: status}
|
||||||
|
// The day plan (#128) shows on this page because it is the same question at
|
||||||
|
// a different scale. A plan read that fails must not take the checklist
|
||||||
|
// down with it — the page degrades to what it had before.
|
||||||
|
plan, err := core.DayPlan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("morning: day plan: %v", err)
|
||||||
|
view.PlanErr = err.Error()
|
||||||
|
} else {
|
||||||
|
view.Plan = &plan
|
||||||
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := morningTmpl.Execute(w, status); err != nil {
|
if err := morningTmpl.Execute(w, view); err != nil {
|
||||||
log.Printf("morning render: %v", err)
|
log.Printf("morning render: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// morningView — what /morning renders: today's plan on top, the checklist
|
||||||
|
// state under it. PlanErr is set instead of Plan when the core could not build
|
||||||
|
// a plan, so the page says so rather than showing an empty day.
|
||||||
|
type morningView struct {
|
||||||
|
Plan *ipc.DayPlan
|
||||||
|
PlanErr string
|
||||||
|
Routines []ipc.MorningRoutineStatus
|
||||||
|
}
|
||||||
|
|
||||||
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := voiceTmpl.Execute(w, nil); err != nil {
|
if err := voiceTmpl.Execute(w, nil); err != nil {
|
||||||
@@ -1258,7 +1499,14 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
||||||
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
//
|
||||||
|
// State-changing, and the widest surface on this server: the text reaches the
|
||||||
|
// router, the LLM, and through mavend's applyAction the whole action path
|
||||||
|
// including `act` — so it is gated on the same step-up as POST /tools and
|
||||||
|
// POST /api/revert (Vikunja #317). With WebAuthn unconfigured the gate is
|
||||||
|
// fail-open exactly like the others (see stepUpOK); with -require-stepup it
|
||||||
|
// denies, which is the point of that flag.
|
||||||
|
func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -1267,6 +1515,10 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !stepUpOK(session, requireStepUp) {
|
||||||
|
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
text := strings.TrimSpace(r.FormValue("text"))
|
text := strings.TrimSpace(r.FormValue("text"))
|
||||||
if text == "" {
|
if text == "" {
|
||||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||||
|
|||||||
+20
-2
@@ -1,9 +1,27 @@
|
|||||||
{{template "shellTop" "morning"}}
|
{{template "shellTop" "morning"}}
|
||||||
|
<h1>Today</h1>
|
||||||
|
{{with .Plan}}
|
||||||
|
<div class=hint>{{.Date.Format "02.01.2006"}}</div>
|
||||||
|
{{if not .Items}}
|
||||||
|
<div class=hint>nothing planned</div>
|
||||||
|
{{else}}
|
||||||
|
<div class=scroll><table class=mono>
|
||||||
|
<tr><th>at<th>kind<th>what</tr>
|
||||||
|
{{range .Items}}<tr>
|
||||||
|
<td>{{.At.Format "15:04"}}</td>
|
||||||
|
<td class=gray>{{.Kind}}</td>
|
||||||
|
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
||||||
|
</tr>{{end}}
|
||||||
|
</table></div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{if .PlanErr}}<div class=hint>plan unavailable: {{.PlanErr}}</div>{{end}}
|
||||||
|
|
||||||
<h1>Morning Routines</h1>
|
<h1>Morning Routines</h1>
|
||||||
{{if not .}}
|
{{if not .Routines}}
|
||||||
<div class=hint>no morning routines configured</div>
|
<div class=hint>no morning routines configured</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{range .}}
|
{{range .Routines}}
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div><strong>{{.Name}}</strong>
|
<div><strong>{{.Name}}</strong>
|
||||||
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
|
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{{template "shellTop" "tasks"}}
|
||||||
|
<h1>Tasks</h1>
|
||||||
|
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
|
||||||
|
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
|
||||||
|
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>add</h2>
|
||||||
|
<form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=action value=add>
|
||||||
|
<input type=text name=text placeholder="что нужно сделать" size=44 required>
|
||||||
|
<input type=date name=due title="due date (optional)">
|
||||||
|
<select name=weight title="importance (optional)">
|
||||||
|
<option value=0>normal</option>
|
||||||
|
<option value=2>важно</option>
|
||||||
|
<option value=3>срочно</option>
|
||||||
|
</select>
|
||||||
|
<button class=btn>add</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .Candidates}}
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>found, not confirmed <span class=badge>{{len .Candidates}}</span></h2>
|
||||||
|
<div class=hint>maven derived these from something she read. nothing counts as your work until you confirm it.</div>
|
||||||
|
<div class=scroll><table>
|
||||||
|
<tr><th>task</th><th>where from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||||
|
{{range .Candidates}}<tr>
|
||||||
|
<td class=text-max>{{.Text}}</td>
|
||||||
|
<td class=hint>{{.Source}}{{if .Evidence}} — {{.Evidence}}{{end}}</td>
|
||||||
|
<td>{{.Due}}</td>
|
||||||
|
<td class=muted>{{.Created}}</td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=confirm>
|
||||||
|
<button class=btn>confirm</button></form></td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=drop>
|
||||||
|
<button class="btn btn-muted">drop</button></form></td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>open <span class=badge>{{len .Open}}</span></h2>
|
||||||
|
<div class=hint>most pressing first — by the deadlines and the urgency you gave, nothing guessed.</div>
|
||||||
|
{{if .Open}}<div class=scroll><table>
|
||||||
|
<tr><th>task</th><th>why</th><th>from</th><th>due</th><th>captured</th><th></th><th></th></tr>
|
||||||
|
{{range .Open}}<tr>
|
||||||
|
<td class=text-max>{{.Text}}</td>
|
||||||
|
<td class=hint>{{.Why}}</td>
|
||||||
|
<td class=hint>{{.Source}}</td>
|
||||||
|
<td>{{.Due}}</td>
|
||||||
|
<td class=muted>{{.Created}}</td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=done>
|
||||||
|
<button class=btn>done</button></form></td>
|
||||||
|
<td><form method=post action=/tasks class=inline-form>
|
||||||
|
<input type=hidden name=id value="{{.ID}}">
|
||||||
|
<input type=hidden name=action value=drop>
|
||||||
|
<button class="btn btn-muted">drop</button></form></td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
{{else}}<div class=empty>
|
||||||
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-grid"/></svg>
|
||||||
|
<div>no open tasks</div>
|
||||||
|
<div class=hint>add one above, or tell maven "добавь в задачи …"</div>
|
||||||
|
</div>{{end}}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{if .Resolved}}
|
||||||
|
<section class=card>
|
||||||
|
<h2 class=card-title>resolved <span class=badge>{{len .Resolved}}</span></h2>
|
||||||
|
<div class=scroll><table>
|
||||||
|
<tr><th>task</th><th>status</th><th>when</th></tr>
|
||||||
|
{{range .Resolved}}<tr>
|
||||||
|
<td class=text-max>{{.Text}}</td>
|
||||||
|
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||||
|
<td class=muted>{{.Resolved}}</td>
|
||||||
|
</tr>{{end}}</table></div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
{{template "shellBottom"}}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/tasks"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeTaskCore serves the /tasks handler: a canned list plus a log of the
|
||||||
|
// writes the page made.
|
||||||
|
type fakeTaskCore struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
|
tasks []ipc.Task
|
||||||
|
listErr error
|
||||||
|
|
||||||
|
captured []ipc.CaptureTaskReq
|
||||||
|
created bool
|
||||||
|
captureErr error
|
||||||
|
|
||||||
|
statusID int64
|
||||||
|
statusVal string
|
||||||
|
statusErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||||
|
if f.listErr != nil {
|
||||||
|
return nil, f.listErr
|
||||||
|
}
|
||||||
|
return f.tasks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||||
|
f.captured = append(f.captured, req)
|
||||||
|
if f.captureErr != nil {
|
||||||
|
return ipc.CaptureTaskResp{}, f.captureErr
|
||||||
|
}
|
||||||
|
return ipc.CaptureTaskResp{ID: 7, Created: f.created}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time) error {
|
||||||
|
f.statusID, f.statusVal = id, status
|
||||||
|
return f.statusErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||||
|
resolved := now.Add(time.Hour)
|
||||||
|
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now},
|
||||||
|
{ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now},
|
||||||
|
{ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved},
|
||||||
|
}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
for _, want := range []string{
|
||||||
|
"купить молоко", "продлить страховку", "полить цветы",
|
||||||
|
"полис истекает", // the evidence trail is visible for review
|
||||||
|
"found, not confirmed", // candidates get their own section
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("body missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The candidate must offer confirm, and the open task must not.
|
||||||
|
if !strings.Contains(body, "value=confirm") {
|
||||||
|
t.Error("candidate row has no confirm action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksAddCaptures(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: true}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {" позвонить в банк "}, "due": {"2026-08-05"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, req, core)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if len(core.captured) != 1 {
|
||||||
|
t.Fatalf("captured %d requests, want 1", len(core.captured))
|
||||||
|
}
|
||||||
|
got := core.captured[0]
|
||||||
|
if got.Text != "позвонить в банк" {
|
||||||
|
t.Errorf("text = %q, want trimmed", got.Text)
|
||||||
|
}
|
||||||
|
if got.Source != "tap:web" {
|
||||||
|
t.Errorf("source = %q, want tap:web", got.Source)
|
||||||
|
}
|
||||||
|
if got.Status != "open" {
|
||||||
|
t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status)
|
||||||
|
}
|
||||||
|
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
||||||
|
t.Errorf("due = %v", got.Due)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "added task") {
|
||||||
|
t.Error("no confirmation message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: false}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {"купить молоко"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, req, core)
|
||||||
|
if !strings.Contains(rec.Body.String(), "already on the list") {
|
||||||
|
t.Error("a deduped capture must not claim it saved something new")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksStatusActions(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ action, want string }{
|
||||||
|
{"confirm", "open"},
|
||||||
|
{"done", "done"},
|
||||||
|
{"drop", "dropped"},
|
||||||
|
} {
|
||||||
|
core := &fakeTaskCore{}
|
||||||
|
form := url.Values{"action": {tc.action}, "id": {"42"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
handleTasks(httptest.NewRecorder(), req, core)
|
||||||
|
if core.statusID != 42 || core.statusVal != tc.want {
|
||||||
|
t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksRejectsBadPost(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{}
|
||||||
|
form := url.Values{"action": {"explode"}, "id": {"1"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, req, core)
|
||||||
|
// The page still renders, with the error inline — and nothing was written.
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d", rec.Code)
|
||||||
|
}
|
||||||
|
if core.statusVal != "" || len(core.captured) != 0 {
|
||||||
|
t.Error("an unknown action must write nothing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "unknown action") {
|
||||||
|
t.Error("error not surfaced on the page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleTasksNoCore(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil)
|
||||||
|
if rec.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("status = %d, want 503", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The open list is ordered by the ranker, and the reason is shown so the page
|
||||||
|
// says why a task is first instead of asking him to trust the order.
|
||||||
|
func TestHandleTasksOrdersOpenByRank(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
due := now
|
||||||
|
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "купить молоко", Status: "open", CreatedTs: now},
|
||||||
|
{ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: now, Due: &due},
|
||||||
|
}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||||
|
body := rec.Body.String()
|
||||||
|
if strings.Index(body, "оплатить интернет") > strings.Index(body, "купить молоко") {
|
||||||
|
t.Error("want the dated task rendered first")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "сегодня") {
|
||||||
|
t.Error("want the ranker's reason shown in the why column")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A candidate is ranked into place but never carries a priority reason: its due
|
||||||
|
// date is Maven's reading of a mail, not something he stated.
|
||||||
|
func TestHandleTasksHidesCandidateReason(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
due := now
|
||||||
|
core := &fakeTaskCore{tasks: []ipc.Task{
|
||||||
|
{ID: 1, Text: "продлить страховку", Status: "candidate", CreatedTs: now, Due: &due},
|
||||||
|
}}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
||||||
|
if strings.Contains(rec.Body.String(), "сегодня") {
|
||||||
|
t.Error("a candidate must not be shown with a priority reason")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyTaskPostCarriesWeight(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: true}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {"оплатить интернет"}, "weight": {"3"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
handleTasks(httptest.NewRecorder(), req, core)
|
||||||
|
if len(core.captured) != 1 || core.captured[0].Weight != 3 {
|
||||||
|
t.Fatalf("captured = %+v, want weight 3", core.captured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||||
|
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||||
|
core := &fakeTaskCore{created: true}
|
||||||
|
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"99"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
handleTasks(httptest.NewRecorder(), req, core)
|
||||||
|
if core.captured[0].Weight != tasks.MaxWeight {
|
||||||
|
t.Errorf("weight = %d, want the cap", core.captured[0].Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,37 @@ present (see `.dockerignore`).
|
|||||||
| `/var/lib/maven` (volume) | encrypted db at rest |
|
| `/var/lib/maven` (volume) | encrypted db at rest |
|
||||||
| `/dev/shm` (tmpfs) | decrypted db working copy (RAM only) |
|
| `/dev/shm` (tmpfs) | decrypted db working copy (RAM only) |
|
||||||
|
|
||||||
|
## Reading the outside world (off by default)
|
||||||
|
|
||||||
|
`mavend.json` ships without a `feeds` block, which means no RSS/Atom feed is
|
||||||
|
fetched and no outbound request is made. Switching it on is adding the block:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"feeds": {
|
||||||
|
"poll_interval": "30m",
|
||||||
|
"max_items": 5,
|
||||||
|
"max_age": "24h",
|
||||||
|
"sources": [
|
||||||
|
{ "name": "habr", "url": "https://habr.com/ru/rss/best/daily/",
|
||||||
|
"category": "технологии", "exclude": ["реклама"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does and does not do:
|
||||||
|
|
||||||
|
- items are written as notes with source `rss:<name>`, visible on `/dash`;
|
||||||
|
- **nothing is announced.** She reads them back when asked — "что нового в
|
||||||
|
лентах?", "что нового по технологиям?" — and never on arrival. There is no
|
||||||
|
severity or channel knob here on purpose;
|
||||||
|
- the fetcher is allowlisted to the hosts of the configured feeds, plus any
|
||||||
|
`allow_hosts`. It refuses non-http(s) schemes and every private address
|
||||||
|
(loopback, the LAN, the `10.42.0.0/24` wg range, cloud metadata). It caps the
|
||||||
|
response at 2 MiB and redirects at 3, and makes at most one request per host
|
||||||
|
per second. See `internal/webfetch`;
|
||||||
|
- how far each feed was read is stored as a config fact `rss:latest:<name>`, so
|
||||||
|
a restart does not re-note yesterday's headlines.
|
||||||
|
|
||||||
## Not yet verified / host-dependent
|
## Not yet verified / host-dependent
|
||||||
|
|
||||||
This stack is correct-by-construction but has **not been build-tested here**
|
This stack is correct-by-construction but has **not been build-tested here**
|
||||||
|
|||||||
@@ -8,6 +8,18 @@
|
|||||||
#
|
#
|
||||||
# Maven's own compose joins this same network (add `ecosystem` as an external
|
# Maven's own compose joins this same network (add `ecosystem` as an external
|
||||||
# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly.
|
# network there) to reach nexus:9740 / praxis:8989 / hexis:9741 directly.
|
||||||
|
#
|
||||||
|
# NO RELEASE PINNING (Vikunja #354): each `build:` below points at a sibling
|
||||||
|
# WORKING TREE, so `up --build` ships whatever is checked out there, including
|
||||||
|
# uncommitted edits. Before bringing this up, check what you are about to
|
||||||
|
# deploy:
|
||||||
|
#
|
||||||
|
# for r in nexus praxis hexis; do git -C ../../../$r status --short; \
|
||||||
|
# git -C ../../../$r log -1 --oneline; done
|
||||||
|
#
|
||||||
|
# The host nginx that fronts these is deploy/ecosystem/nginx.conf — it binds
|
||||||
|
# the wg and LAN addresses only, with allow/deny. Keep it that way: none of
|
||||||
|
# these containers has auth of its own.
|
||||||
name: ecosystem
|
name: ecosystem
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|||||||
@@ -1,13 +1,71 @@
|
|||||||
# Reverse-proxy the three sibling admin UIs. Drop into your nginx sites (or the
|
# Reverse-proxy Maven's own web UI plus the three sibling admin UIs. Drop into
|
||||||
# nginx-panel app) and reload. Assumes the compose publishes each service on
|
# your nginx sites (or the nginx-panel app) and reload. Assumes the compose
|
||||||
# 127.0.0.1:<port>. Add TLS (certbot / your existing cert block) per server.
|
# publishes each service on 127.0.0.1:<port>. Add TLS (certbot / your existing
|
||||||
|
# cert block) per server.
|
||||||
#
|
#
|
||||||
# NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that
|
# NOTE: hexis.<domain> previously pointed at the MCP tool — repoint that
|
||||||
# elsewhere first (the app now owns hexis.*).
|
# elsewhere first (the app now owns hexis.*).
|
||||||
|
#
|
||||||
|
# 10.42.0.1 and 192.168.1.104 below are THIS BOX's WireGuard and LAN
|
||||||
|
# addresses (homesrv) — these admin UIs have no auth of their own, so the
|
||||||
|
# explicit bind + allow/deny below is what keeps them off the open internet.
|
||||||
|
# On a different box, replace both addresses with that box's wg and LAN IPs.
|
||||||
|
# Do NOT "fix" a failed bind by reverting to `listen 80` (all interfaces) —
|
||||||
|
# that removes the only access control these containers have.
|
||||||
|
|
||||||
|
# maven.<domain> → mavweb (docker-compose.yml publishes it on 127.0.0.1:9201).
|
||||||
|
# Same bind + ACL as the siblings, and for a stronger reason: mavweb serves
|
||||||
|
# POST /tools, which defines argv that internal/tool EXECUTES, plus POST
|
||||||
|
# /routines, /api/revert and /api/chat (Vikunja #317). Without
|
||||||
|
# -webauthn-origin/-webauthn-rpid mavweb has no auth of its own, so this block
|
||||||
|
# is the auth. If you add TLS and a basic-auth/oauth2-proxy layer, keep the
|
||||||
|
# allow/deny anyway — belt and braces on an RCE surface.
|
||||||
|
#
|
||||||
|
# WebSocket upgrade matters here: /ws carries push-to-talk audio, so the
|
||||||
|
# Upgrade/Connection headers below are required, not decoration. The map keeps
|
||||||
|
# `Connection: upgrade` off plain requests; it sits in the http context, which
|
||||||
|
# is where sites-available files are included — if your nginx already defines
|
||||||
|
# $connection_upgrade, drop this block.
|
||||||
|
map $http_upgrade $connection_upgrade {
|
||||||
|
default upgrade;
|
||||||
|
'' close;
|
||||||
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 10.42.0.1:80;
|
||||||
|
listen 192.168.1.104:80;
|
||||||
|
server_name maven.kvmx.ru;
|
||||||
|
|
||||||
|
allow 10.42.0.0/24;
|
||||||
|
allow 192.168.1.0/24;
|
||||||
|
deny all;
|
||||||
|
|
||||||
|
# push-to-talk uploads raw PCM; the default 1m is enough for a short
|
||||||
|
# utterance but not for a long one.
|
||||||
|
client_max_body_size 32m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:9201;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection $connection_upgrade;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s; # an LLM turn can take minutes on the iGPU
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 10.42.0.1:80;
|
||||||
|
listen 192.168.1.104:80;
|
||||||
server_name nexus.kvmx.ru;
|
server_name nexus.kvmx.ru;
|
||||||
|
|
||||||
|
allow 10.42.0.0/24;
|
||||||
|
allow 192.168.1.0/24;
|
||||||
|
deny all;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:9740;
|
proxy_pass http://127.0.0.1:9740;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -18,8 +76,14 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 10.42.0.1:80;
|
||||||
|
listen 192.168.1.104:80;
|
||||||
server_name praxis.kvmx.ru;
|
server_name praxis.kvmx.ru;
|
||||||
|
|
||||||
|
allow 10.42.0.0/24;
|
||||||
|
allow 192.168.1.0/24;
|
||||||
|
deny all;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8989;
|
proxy_pass http://127.0.0.1:8989;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -30,8 +94,14 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 10.42.0.1:80;
|
||||||
|
listen 192.168.1.104:80;
|
||||||
server_name hexis.kvmx.ru;
|
server_name hexis.kvmx.ru;
|
||||||
|
|
||||||
|
allow 10.42.0.0/24;
|
||||||
|
allow 192.168.1.0/24;
|
||||||
|
deny all;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:9741;
|
proxy_pass http://127.0.0.1:9741;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
+15
-6
@@ -6,11 +6,12 @@
|
|||||||
"state_dir": "/var/lib/maven",
|
"state_dir": "/var/lib/maven",
|
||||||
|
|
||||||
"phraser": {
|
"phraser": {
|
||||||
"model_path": "/opt/maven/models/llm/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf",
|
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||||
"bin_path": "llama-server",
|
"bin_path": "llama-server",
|
||||||
"n_gpu_layers": 99,
|
"n_gpu_layers": 99,
|
||||||
"n_ctx": 2048,
|
"n_ctx": 4096,
|
||||||
"timeout": "60s"
|
"timeout": "60s",
|
||||||
|
"llm_nudges": false
|
||||||
},
|
},
|
||||||
|
|
||||||
"telegram": {
|
"telegram": {
|
||||||
@@ -25,6 +26,11 @@
|
|||||||
"severity_ceiling": 2
|
"severity_ceiling": 2
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"pattern_proposals": {
|
||||||
|
"notify": false,
|
||||||
|
"cooldown": "24h"
|
||||||
|
},
|
||||||
|
|
||||||
"nexus": { "url": "http://nexus:9740" },
|
"nexus": { "url": "http://nexus:9740" },
|
||||||
"praxis": { "url": "http://praxis:8989" },
|
"praxis": { "url": "http://praxis:8989" },
|
||||||
"hexis": { "url": "http://hexis:9741" },
|
"hexis": { "url": "http://hexis:9741" },
|
||||||
@@ -36,11 +42,14 @@
|
|||||||
"stt": { "socket": "/run/maven/stt.sock", "lang": "ru" },
|
"stt": { "socket": "/run/maven/stt.sock", "lang": "ru" },
|
||||||
"tts": { "socket": "/run/maven/tts.sock", "lang": "ru" },
|
"tts": { "socket": "/run/maven/tts.sock", "lang": "ru" },
|
||||||
"embedder": {
|
"embedder": {
|
||||||
"model_path": "/opt/maven/models/embedder/model.onnx",
|
"model_path": "/opt/maven/models/embedder/multilingual-e5-small/model_quantized.onnx",
|
||||||
"tokenizer_path": "/opt/maven/models/embedder/tokenizer.json",
|
"tokenizer_path": "/opt/maven/models/embedder/multilingual-e5-small/tokenizer.json",
|
||||||
"lib_path": "/opt/maven/lib/libonnxruntime.so"
|
"lib_path": "/opt/maven/lib/libonnxruntime.so"
|
||||||
},
|
},
|
||||||
"llm_router": false,
|
"llm_router": true,
|
||||||
|
"query_min_score": 0.55,
|
||||||
|
"query_min_margin": 0.008,
|
||||||
|
"clarify_max_attempts": 3,
|
||||||
"tool_timeout": "30s",
|
"tool_timeout": "30s",
|
||||||
"tools": [
|
"tools": [
|
||||||
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false },
|
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false },
|
||||||
|
|||||||
@@ -101,9 +101,42 @@ services:
|
|||||||
"-netdata", "http://127.0.0.1:19999",
|
"-netdata", "http://127.0.0.1:19999",
|
||||||
"-kuma", "http://127.0.0.1:3001/metrics",
|
"-kuma", "http://127.0.0.1:3001/metrics",
|
||||||
"-kuma-key", "uk5_mavpoll-key"]
|
"-kuma-key", "uk5_mavpoll-key"]
|
||||||
|
# Money tracking (Vikunja #125) is OFF: it needs a zenmoney token,
|
||||||
|
# which mavpoll reads from a FILE so it never appears in `ps`, in
|
||||||
|
# this file, or in shell history. To enable, mount the token and
|
||||||
|
# append: "-zenmoney-token-file", "/run/secrets/zenmoney.token"
|
||||||
|
# (optionally "-zenmoney-interval", "1h"). Core never sees the
|
||||||
|
# token — the poller writes facts(kind=env, source=poll:zenmoney)
|
||||||
|
# and mavend only reads those back when he asks.
|
||||||
depends_on: [mavend]
|
depends_on: [mavend]
|
||||||
volumes:
|
volumes:
|
||||||
- sockets:/run/maven
|
- sockets:/run/maven
|
||||||
|
# - ./deploy/zenmoney.token:/run/secrets/zenmoney.token:ro
|
||||||
|
|
||||||
|
# The mail reader (Vikunja #246) is OFF and commented out: it needs an IMAP
|
||||||
|
# account, and there is none on this box. mavmaild reads the password from a
|
||||||
|
# FILE so it never appears in `ps`, in this file, or in shell history — the
|
||||||
|
# same rule mavpoll follows for the zenmoney token. Core never sees the
|
||||||
|
# password: the reader hands core message text on one IPC method, and core
|
||||||
|
# writes what the model extracts as task CANDIDATES he reviews on /tasks.
|
||||||
|
# Nothing here can create a reminder, so a misread mail cannot fire.
|
||||||
|
#
|
||||||
|
# To enable: write the password to deploy/imap.password (0600, gitignored),
|
||||||
|
# add an "email": {} block to deploy/mavend.json, and uncomment this service.
|
||||||
|
# mavmaild:
|
||||||
|
# <<: *image
|
||||||
|
# command: ["mavmaild", "-socket", "/run/maven/mavend.sock",
|
||||||
|
# "-imap", "imap.example.org:993",
|
||||||
|
# "-user", "kami@example.org",
|
||||||
|
# "-password-file", "/run/secrets/imap.password",
|
||||||
|
# "-mailbox", "INBOX",
|
||||||
|
# "-interval", "15m",
|
||||||
|
# "-state", "/var/lib/maven/mail-seen.json"]
|
||||||
|
# depends_on: [mavend]
|
||||||
|
# volumes:
|
||||||
|
# - sockets:/run/maven
|
||||||
|
# - dbdata:/var/lib/maven
|
||||||
|
# - ./deploy/imap.password:/run/secrets/imap.password:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
|
|||||||
@@ -25,3 +25,46 @@
|
|||||||
6. Add `/eval` API method to `ipc.CoreAPI` (or reuse `Chat` with system context) so mavweb can show evaluation history
|
6. Add `/eval` API method to `ipc.CoreAPI` (or reuse `Chat` with system context) so mavweb can show evaluation history
|
||||||
7. Add `memory_eval` block to `deploy/mavend.json`
|
7. Add `memory_eval` block to `deploy/mavend.json`
|
||||||
8. Test with synthetic store state — verify observations match expected patterns
|
8. Test with synthetic store state — verify observations match expected patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status 2026-08-01 — foundation shipped (Vikunja #248)
|
||||||
|
|
||||||
|
**Shipped:** `internal/memeval` (not `internal/memory/eval.go` — `internal/store`
|
||||||
|
imports `internal/memory` for the vector backend, so an evaluator that reads
|
||||||
|
`store.Fact` there would close an import cycle). `Evaluator.Evaluate` reads
|
||||||
|
`RecentFacts` / `RecentNotes` / `RecentNudges`, prompts the resident model under
|
||||||
|
a GBNF grammar for at most three `{observation, confidence, suggested_action}`
|
||||||
|
objects, drops anything under `min_confidence`, deduplicates against what earlier
|
||||||
|
evaluations wrote, and records the rest as notes with source `infer:memory-eval`.
|
||||||
|
Driver: `cmd/mavend/memoryeval.go`, its own goroutine on its own ticker. Config:
|
||||||
|
the `memory_eval` block — **absent ⇒ the loop does not run**. Visibility: `/dash`
|
||||||
|
already renders notes with their source, so evaluation output is visible with no
|
||||||
|
UI change.
|
||||||
|
|
||||||
|
**Deliberately not shipped — this is policy, not an unfinished edge:**
|
||||||
|
|
||||||
|
- *Dispatching observations as care nudges (plan step 4).* An hourly LLM loop
|
||||||
|
with permission to speak is a machine for generating interruptions, and the
|
||||||
|
content is model-generated text about his own life. The evaluator has no
|
||||||
|
dispatcher reference at all, so it cannot reach a channel by accident. Wiring
|
||||||
|
it to `delivery.Dispatcher` is a separate decision with its own opt-in.
|
||||||
|
- *Acting on `suggested_action`.* It is recorded inside the note text and
|
||||||
|
interpreted by nobody. No reminder, routine or fact is created.
|
||||||
|
- *Writing observation embeddings.* Notes are written with a nil embedding, so
|
||||||
|
they stay out of the RAG recall pool. Feeding generated text back into the pool
|
||||||
|
it came from is how a small model starts citing its own guesses as evidence.
|
||||||
|
|
||||||
|
**Deferred, wants a decision or another capability:**
|
||||||
|
|
||||||
|
- *Plan step 6, the `/eval` IPC method and an evaluation-history view.* `/dash`
|
||||||
|
covers reading the output; a dedicated trace surface is worth building once
|
||||||
|
there is real output to look at, and it should probably show the prompt too.
|
||||||
|
- *`RecentEvents`.* The plan lists it; the evaluator reads facts, notes and
|
||||||
|
nudges. Detected action/object events already drive pattern proposals (#43), and
|
||||||
|
duplicating them here would mostly re-derive that.
|
||||||
|
- *Output quality is unmeasured.* There is no fixture for "did she notice
|
||||||
|
something true". The tests cover the machinery — empty store, confidence floor,
|
||||||
|
dedupe, own-notes exclusion, error handling — not the observations. Until
|
||||||
|
someone reads a week of real output on `/dash`, treat the wording and the
|
||||||
|
`min_confidence` default as unvalidated.
|
||||||
|
|||||||
@@ -27,3 +27,36 @@
|
|||||||
6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer
|
6. Wire voice query — `"что я обычно делаю?"` routes to `IntentQuery` → behavior profile lookup → LLM-phrased answer
|
||||||
7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash`
|
7. Add IPC read method `MethodGetBehaviorProfile` so mavweb can display it on `/dash`
|
||||||
8. Test with synthetic fact history — verify weekly schedule is correctly inferred
|
8. Test with synthetic fact history — verify weekly schedule is correctly inferred
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status (2026-08-01) — partially shipped, deliberately narrowed
|
||||||
|
|
||||||
|
Shipped on `overnight/behavior-profile`:
|
||||||
|
|
||||||
|
- `internal/memory/behavior.go` — `BuildProfile` counts habits per weekday out of
|
||||||
|
self-facts: distinct-day counts (`MinHabitDays = 2`), a median time-of-day, and
|
||||||
|
`FormatWeekdayRU` / `FormatOverallRU` for the spoken answer.
|
||||||
|
- `internal/router/habit.go` — `ParseHabitQuery`, which requires a habit marker
|
||||||
|
("обычно", "каждую", "привычки", …) and parses the weekday deterministically.
|
||||||
|
- `cmd/mavend/actions_query.go` — a `habits` query source, so "что я обычно делаю
|
||||||
|
по вторникам?" is answered.
|
||||||
|
|
||||||
|
**Not shipped, and not to be shipped as written:**
|
||||||
|
|
||||||
|
- *Step 3, LLM-generated profile stored as a fact.* The profile is COUNTED, not
|
||||||
|
generated. A 1.7B asked to summarise a year of habits produces fluent claims
|
||||||
|
about the owner's life that no row supports, and a wrong claim about him is the
|
||||||
|
most expensive kind of wrong maven can be. Counting is verifiable and cheap.
|
||||||
|
- *Step 5, incremental updates on fact write.* There is no cache to keep fresh —
|
||||||
|
the profile is recomputed on the question, so a new fact is already in the next
|
||||||
|
answer. A cached profile that can disagree with its own rows is two truths.
|
||||||
|
- *Step 4, proactive daily plan proposals via the dispatcher.* Maven is not a nag,
|
||||||
|
and a nudge at 08:00 every day proposing the day is the definition of one. The
|
||||||
|
sanctioned path from "she noticed a pattern" to "she acts on it" already exists:
|
||||||
|
`internal/pattern/detector.go` proposes a routine, and the owner accepts it on
|
||||||
|
`/routines`. It goes through him.
|
||||||
|
|
||||||
|
Still open, if wanted later: `MethodGetBehaviorProfile` + a `/dash` panel (step 7).
|
||||||
|
The counted profile needs no new IPC method to be *asked* about — the query source
|
||||||
|
reads `RecentFacts` over the existing surface — so this is a display concern only.
|
||||||
|
|||||||
@@ -27,3 +27,28 @@
|
|||||||
7. Add voice query handler — `"что нового?"` queries `RecentNotes` filtered by source prefix `rss:` and phrases via `phraser.PhraseQuery`
|
7. Add voice query handler — `"что нового?"` queries `RecentNotes` filtered by source prefix `rss:` and phrases via `phraser.PhraseQuery`
|
||||||
8. Add `feeds` block to `config.Config` and `deploy/mavend.json`
|
8. Add `feeds` block to `config.Config` and `deploy/mavend.json`
|
||||||
9. Test with a live RSS feed (e.g., `https://news.ycombinator.com/rss`) — verify items appear in notes table
|
9. Test with a live RSS feed (e.g., `https://news.ycombinator.com/rss`) — verify items appear in notes table
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shipped 2026-08-01 (#258)
|
||||||
|
|
||||||
|
`internal/webfetch` (the guarded HTTP door: scheme, allow/deny hosts, private-address
|
||||||
|
refusal in the dialer, size cap, redirect cap, per-host rate limit), `internal/rss`
|
||||||
|
(RSS 2.0 + Atom parser, poller with durable marks and a keyword filter),
|
||||||
|
`cmd/mavend/feeds.go` (ticker, fetcher adapter, `rss:latest:<feed>` fact marks),
|
||||||
|
config block `feeds`, and the `feeds` query source with `router.ParseFeedQuery`.
|
||||||
|
|
||||||
|
Deviations from the plan above, both deliberate:
|
||||||
|
|
||||||
|
- **Step 5 (breaking-news nudges) was not built.** A feed that dispatches is a nag,
|
||||||
|
and the one thing Maven is not is a nag. Items are read when asked and nowhere else.
|
||||||
|
If breaking news is ever wanted, it belongs behind the existing delivery policy
|
||||||
|
(severity, quiet hours, digest), not in the poller.
|
||||||
|
- **Step 3 (embedder relevance) is a seam, not an implementation.** `rss.Ranker`
|
||||||
|
exists and is wired nil. Scoring items against an "interest profile" needs a
|
||||||
|
profile, and there is none yet; a threshold with nothing to compare against is a
|
||||||
|
random filter with a confident name. The filter that runs is the per-feed
|
||||||
|
include/exclude keyword list, which he can read and predict.
|
||||||
|
|
||||||
|
No new dependency: stdlib `encoding/xml`, no gofeed. Stock deploy config has no
|
||||||
|
`feeds` block, so the capability is off.
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
)
|
)
|
||||||
@@ -359,8 +358,12 @@ func TestGate_IpcServer_ChatAllowedForEnrolledCaller(t *testing.T) {
|
|||||||
|
|
||||||
// recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth
|
// recordingAPI — a no-op CoreAPI that counts WriteFact invocations; the auth
|
||||||
// check must reject before reaching it, otherwise the refusal leaks into the
|
// check must reject before reaching it, otherwise the refusal leaks into the
|
||||||
// fake's counts and we fail.
|
// fake's counts and we fail. Embeds ipc.UnimplementedCoreAPI so every method
|
||||||
|
// this test doesn't exercise returns ipc.ErrNotImplemented loudly instead of
|
||||||
|
// being hand-stubbed to a canned value nobody checks.
|
||||||
type recordingAPI struct {
|
type recordingAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
|
||||||
writes int
|
writes int
|
||||||
chats int
|
chats int
|
||||||
}
|
}
|
||||||
@@ -369,89 +372,7 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
|
|||||||
r.writes++
|
r.writes++
|
||||||
return int64(r.writes), nil
|
return int64(r.writes), nil
|
||||||
}
|
}
|
||||||
func (r *recordingAPI) LatestFact(_ context.Context, _ string) (ipc.Fact, error) {
|
|
||||||
return ipc.Fact{}, ipc.ErrNoFact
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) LatestFactBySource(_ context.Context, _, _ string) (ipc.Fact, error) {
|
|
||||||
return ipc.Fact{}, ipc.ErrNoFact
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) Since(_ context.Context, _ string, _ time.Time) (time.Duration, error) {
|
|
||||||
return 0, ipc.ErrNoFact
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) Presence(_ context.Context) (ipc.Presence, error) {
|
|
||||||
return ipc.Presence{}, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) CreateReminder(_ context.Context, _ time.Time, _, _ string) (int64, error) {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) MarkReminder(_ context.Context, _ int64, _ string) error { return nil }
|
|
||||||
func (r *recordingAPI) ListReminders(_ context.Context, _ int) ([]ipc.Reminder, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) TickTrace(_ context.Context) (ipc.TickTrace, error) {
|
|
||||||
return ipc.TickTrace{}, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) MorningStatus(_ context.Context) ([]ipc.MorningRoutineStatus, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) RecordNudge(_ context.Context, _, _, _ string, _ time.Time) (int64, error) {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) ResolveNudge(_ context.Context, _ int64, _ string, _ time.Time) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) RecentOutcomes(_ context.Context, _ string, _ int) ([]string, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) RecentFacts(_ context.Context, _ int) ([]ipc.Fact, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) CalendarEvents(_ context.Context, _, _ time.Time) ([]ipc.Fact, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) RecentNudges(_ context.Context, _ int) ([]ipc.Nudge, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) WriteNote(_ context.Context, _ time.Time, _ string, _ []float32, _ string) (int64, error) {
|
|
||||||
return 1, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) QueryNotes(_ context.Context, _ []float32, _ int) ([]ipc.Note, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) RecentNotes(_ context.Context, _ int) ([]ipc.Note, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) ProposeTool(_ context.Context, _, _, _ string, _ time.Time) (bool, error) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) EnableTool(_ context.Context, _ string, _ []string, _ bool, _ string, _ time.Time) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) DisableTool(_ context.Context, _ string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) DeleteTool(_ context.Context, _ string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) LookupTool(_ context.Context, _ string) (ipc.Tool, error) {
|
|
||||||
return ipc.Tool{}, ipc.ErrToolNotFound
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) ListTools(_ context.Context, _ string) ([]ipc.Tool, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *recordingAPI) RevertFact(_ context.Context, _ string) (int64, error) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) ListProposedRoutines(_ context.Context) ([]ipc.ProposedRoutine, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) AcceptProposedRoutine(_ context.Context, _ int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) DismissProposedRoutine(_ context.Context, _ int64) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) {
|
func (r *recordingAPI) Chat(_ context.Context, text string) (string, error) {
|
||||||
r.chats++
|
r.chats++
|
||||||
return "echo: " + text, nil
|
return "echo: " + text, nil
|
||||||
|
|||||||
+15
-1
@@ -65,7 +65,21 @@ func Requirement(m ipc.Method) Authority {
|
|||||||
ipc.MethodCreateReminder,
|
ipc.MethodCreateReminder,
|
||||||
ipc.MethodMarkReminder,
|
ipc.MethodMarkReminder,
|
||||||
ipc.MethodRecordNudge,
|
ipc.MethodRecordNudge,
|
||||||
ipc.MethodResolveNudge:
|
ipc.MethodResolveNudge,
|
||||||
|
// Task capture (Vikunja #130). Listed explicitly rather than left to
|
||||||
|
// the default so the intent is on the record: capturing a task is a
|
||||||
|
// module write, not an allowlist mutation and not a new standing reason
|
||||||
|
// for Maven to speak — nothing in the tick loop reads tasks. It stays
|
||||||
|
// at AuthRead, the same rung as CreateReminder, which is the closest
|
||||||
|
// existing analogue.
|
||||||
|
ipc.MethodCaptureTask,
|
||||||
|
ipc.MethodListTasks,
|
||||||
|
ipc.MethodSetTaskStatus,
|
||||||
|
// Mail ingestion (Vikunja #246). AuthRead because of what the method can
|
||||||
|
// produce: candidate tasks and nothing else. It cannot write a fact, set a
|
||||||
|
// reminder, or touch the tool allowlist, so a compromised mail reader can
|
||||||
|
// at worst put junk on a review page he clears in one click.
|
||||||
|
ipc.MethodIngestMail:
|
||||||
return AuthRead
|
return AuthRead
|
||||||
}
|
}
|
||||||
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ambient events — the work calendar read (Vikunja #126).
|
||||||
|
//
|
||||||
|
// The work calendar is not read by holding a work credential. A corp mail or
|
||||||
|
// calendar session living on the homelab ties the box's blast radius to the
|
||||||
|
// employer's data, which is the thing the task exists to refuse. What maven
|
||||||
|
// reads instead is the SIGNAL: an Android notification-listener on the owner's
|
||||||
|
// phone relays meeting notifications over wg/LAN, and maven turns the ones that
|
||||||
|
// clearly describe a meeting into calendar events.
|
||||||
|
//
|
||||||
|
// That makes the provenance honest. A notification is evidence about an event,
|
||||||
|
// not a reading of the calendar, so it is stored under SourceAmbient at
|
||||||
|
// AmbientConfidence — never indistinguishable from a real CalDAV read, and the
|
||||||
|
// query path hedges when it recites one.
|
||||||
|
//
|
||||||
|
// The parse is deliberately conservative. A notification with no recognisable
|
||||||
|
// clock reading produces nothing at all: maven is not a guesser-of-truth, and a
|
||||||
|
// mailbox full of noise turned into invented events is worse than a gap. Mail
|
||||||
|
// as a notification signal, not a mailbox.
|
||||||
|
|
||||||
|
// Notification — one relayed Android notification. Package is the posting app
|
||||||
|
// (for the log and for the owner to see where a wrong event came from), Title
|
||||||
|
// and Text are the notification's two text lines, Posted is when the phone
|
||||||
|
// showed it. Nothing else off the notification is kept.
|
||||||
|
type Notification struct {
|
||||||
|
Package string `json:"package"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Posted time.Time `json:"posted_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventFromNotification turns a notification into the event it describes, or
|
||||||
|
// reports false when it does not clearly describe one.
|
||||||
|
//
|
||||||
|
// It needs two things: a clock reading, and a summary that is not just that
|
||||||
|
// clock reading. Everything else is defaulted — the date is Posted's day (a
|
||||||
|
// meeting notification is about today or it would not be firing now), and a
|
||||||
|
// bare start time gets DefaultReminderDuration.
|
||||||
|
func EventFromNotification(n Notification) (Event, bool) {
|
||||||
|
if n.Posted.IsZero() {
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(n.Title + " " + n.Text)
|
||||||
|
start, end, ok := parseTimeRange(line)
|
||||||
|
if !ok {
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
summary := notificationSummary(n)
|
||||||
|
if summary == "" {
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
y, m, d := n.Posted.Date()
|
||||||
|
loc := n.Posted.Location()
|
||||||
|
s := time.Date(y, m, d, start.hour, start.min, 0, 0, loc)
|
||||||
|
var e time.Time
|
||||||
|
if end != nil {
|
||||||
|
e = time.Date(y, m, d, end.hour, end.min, 0, 0, loc)
|
||||||
|
// A range that ends before it starts crossed midnight.
|
||||||
|
if !e.After(s) {
|
||||||
|
e = e.AddDate(0, 0, 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
e = s.Add(DefaultReminderDuration)
|
||||||
|
}
|
||||||
|
return Event{Summary: summary, Start: s, End: e}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// notificationSummary picks the text that names the meeting: the title when it
|
||||||
|
// carries words, otherwise the body. The clock reading is stripped out — it
|
||||||
|
// already lives in the times, and FactValue renders it again.
|
||||||
|
func notificationSummary(n Notification) string {
|
||||||
|
for _, cand := range []string{n.Title, n.Text} {
|
||||||
|
s := strings.TrimSpace(stripClock(cand))
|
||||||
|
s = strings.Trim(s, " \t-–—,;:@|·")
|
||||||
|
s = strings.Join(strings.Fields(s), " ")
|
||||||
|
if hasLetters(s) {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type clock struct{ hour, min int }
|
||||||
|
|
||||||
|
// parseTimeRange finds the first clock reading in s, and a second one if the
|
||||||
|
// text spells a range. Accepted separators between hours and minutes are ":"
|
||||||
|
// and "."; between the two ends of a range, "-", "–", "—" or "до".
|
||||||
|
//
|
||||||
|
// Bare hours ("в 14") are NOT accepted. Loose digits in a notification are far
|
||||||
|
// more often a count, a date or an unread badge than a meeting time, and an
|
||||||
|
// invented event is worse than no event.
|
||||||
|
func parseTimeRange(s string) (start clock, end *clock, ok bool) {
|
||||||
|
first, _, firstEnd, ok := nextClock(s, 0)
|
||||||
|
if !ok {
|
||||||
|
return clock{}, nil, false
|
||||||
|
}
|
||||||
|
sep := strings.TrimLeft(s[firstEnd:], " \t")
|
||||||
|
for _, p := range []string{"-", "–", "—", "до "} {
|
||||||
|
if !strings.HasPrefix(sep, p) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if second, _, _, ok2 := nextClock(strings.TrimPrefix(sep, p), 0); ok2 {
|
||||||
|
return first, &second, true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return first, nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextClock scans s from byte offset `from` for the first HH:MM (or HH.MM) and
|
||||||
|
// returns it with the byte range it occupied. Digits and separators are ASCII,
|
||||||
|
// so byte offsets are safe over Cyrillic text.
|
||||||
|
func nextClock(s string, from int) (c clock, start, end int, ok bool) {
|
||||||
|
for i := from; i < len(s); i++ {
|
||||||
|
if !isDigit(s[i]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
j := i
|
||||||
|
for j < len(s) && isDigit(s[j]) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
// A run longer than two digits is a year, an id or an unread count.
|
||||||
|
if j-i > 2 {
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if j >= len(s) || (s[j] != ':' && s[j] != '.') {
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := j + 1
|
||||||
|
for k < len(s) && isDigit(s[k]) {
|
||||||
|
k++
|
||||||
|
}
|
||||||
|
if k-(j+1) != 2 {
|
||||||
|
i = j
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Reject a group that is a link in a longer dotted or colon chain:
|
||||||
|
// "2026.08.15" would otherwise offer "08.15" as 08:15, and a deadline
|
||||||
|
// date invented as a meeting time is exactly the wrong kind of guess.
|
||||||
|
// A trailing ":ss" is fine — that is a time with seconds.
|
||||||
|
if i > 0 && (s[i-1] == '.' || s[i-1] == ':' || isDigit(s[i-1])) {
|
||||||
|
i = k
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if k < len(s) && s[k] == '.' && k+1 < len(s) && isDigit(s[k+1]) {
|
||||||
|
i = k
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hour, min := atoi(s[i:j]), atoi(s[j+1:k])
|
||||||
|
if hour > 23 || min > 59 {
|
||||||
|
i = k
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return clock{hour, min}, i, k, true
|
||||||
|
}
|
||||||
|
return clock{}, 0, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
|
||||||
|
|
||||||
|
func atoi(s string) int {
|
||||||
|
n := 0
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
n = n*10 + int(s[i]-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripClock removes every clock reading from a summary candidate, along with
|
||||||
|
// the preposition or separator that introduced it.
|
||||||
|
func stripClock(s string) string {
|
||||||
|
for {
|
||||||
|
_, start, end, ok := nextClock(s, 0)
|
||||||
|
if !ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
head := trimTrailingPreposition(strings.TrimRight(s[:start], "0123456789:.-–— \t"))
|
||||||
|
s = strings.TrimSpace(strings.TrimSpace(head) + " " + strings.TrimSpace(s[end:]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// trimTrailingPreposition drops the word that introduced a clock reading, so
|
||||||
|
// "Встреча в 14:00" becomes "Встреча" and "с 11:30 до 12:15 Созвон" does not
|
||||||
|
// keep a dangling "с". It repeats, because a range has two of them.
|
||||||
|
func trimTrailingPreposition(s string) string {
|
||||||
|
preps := []string{"в", "с", "до", "от", "at", "from", "to"}
|
||||||
|
for again := true; again; {
|
||||||
|
again = false
|
||||||
|
s = strings.TrimRight(s, " \t")
|
||||||
|
for _, p := range preps {
|
||||||
|
if s == p {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(s, " "+p) {
|
||||||
|
s = s[:len(s)-len(p)-1]
|
||||||
|
again = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLetters(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.IsLetter(r) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventFromNotification(t *testing.T) {
|
||||||
|
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
title, text string
|
||||||
|
wantOK bool
|
||||||
|
wantSummary string
|
||||||
|
wantStart string // "15:04"
|
||||||
|
wantEnd string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "range in the body",
|
||||||
|
title: "Планёрка",
|
||||||
|
text: "10:00-10:30",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Планёрка",
|
||||||
|
wantStart: "10:00", wantEnd: "10:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "russian preposition and single time",
|
||||||
|
title: "Встреча с подрядчиком в 14:00",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Встреча с подрядчиком",
|
||||||
|
wantStart: "14:00", wantEnd: "14:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "en dash range",
|
||||||
|
title: "Sprint review",
|
||||||
|
text: "Today 16:00 – 17:00, Meet",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Sprint review",
|
||||||
|
wantStart: "16:00", wantEnd: "17:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "до as a range separator",
|
||||||
|
title: "Созвон",
|
||||||
|
text: "с 11:30 до 12:15",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Созвон",
|
||||||
|
wantStart: "11:30", wantEnd: "12:15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "dotted clock",
|
||||||
|
title: "Обед 13.00",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Обед",
|
||||||
|
wantStart: "13:00", wantEnd: "13:30",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "range crossing midnight",
|
||||||
|
title: "Ночной релиз",
|
||||||
|
text: "23:30-00:30",
|
||||||
|
wantOK: true,
|
||||||
|
wantSummary: "Ночной релиз",
|
||||||
|
wantStart: "23:30", wantEnd: "00:30",
|
||||||
|
},
|
||||||
|
// The conservative half: no clock reading, no event.
|
||||||
|
{name: "no time at all", title: "3 новых письма", wantOK: false},
|
||||||
|
{name: "bare hour is not a time", title: "Планёрка в 14", wantOK: false},
|
||||||
|
{name: "unread count", title: "Входящие", text: "12 непрочитанных", wantOK: false},
|
||||||
|
{name: "a date is not a clock", title: "Отчёт", text: "срок 2026.08.15", wantOK: false},
|
||||||
|
{name: "time but nothing named", title: "10:00-10:30", wantOK: false},
|
||||||
|
{name: "impossible clock", title: "Смена 99:99", wantOK: false},
|
||||||
|
{name: "empty", wantOK: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ev, ok := EventFromNotification(Notification{
|
||||||
|
Package: "com.google.android.gm",
|
||||||
|
Title: tt.title,
|
||||||
|
Text: tt.text,
|
||||||
|
Posted: posted,
|
||||||
|
})
|
||||||
|
if ok != tt.wantOK {
|
||||||
|
t.Fatalf("ok = %v, want %v (event %+v)", ok, tt.wantOK, ev)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev.Summary != tt.wantSummary {
|
||||||
|
t.Errorf("summary = %q, want %q", ev.Summary, tt.wantSummary)
|
||||||
|
}
|
||||||
|
if got := ev.Start.Format("15:04"); got != tt.wantStart {
|
||||||
|
t.Errorf("start = %s, want %s", got, tt.wantStart)
|
||||||
|
}
|
||||||
|
if got := ev.End.Format("15:04"); got != tt.wantEnd {
|
||||||
|
t.Errorf("end = %s, want %s", got, tt.wantEnd)
|
||||||
|
}
|
||||||
|
if !ev.End.After(ev.Start) {
|
||||||
|
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
|
||||||
|
}
|
||||||
|
// The event lands on the day the phone showed it, in the phone's
|
||||||
|
// location — not shifted into UTC.
|
||||||
|
if ev.Start.Location() != posted.Location() {
|
||||||
|
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
||||||
|
}
|
||||||
|
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
|
||||||
|
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||||
|
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
||||||
|
t.Error("a notification with no posted_at has no date to sit on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ambient event must never be indistinguishable from a calendar read.
|
||||||
|
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||||
|
ev, ok := EventFromNotification(Notification{
|
||||||
|
Title: "Планёрка 10:00-10:30",
|
||||||
|
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected an event")
|
||||||
|
}
|
||||||
|
if FactKey(ev) == "" || FactValue(ev) == "" {
|
||||||
|
t.Fatal("ambient events must use the shared fact encoding")
|
||||||
|
}
|
||||||
|
if AmbientConfidence >= 1.0 {
|
||||||
|
t.Fatal("ambient confidence must be below a calendar read's")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripClock(t *testing.T) {
|
||||||
|
tests := []struct{ in, want string }{
|
||||||
|
{"Встреча в 14:00", "Встреча"},
|
||||||
|
{"Планёрка 10:00-10:30", "Планёрка"},
|
||||||
|
{"с 11:30 до 12:15 Созвон", "Созвон"},
|
||||||
|
{"Ничего", "Ничего"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := stripClock(tt.in); got != tt.want {
|
||||||
|
t.Errorf("stripClock(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
// Package calendar is the one calendar data model the rest of maven shares:
|
||||||
|
// an Event, the iCal text it is parsed from and rendered to, and the fact
|
||||||
|
// encoding that puts it in the store.
|
||||||
|
//
|
||||||
|
// It exists because three separate features read or write the same events and
|
||||||
|
// must agree on their shape: the CalDAV read side (cmd/mavcaldav, Vikunja
|
||||||
|
// #126/#127), the write-only render target that publishes maven's own
|
||||||
|
// reminders as a calendar (#127), and the day plan that recites them (#128).
|
||||||
|
// Before this package the parse lived inline in cmd/mavcaldav and the fact key
|
||||||
|
// format was a Sprintf in two places.
|
||||||
|
//
|
||||||
|
// The package is pure: no HTTP, no store, no clock of its own. Callers own the
|
||||||
|
// impurity, the way internal/morning and internal/loop do.
|
||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fact sources. A calendar event reaches the store as a
|
||||||
|
// `facts (kind=env, key=calendar_event_..., source=<one of these>)` row, and
|
||||||
|
// the source is the whole provenance story:
|
||||||
|
//
|
||||||
|
// - SourcePersonal — maven's own Radicale, read AND rendered to. Canonical
|
||||||
|
// state stays in sqlite; the calendar is a render target (#127).
|
||||||
|
// - SourceWork — a work calendar, read-only by definition (#126). Nothing in
|
||||||
|
// maven ever writes to it: no code path pairs this source with a PUT.
|
||||||
|
// - SourceAmbient — inferred from an Android notification-listener relay
|
||||||
|
// rather than read from a server (#126). Confidence is below 1.0 because a
|
||||||
|
// notification is a signal about an event, not the event.
|
||||||
|
const (
|
||||||
|
SourcePersonal = "poll:caldav"
|
||||||
|
SourceWork = "poll:caldav:work"
|
||||||
|
SourceAmbient = "ambient:notif"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AmbientConfidence — the confidence a notification-derived event is stored
|
||||||
|
// with. A parsed notification line is evidence, not a reading of the calendar,
|
||||||
|
// so it must never be indistinguishable from one (#126).
|
||||||
|
const AmbientConfidence = 0.6
|
||||||
|
|
||||||
|
// Sources lists every source a calendar event may legitimately carry, for the
|
||||||
|
// store query that reads the calendar back out. Ordered from most to least
|
||||||
|
// trusted.
|
||||||
|
func Sources() []string {
|
||||||
|
return []string{SourcePersonal, SourceWork, SourceAmbient}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadOnlySource reports whether events from this source may never be written
|
||||||
|
// back. The work calendar is read-only by definition — see #126: maven holding
|
||||||
|
// a credential that can write to an employer's calendar is the thing the task
|
||||||
|
// exists to avoid.
|
||||||
|
func ReadOnlySource(source string) bool {
|
||||||
|
return source == SourceWork || source == SourceAmbient
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event — one calendar entry. UID is the iCal UID when the event was parsed
|
||||||
|
// from a server and the identity maven renders under when it publishes one;
|
||||||
|
// Start/End are instants. All-day events are not modelled: the busy gate and
|
||||||
|
// the day plan both need a time of day, and an all-day marker answers neither.
|
||||||
|
type Event struct {
|
||||||
|
UID string
|
||||||
|
Summary string
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactKey is the store key for an event: one key per day per summary, stable
|
||||||
|
// across polls so re-reading an unchanged calendar rewrites nothing.
|
||||||
|
//
|
||||||
|
// The date prefix is load-bearing — store.CalendarEvents selects a day range
|
||||||
|
// by key prefix, not by a timestamp column.
|
||||||
|
func FactKey(e Event) string {
|
||||||
|
return fmt.Sprintf("calendar_event_%s_%s", e.Start.Format("20060102"), safeKey(e.Summary))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactValue is the human-readable rendering stored as the fact value, and the
|
||||||
|
// string the day plan and the query path read back.
|
||||||
|
func FactValue(e Event) string {
|
||||||
|
return fmt.Sprintf("%s @ %s-%s", e.Summary, e.Start.Format("15:04"), e.End.Format("15:04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyPrefixForDay is the fact-key prefix covering one calendar day. The store
|
||||||
|
// range-scans between two of these.
|
||||||
|
func KeyPrefixForDay(day time.Time) string {
|
||||||
|
return fmt.Sprintf("calendar_event_%s", day.Format("20060102"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Busy reports whether any event covers the instant now — the read the loop
|
||||||
|
// gate uses to suppress nudges during a meeting.
|
||||||
|
func Busy(events []Event, now time.Time) bool {
|
||||||
|
for _, e := range events {
|
||||||
|
if !now.Before(e.Start) && now.Before(e.End) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlapping returns the events intersecting [from, to), sorted by start.
|
||||||
|
func Overlapping(events []Event, from, to time.Time) []Event {
|
||||||
|
var out []Event
|
||||||
|
for _, e := range events {
|
||||||
|
if e.End.After(from) && e.Start.Before(to) {
|
||||||
|
out = append(out, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Start.Before(out[j].Start) })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
||||||
|
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
||||||
|
// the day prefix carries the identity and this only disambiguates within a day.
|
||||||
|
func safeKey(s string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
||||||
|
b.WriteRune(r)
|
||||||
|
case r == ' ' || r == '_':
|
||||||
|
b.WriteRune('-')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseICalDayKeepsOnlyToday(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
body := []byte(`BEGIN:VCALENDAR
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:a@example
|
||||||
|
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 := ParseICalDay(body, now)
|
||||||
|
if len(events) != 2 {
|
||||||
|
t.Fatalf("got %d events, want 2 (today only, no all-day/past/future)", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Summary != "Morning standup" || events[0].UID != "a@example" {
|
||||||
|
t.Errorf("events[0] = %+v", events[0])
|
||||||
|
}
|
||||||
|
if !events[0].Start.Equal(time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("events[0].Start = %v", events[0].Start)
|
||||||
|
}
|
||||||
|
if !events[0].End.Equal(time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("events[0].End = %v", events[0].End)
|
||||||
|
}
|
||||||
|
if events[1].Summary != "Team sync" {
|
||||||
|
t.Errorf("events[1].Summary = %q", events[1].Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regression: "today" is the owner's day, in the owner's location. Taking the
|
||||||
|
// day number off a local clock but building the boundaries in UTC made the
|
||||||
|
// evening fall outside the window on any box east of Greenwich.
|
||||||
|
func TestParseICalDayUsesOwnersDay(t *testing.T) {
|
||||||
|
plus4 := time.FixedZone("+04", 4*60*60)
|
||||||
|
// 01:00 on Aug 1 local is 21:00 on Jul 31 UTC.
|
||||||
|
now := time.Date(2026, 8, 1, 1, 0, 0, 0, plus4)
|
||||||
|
body := []byte("BEGIN:VCALENDAR\nBEGIN:VEVENT\n" +
|
||||||
|
"DTSTART:20260731T195406Z\nDTEND:20260731T235406Z\nSUMMARY:Current meeting\n" +
|
||||||
|
"END:VEVENT\nEND:VCALENDAR")
|
||||||
|
|
||||||
|
events := ParseICalDay(body, now)
|
||||||
|
if len(events) != 1 {
|
||||||
|
t.Fatalf("got %d events, want the in-progress one", len(events))
|
||||||
|
}
|
||||||
|
if !Busy(events, now.UTC()) {
|
||||||
|
t.Error("an event in progress right now must read as busy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVEVENT(t *testing.T) {
|
||||||
|
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
||||||
|
e, ok := parseVEVENT(block)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a parsed event")
|
||||||
|
}
|
||||||
|
if !e.Start.Equal(time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("start = %v", e.Start)
|
||||||
|
}
|
||||||
|
if !e.End.Equal(time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)) {
|
||||||
|
t.Errorf("end = %v", e.End)
|
||||||
|
}
|
||||||
|
if e.Summary != "Stand up meeting" {
|
||||||
|
t.Errorf("summary = %q", e.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
||||||
|
if _, ok := parseVEVENT(allDay); ok {
|
||||||
|
t.Error("all-day event should be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDT(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
line string
|
||||||
|
want time.Time
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"UTC", "DTEND:20260703T100000Z", time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC), true},
|
||||||
|
{"local", "DTSTART;TZID=Europe/Moscow:20260703T130000", time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC), true},
|
||||||
|
{"all-day", "DTSTART;VALUE=DATE:20260703", time.Time{}, false},
|
||||||
|
{"garbage", "DTSTART:garbage", time.Time{}, 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{ in, 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 {
|
||||||
|
if got := safeKey(tt.in); got != tt.want {
|
||||||
|
t.Errorf("safeKey(%q) = %q, want %q", tt.in, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFactKeyAndValue(t *testing.T) {
|
||||||
|
e := Event{
|
||||||
|
Summary: "Team sync",
|
||||||
|
Start: time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC),
|
||||||
|
End: time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC),
|
||||||
|
}
|
||||||
|
if got, want := FactKey(e), "calendar_event_20260703_Team-sync"; got != want {
|
||||||
|
t.Errorf("FactKey = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := FactValue(e), "Team sync @ 14:00-15:00"; got != want {
|
||||||
|
t.Errorf("FactValue = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := KeyPrefixForDay(e.Start), "calendar_event_20260703"; got != want {
|
||||||
|
t.Errorf("KeyPrefixForDay = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(FactKey(e), KeyPrefixForDay(e.Start)) {
|
||||||
|
t.Error("FactKey must start with the day prefix the store range-scans on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBusyAndOverlapping(t *testing.T) {
|
||||||
|
base := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
||||||
|
events := []Event{
|
||||||
|
{Summary: "late", Start: base.Add(15 * time.Hour), End: base.Add(16 * time.Hour)},
|
||||||
|
{Summary: "early", Start: base.Add(9 * time.Hour), End: base.Add(10 * time.Hour)},
|
||||||
|
}
|
||||||
|
if !Busy(events, base.Add(9*time.Hour+30*time.Minute)) {
|
||||||
|
t.Error("should be busy inside the early event")
|
||||||
|
}
|
||||||
|
if Busy(events, base.Add(12*time.Hour)) {
|
||||||
|
t.Error("should be free at noon")
|
||||||
|
}
|
||||||
|
// Half-open: the end instant is free.
|
||||||
|
if Busy(events, base.Add(10*time.Hour)) {
|
||||||
|
t.Error("the end instant should not count as busy")
|
||||||
|
}
|
||||||
|
got := Overlapping(events, base.Add(8*time.Hour), base.Add(11*time.Hour))
|
||||||
|
if len(got) != 1 || got[0].Summary != "early" {
|
||||||
|
t.Fatalf("Overlapping = %+v", got)
|
||||||
|
}
|
||||||
|
all := Overlapping(events, base, base.AddDate(0, 0, 1))
|
||||||
|
if len(all) != 2 || all[0].Summary != "early" {
|
||||||
|
t.Fatalf("Overlapping must sort by start: %+v", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceTrust(t *testing.T) {
|
||||||
|
if ReadOnlySource(SourcePersonal) {
|
||||||
|
t.Error("the personal calendar is the one maven may render to")
|
||||||
|
}
|
||||||
|
if !ReadOnlySource(SourceWork) {
|
||||||
|
t.Error("the work calendar must be read-only")
|
||||||
|
}
|
||||||
|
if !ReadOnlySource(SourceAmbient) {
|
||||||
|
t.Error("an ambient notification is not a writable calendar")
|
||||||
|
}
|
||||||
|
if AmbientConfidence >= 1.0 {
|
||||||
|
t.Error("ambient events must be less trusted than a calendar read")
|
||||||
|
}
|
||||||
|
if len(Sources()) != 3 {
|
||||||
|
t.Errorf("Sources() = %v", Sources())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseICal scans iCal text for VEVENT components and returns the events
|
||||||
|
// overlapping [from, to). All-day events are skipped: parseDT reports no time
|
||||||
|
// for a VALUE=DATE value, and an event with no clock reading answers neither
|
||||||
|
// the busy gate nor the day plan.
|
||||||
|
func ParseICal(body []byte, from, to time.Time) []Event {
|
||||||
|
var events []Event
|
||||||
|
text := string(body)
|
||||||
|
for {
|
||||||
|
i := strings.Index(text, "BEGIN:VEVENT")
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text = text[i+len("BEGIN:VEVENT"):]
|
||||||
|
j := strings.Index(text, "END:VEVENT")
|
||||||
|
if j < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
block := text[:j]
|
||||||
|
text = text[j+len("END:VEVENT"):]
|
||||||
|
|
||||||
|
e, ok := parseVEVENT(block)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.End.After(from) && e.Start.Before(to) {
|
||||||
|
events = append(events, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseICalDay is ParseICal over the calendar day containing now, in now's own
|
||||||
|
// location — the window cmd/mavcaldav polls.
|
||||||
|
//
|
||||||
|
// The location matters. The old inline version took the day number off a local
|
||||||
|
// clock reading but built the boundaries in UTC, so east of Greenwich the
|
||||||
|
// window was shifted by the offset and part of the evening fell outside
|
||||||
|
// "today": on a +04 box after 20:00 UTC the poller saw an empty calendar. The
|
||||||
|
// owner's day is the day the day plan and the busy gate mean.
|
||||||
|
func ParseICalDay(body []byte, now time.Time) []Event {
|
||||||
|
y, m, d := now.Date()
|
||||||
|
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||||
|
return ParseICal(body, start, start.AddDate(0, 0, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseVEVENT extracts UID, start, end and summary from a VEVENT block.
|
||||||
|
// Reports false for all-day events and parse failures.
|
||||||
|
func parseVEVENT(block string) (Event, bool) {
|
||||||
|
var e Event
|
||||||
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
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"):
|
||||||
|
e.Summary = afterColon(line)
|
||||||
|
case strings.HasPrefix(line, "UID"):
|
||||||
|
e.UID = afterColon(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.Start.IsZero() || e.End.IsZero() {
|
||||||
|
return Event{}, false
|
||||||
|
}
|
||||||
|
return e, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func afterColon(line string) string {
|
||||||
|
if i := strings.Index(line, ":"); i >= 0 {
|
||||||
|
return strings.TrimSpace(line[i+1:])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDT parses a DTSTART/DTEND value:
|
||||||
|
//
|
||||||
|
// - UTC: DTEND:20260703T100000Z
|
||||||
|
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
|
||||||
|
// - All-day: DTSTART;VALUE=DATE:20260703 (rejected)
|
||||||
|
//
|
||||||
|
// A local time is read as UTC, the behaviour cmd/mavcaldav has always had: the
|
||||||
|
// CalDAV server and the poller run in the same timezone, and the busy gate only
|
||||||
|
// needs busy/not-busy to be right.
|
||||||
|
func parseDT(line string) (time.Time, bool) {
|
||||||
|
if strings.Contains(line, "VALUE=DATE:") {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(line, ":")
|
||||||
|
if i < 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
val := strings.TrimSuffix(strings.TrimSpace(line[i+1:]), "Z")
|
||||||
|
t, err := time.Parse("20060102T150405", val)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return t.UTC(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenderICal wraps events in a VCALENDAR body suitable for PUTting to a CalDAV
|
||||||
|
// collection. One event per file is the CalDAV convention, so callers normally
|
||||||
|
// pass a single event.
|
||||||
|
//
|
||||||
|
// This is the write half of #127 and it only ever renders: the canonical state
|
||||||
|
// is sqlite, the calendar is a view of it. Nothing reads a rendered file back.
|
||||||
|
func RenderICal(events []Event) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//maven//local calendar//RU\r\n")
|
||||||
|
for _, e := range events {
|
||||||
|
b.WriteString("BEGIN:VEVENT\r\n")
|
||||||
|
fmt.Fprintf(&b, "UID:%s\r\n", escapeText(e.UID))
|
||||||
|
fmt.Fprintf(&b, "DTSTAMP:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
||||||
|
fmt.Fprintf(&b, "DTSTART:%s\r\n", e.Start.UTC().Format("20060102T150405Z"))
|
||||||
|
fmt.Fprintf(&b, "DTEND:%s\r\n", e.End.UTC().Format("20060102T150405Z"))
|
||||||
|
fmt.Fprintf(&b, "SUMMARY:%s\r\n", escapeText(e.Summary))
|
||||||
|
b.WriteString("END:VEVENT\r\n")
|
||||||
|
}
|
||||||
|
b.WriteString("END:VCALENDAR\r\n")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeText applies RFC 5545 TEXT escaping and strips the line breaks that
|
||||||
|
// would otherwise let a reminder payload inject iCal properties.
|
||||||
|
func escapeText(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||||
|
s = strings.ReplaceAll(s, ";", "\\;")
|
||||||
|
s = strings.ReplaceAll(s, ",", "\\,")
|
||||||
|
s = strings.ReplaceAll(s, "\r\n", "\\n")
|
||||||
|
s = strings.ReplaceAll(s, "\n", "\\n")
|
||||||
|
s = strings.ReplaceAll(s, "\r", "\\n")
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderICalRoundTrips(t *testing.T) {
|
||||||
|
e := ReminderEvent(7, time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC), "позвонить маме", 0)
|
||||||
|
if e.UID != "maven-reminder-7" {
|
||||||
|
t.Errorf("UID = %q", e.UID)
|
||||||
|
}
|
||||||
|
if got := e.End.Sub(e.Start); got != DefaultReminderDuration {
|
||||||
|
t.Errorf("duration = %v, want %v", got, DefaultReminderDuration)
|
||||||
|
}
|
||||||
|
if got, want := ReminderPath(7), "maven-reminder-7.ics"; got != want {
|
||||||
|
t.Errorf("ReminderPath = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
body := RenderICal([]Event{e})
|
||||||
|
if !strings.HasPrefix(body, "BEGIN:VCALENDAR\r\n") || !strings.HasSuffix(body, "END:VCALENDAR\r\n") {
|
||||||
|
t.Fatalf("not a VCALENDAR body:\n%s", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
back := ParseICal([]byte(body), e.Start.Add(-time.Hour), e.Start.Add(time.Hour))
|
||||||
|
if len(back) != 1 {
|
||||||
|
t.Fatalf("got %d events back, want 1:\n%s", len(back), body)
|
||||||
|
}
|
||||||
|
if back[0].UID != e.UID || back[0].Summary != e.Summary {
|
||||||
|
t.Errorf("round trip lost identity: %+v", back[0])
|
||||||
|
}
|
||||||
|
if !back[0].Start.Equal(e.Start) || !back[0].End.Equal(e.End) {
|
||||||
|
t.Errorf("round trip lost times: %+v", back[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderICalIsDeterministic(t *testing.T) {
|
||||||
|
e := ReminderEvent(1, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), "выпить воды", 0)
|
||||||
|
if RenderICal([]Event{e}) != RenderICal([]Event{e}) {
|
||||||
|
t.Error("the same reminder must render byte-identically, or every poll re-PUTs it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reminder payload is owner-supplied text. It must not be able to close the
|
||||||
|
// VEVENT and inject properties of its own.
|
||||||
|
func TestRenderICalEscapesInjection(t *testing.T) {
|
||||||
|
e := ReminderEvent(2, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC),
|
||||||
|
"обед\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nSUMMARY:injected", 0)
|
||||||
|
body := RenderICal([]Event{e})
|
||||||
|
// Count line-initial occurrences: the escaped text still contains the
|
||||||
|
// characters "BEGIN:VEVENT", it just can no longer start a line.
|
||||||
|
if n := strings.Count(body, "\r\nBEGIN:VEVENT\r\n"); n != 1 {
|
||||||
|
t.Fatalf("payload injected a second VEVENT (%d):\n%s", n, body)
|
||||||
|
}
|
||||||
|
if n := strings.Count(body, "\r\nEND:VEVENT\r\n"); n != 1 {
|
||||||
|
t.Fatalf("payload closed the VEVENT early (%d):\n%s", n, body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `SUMMARY:обед\nEND:VEVENT`) {
|
||||||
|
t.Errorf("newlines should be escaped, not dropped:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReminderEventEmptyPayload(t *testing.T) {
|
||||||
|
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
||||||
|
if e.Summary != "напоминание" {
|
||||||
|
t.Errorf("Summary = %q, want the neutral RU fallback", e.Summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package calendar
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReminderUIDPrefix namespaces every event maven publishes. Two reasons it is a
|
||||||
|
// fixed prefix and not a random UUID: the render is idempotent (the same
|
||||||
|
// reminder always lands on the same UID, so re-rendering overwrites instead of
|
||||||
|
// duplicating), and everything maven owns in the target collection is
|
||||||
|
// identifiable at a glance — she never touches a file she did not create.
|
||||||
|
const ReminderUIDPrefix = "maven-reminder-"
|
||||||
|
|
||||||
|
// DefaultReminderDuration — how long a rendered reminder occupies. A reminder
|
||||||
|
// is an instant, a calendar entry is a span, so one has to be invented; 30
|
||||||
|
// minutes reads as a block in a calendar app without swallowing the afternoon.
|
||||||
|
const DefaultReminderDuration = 30 * time.Minute
|
||||||
|
|
||||||
|
// ReminderEvent maps a reminder to the event that represents it. id and fire
|
||||||
|
// come from the store; payload is the RU text as the owner said it, rendered
|
||||||
|
// verbatim as the summary — the calendar is a view of sqlite, not a place to
|
||||||
|
// rephrase.
|
||||||
|
func ReminderEvent(id int64, fire time.Time, payload string, dur time.Duration) Event {
|
||||||
|
if dur <= 0 {
|
||||||
|
dur = DefaultReminderDuration
|
||||||
|
}
|
||||||
|
summary := strings.TrimSpace(payload)
|
||||||
|
if summary == "" {
|
||||||
|
summary = "напоминание"
|
||||||
|
}
|
||||||
|
return Event{
|
||||||
|
UID: fmt.Sprintf("%s%d", ReminderUIDPrefix, id),
|
||||||
|
Summary: summary,
|
||||||
|
Start: fire,
|
||||||
|
End: fire.Add(dur),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReminderPath is the collection-relative filename for a rendered reminder.
|
||||||
|
// One event per resource, per the CalDAV convention.
|
||||||
|
func ReminderPath(id int64) string {
|
||||||
|
return fmt.Sprintf("%s%d.ics", ReminderUIDPrefix, id)
|
||||||
|
}
|
||||||
+254
-12
@@ -140,6 +140,27 @@ type Config struct {
|
|||||||
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
||||||
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
|
MorningRoutines []MorningRoutineConfig `json:"morning_routines,omitempty"`
|
||||||
|
|
||||||
|
// PatternProposals — whether a routine the digestion tick inferred on its
|
||||||
|
// own may be announced, and how often. nil / absent ⇒ silent detection
|
||||||
|
// only: proposals are written for /routines and never announced. See
|
||||||
|
// PatternProposalConfig.
|
||||||
|
PatternProposals *PatternProposalConfig `json:"pattern_proposals,omitempty"`
|
||||||
|
|
||||||
|
// MemoryEval — background memory evaluation (internal/memeval). nil /
|
||||||
|
// absent ⇒ no evaluation loop at all. See MemoryEvalConfig.
|
||||||
|
MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"`
|
||||||
|
|
||||||
|
// Email — mail ingestion (Vikunja #246). nil / absent ⇒ core refuses
|
||||||
|
// ipc.MethodIngestMail outright, so a mail reader cannot make Maven read a
|
||||||
|
// mailbox by merely existing. See EmailConfig; the IMAP host and credential
|
||||||
|
// live in the reader (cmd/mavmaild), never here.
|
||||||
|
Email *EmailConfig `json:"email,omitempty"`
|
||||||
|
|
||||||
|
// Feeds — RSS/Atom feed reading (Vikunja #258). nil / absent ⇒ no feed is
|
||||||
|
// ever fetched: reading the outside world is off unless configured, like
|
||||||
|
// the weather and telegram. See FeedsConfig.
|
||||||
|
Feeds *FeedsConfig `json:"feeds,omitempty"`
|
||||||
|
|
||||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||||
@@ -258,18 +279,25 @@ type VoiceConfig struct {
|
|||||||
RouterThreshold float64 `json:"router_threshold,omitempty"`
|
RouterThreshold float64 `json:"router_threshold,omitempty"`
|
||||||
|
|
||||||
// LLMRouter — route with the resident model instead of the embedding
|
// LLMRouter — route with the resident model instead of the embedding
|
||||||
// classifier. Measured on the held-out fixture (ROUTING-EVAL-31-07-2026.md)
|
// classifier. On by default since Vikunja #320.
|
||||||
// the model gets 50.0% of intents right against the classifier's 36.8%, but
|
|
||||||
// it costs about 800ms per turn instead of 30ms.
|
|
||||||
//
|
//
|
||||||
// TODO: the default stays false until two things land.
|
// Measured on the held-out fixture (ROUTING-EVAL-31-07-2026.md): 63.2% of
|
||||||
// 1. The LLM router cannot refuse. LLMRouter.Route hardcodes
|
// intents right against the classifier's 50.0%, and no route errors. It
|
||||||
// Confidence: 1.0, so the stage-3 clarify gate never fires and an
|
// costs about 1s per turn instead of 30ms.
|
||||||
// unclear utterance becomes a confident wrong action (Vikunja #359).
|
//
|
||||||
// 2. Extractor.Extract never runs on an LLM decision, so acts arrive with
|
// It is safe to leave on. The model can refuse — it answers "unknown" when
|
||||||
// no Fn and reminders with no Time.
|
// it cannot route, and the turn drops to the classifier and its clarify
|
||||||
// Turning this on today makes routing more accurate and less safe.
|
// gate. Any LLM error does the same, so a turn never breaks on the model.
|
||||||
LLMRouter bool `json:"llm_router,omitempty"`
|
// Slot extraction runs on LLM decisions too, so acts get their Fn and
|
||||||
|
// reminders their Time.
|
||||||
|
//
|
||||||
|
// Set it false to go back to the classifier, e.g. on a box with no
|
||||||
|
// llama-server or when 1s a turn is too slow.
|
||||||
|
//
|
||||||
|
// It is a pointer so that "missing from the file" and "explicitly false"
|
||||||
|
// are different things: missing means on, false means off. Read it with
|
||||||
|
// UseLLMRouter(), not directly.
|
||||||
|
LLMRouter *bool `json:"llm_router,omitempty"`
|
||||||
|
|
||||||
// QueryMinScore — the note-recall confidence gate. Top cosine below this
|
// QueryMinScore — the note-recall confidence gate. Top cosine below this
|
||||||
// ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55);
|
// ⇒ "I don't know" instead of a guess. Tuned for the ONNX embedder (0.55);
|
||||||
@@ -277,12 +305,31 @@ type VoiceConfig struct {
|
|||||||
// default if unset.
|
// default if unset.
|
||||||
QueryMinScore float64 `json:"query_min_score,omitempty"`
|
QueryMinScore float64 `json:"query_min_score,omitempty"`
|
||||||
|
|
||||||
|
// QueryMinMargin — the second half of the recall gate: the top hit must
|
||||||
|
// beat the runner-up by more than this. The absolute score above cannot do
|
||||||
|
// the job on its own, because the e5 embedder puts every cosine in one
|
||||||
|
// narrow high band, so a made-up question scores as high as a real one.
|
||||||
|
// The margin asks whether one note is clearly the best instead.
|
||||||
|
// Negative ⇒ off. 0 ⇒ the default below.
|
||||||
|
QueryMinMargin float64 `json:"query_min_margin,omitempty"`
|
||||||
|
|
||||||
|
// ClarifyMaxAttempts — how many clarifying questions she may ask about one
|
||||||
|
// request before she gives up and says she did not understand. Default 3.
|
||||||
|
ClarifyMaxAttempts int `json:"clarify_max_attempts,omitempty"`
|
||||||
|
|
||||||
// Persona — optional prompt prefix that tunes maven's character. Prepended
|
// Persona — optional prompt prefix that tunes maven's character. Prepended
|
||||||
// to every LLM system prompt (nudge phrasing, note queries, general
|
// to every LLM system prompt (nudge phrasing, note queries, general
|
||||||
// knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered
|
// knowledge). Empty string ⇒ current hardcoded persona (feminine-gendered
|
||||||
// Russian self-reference). Example: "Be formal and answer in English only."
|
// Russian self-reference). Example: "Be formal and answer in English only."
|
||||||
Persona string `json:"persona,omitempty"`
|
Persona string `json:"persona,omitempty"`
|
||||||
|
|
||||||
|
// OwnerName / City — optional facts about the owner, added to the shared
|
||||||
|
// context block (internal/persona). Empty is fine: the block still states
|
||||||
|
// who he is grammatically (a man, addressed as "ты") and the current time.
|
||||||
|
// Nothing about correct behaviour may depend on these being filled in.
|
||||||
|
OwnerName string `json:"owner_name,omitempty"`
|
||||||
|
City string `json:"city,omitempty"`
|
||||||
|
|
||||||
// Weather — the weather provider config. nil ⇒ the daemon wires
|
// Weather — the weather provider config. nil ⇒ the daemon wires
|
||||||
// the stub provider (returns ErrNotConfigured — "погода не настроена").
|
// the stub provider (returns ErrNotConfigured — "погода не настроена").
|
||||||
// Set provider to "open-meteo" to use the keyless Open-Meteo API.
|
// Set provider to "open-meteo" to use the keyless Open-Meteo API.
|
||||||
@@ -326,6 +373,129 @@ type DigestConfig struct {
|
|||||||
SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched
|
SeverityCeiling int `json:"severity_ceiling,omitempty"` // max sev batched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PatternProposalConfig — announcement policy for routines the digestion tick
|
||||||
|
// inferred by itself (Vikunja #247, #43).
|
||||||
|
//
|
||||||
|
// Detection is always on and always silent by default: the tick writes a
|
||||||
|
// proposed_routines row and the /routines page shows it. Notify is what turns
|
||||||
|
// "she noticed" into "she said something", and it is OFF unless configured —
|
||||||
|
// Maven is not a nag and not autonomous, so a behaviour that speaks without
|
||||||
|
// being asked has to be switched on deliberately, like weather and telegram.
|
||||||
|
//
|
||||||
|
// When Notify is on, the announcement is still heavily restrained:
|
||||||
|
// - at most one proposal per tick, however many were detected;
|
||||||
|
// - at most one per Cooldown across all pairs (not per pair), so a batch of
|
||||||
|
// freshly-detected patterns cannot turn into a queue of interruptions;
|
||||||
|
// - through the ordinary care-class gate (quiet hours / away / snooze), at
|
||||||
|
// sev1 — the lowest severity there is. A proposal is the least urgent
|
||||||
|
// thing Maven can say.
|
||||||
|
//
|
||||||
|
// A pair is only ever announced once, because it is only ever proposed once:
|
||||||
|
// proposed_routines is UNIQUE(action, object) and the row survives dismissal.
|
||||||
|
type PatternProposalConfig struct {
|
||||||
|
// Notify — announce newly inferred routines. Default false.
|
||||||
|
Notify bool `json:"notify,omitempty"`
|
||||||
|
|
||||||
|
// Cooldown — minimum spacing between two proposal announcements. 0 ⇒
|
||||||
|
// DefaultProposalCooldown (24h).
|
||||||
|
Cooldown Duration `json:"cooldown,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnnounceProposals reports whether inferred routines may be announced. Safe
|
||||||
|
// on a nil receiver — an absent config block means silent detection.
|
||||||
|
func (p *PatternProposalConfig) AnnounceProposals() bool {
|
||||||
|
return p != nil && p.Notify
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedsConfig — the RSS/Atom reader (Vikunja #258, docs/plans/13-rss-news-feeds.md).
|
||||||
|
//
|
||||||
|
// Absent ⇒ off, and off means no outbound request at all. Present with an empty
|
||||||
|
// `sources` list is also off — a poller with nothing to poll is not wired.
|
||||||
|
//
|
||||||
|
// What a feed may NOT do here: speak. Items are written as notes with source
|
||||||
|
// "rss:<name>" and read back when he asks; nothing is dispatched, nudged or
|
||||||
|
// announced on arrival. That is the "not a nag" constraint, and it is why there
|
||||||
|
// is no severity or channel field in this block to reach for.
|
||||||
|
type FeedsConfig struct {
|
||||||
|
// Sources — the feeds to read. Empty ⇒ the reader stays down.
|
||||||
|
Sources []FeedSourceConfig `json:"sources,omitempty"`
|
||||||
|
|
||||||
|
// PollInterval — default per-feed cadence. 0 ⇒ rss.DefaultPollInterval (30m).
|
||||||
|
PollInterval Duration `json:"poll_interval,omitempty"`
|
||||||
|
|
||||||
|
// MaxItems — most items kept from one feed in one poll. 0 ⇒
|
||||||
|
// rss.DefaultMaxItems (5). This is the "не завали мне /dash" knob.
|
||||||
|
MaxItems int `json:"max_items,omitempty"`
|
||||||
|
|
||||||
|
// MaxAge — on a first poll (no saved mark), how far back to take items.
|
||||||
|
// 0 ⇒ rss.DefaultMaxAge (24h), so switching a feed on imports today, not
|
||||||
|
// the archive.
|
||||||
|
MaxAge Duration `json:"max_age,omitempty"`
|
||||||
|
|
||||||
|
// AllowHosts — when set, the reader may only connect to these hosts (and
|
||||||
|
// their subdomains). The feed URLs' own hosts are added automatically, so
|
||||||
|
// this is only needed to be stricter than that.
|
||||||
|
AllowHosts []string `json:"allow_hosts,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
|
||||||
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
|
||||||
|
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FeedSourceConfig — one feed.
|
||||||
|
type FeedSourceConfig struct {
|
||||||
|
Name string `json:"name"` // note source is "rss:<name>"
|
||||||
|
URL string `json:"url"` // http(s) only
|
||||||
|
Category string `json:"category,omitempty"` // "технологии" — what "что нового по X?" matches
|
||||||
|
Interval Duration `json:"interval,omitempty"` // 0 ⇒ FeedsConfig.PollInterval
|
||||||
|
Include []string `json:"include,omitempty"` // keep only items containing one of these
|
||||||
|
Exclude []string `json:"exclude,omitempty"` // drop items containing any of these
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
|
||||||
|
// Absent ⇒ off, like every other capability that costs something the owner did
|
||||||
|
// not ask for. Each evaluation is a full LLM round-trip on the one resident
|
||||||
|
// model, which is the same model answering him; running it hourly by default
|
||||||
|
// would put a multi-second stall in front of an occasional voice turn for a
|
||||||
|
// feature he may not want.
|
||||||
|
//
|
||||||
|
// The loop only ever writes notes (source infer:memory-eval, visible on
|
||||||
|
// /dash). It cannot speak — see internal/memeval.
|
||||||
|
type MemoryEvalConfig struct {
|
||||||
|
// Interval — how often to evaluate. 0 ⇒ DefaultMemoryEvalInterval.
|
||||||
|
Interval Duration `json:"interval,omitempty"`
|
||||||
|
|
||||||
|
// MaxItems — recent facts / notes / nudges fed into one evaluation.
|
||||||
|
// 0 ⇒ memeval.DefaultMaxItems.
|
||||||
|
MaxItems int `json:"max_items,omitempty"`
|
||||||
|
|
||||||
|
// MinConfidence — observations the model scores below this are dropped.
|
||||||
|
// 0 ⇒ memeval.DefaultMinConfidence.
|
||||||
|
MinConfidence float64 `json:"min_confidence,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailConfig — core's half of the email reader: how many task candidates one
|
||||||
|
// message may produce, and how long the extraction call may take.
|
||||||
|
//
|
||||||
|
// There is deliberately nothing about a mailbox here. Core does not connect to
|
||||||
|
// IMAP, does not know an account exists, and holds no mail credential — the
|
||||||
|
// reader daemon does, the same split mavpoll uses for the zenmoney token. This
|
||||||
|
// block only says "extraction is allowed, with these bounds".
|
||||||
|
type EmailConfig struct {
|
||||||
|
// MaxTasks — candidates per message. 0 ⇒ email.MaxCandidates (3).
|
||||||
|
MaxTasks int `json:"max_tasks,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — per-message extraction budget. 0 ⇒ DefaultEmailTimeout. This is
|
||||||
|
// a Thinking model reading a mail; nobody is waiting on the answer, but a
|
||||||
|
// hung llama-server must not pin the reader's connection forever.
|
||||||
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultEmailTimeout — extraction budget per message.
|
||||||
|
const DefaultEmailTimeout = 2 * time.Minute
|
||||||
|
|
||||||
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||||
@@ -343,6 +513,12 @@ type PhraserConfig struct {
|
|||||||
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
||||||
NCtx int `json:"n_ctx,omitempty"`
|
NCtx int `json:"n_ctx,omitempty"`
|
||||||
Timeout Duration `json:"timeout,omitempty"`
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// LLMNudges — let the model word nudges again. Off by default: nudges are
|
||||||
|
// worded from hand-written Russian templates now (the model broke the
|
||||||
|
// persona and invented units). Chat, query and reminder phrasing always go
|
||||||
|
// through the model regardless. See phraser.Config.LLMNudges.
|
||||||
|
LLMNudges bool `json:"llm_nudges,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||||
@@ -404,9 +580,27 @@ const (
|
|||||||
DefaultAutotuneInterval = 10 * time.Minute
|
DefaultAutotuneInterval = 10 * time.Minute
|
||||||
DefaultRouterThreshold = 0.55
|
DefaultRouterThreshold = 0.55
|
||||||
DefaultQueryMinScore = 0.55
|
DefaultQueryMinScore = 0.55
|
||||||
DefaultToolTimeout = 30 * time.Second
|
// Read off the margin sweep in internal/memory/recalleval on the e5
|
||||||
|
// embedder: 0.008 answers 68% of real questions (down from 72%) and cuts
|
||||||
|
// false recall from 5/5 to 1/5. Every larger delta costs real recall
|
||||||
|
// without removing that last one until 0.020, which drops recall to 44%.
|
||||||
|
DefaultQueryMinMargin = 0.008
|
||||||
|
// DefaultClarifyMaxAttempts — see dialogue.DefaultMaxAttempts.
|
||||||
|
DefaultClarifyMaxAttempts = 3
|
||||||
|
DefaultToolTimeout = 30 * time.Second
|
||||||
|
// DefaultLLMRouter — route with the resident model unless told otherwise.
|
||||||
|
DefaultLLMRouter = true
|
||||||
|
|
||||||
DefaultFactEnrichmentInterval = 30 * time.Second
|
DefaultFactEnrichmentInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// DefaultProposalCooldown — one inferred-routine announcement per day at
|
||||||
|
// most. A proposal is never urgent; if two patterns surface in the same
|
||||||
|
// hour, the second one waits, and the /routines page has it either way.
|
||||||
|
DefaultProposalCooldown = 24 * time.Hour
|
||||||
|
|
||||||
|
// DefaultMemoryEvalInterval — the plan's cadence (1h) for the memory
|
||||||
|
// evaluation loop, applied only when the block is present at all.
|
||||||
|
DefaultMemoryEvalInterval = time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads the JSON config at path and applies defaults. A missing file is
|
// Load reads the JSON config at path and applies defaults. A missing file is
|
||||||
@@ -479,6 +673,30 @@ func (c *Config) applyDefaults() {
|
|||||||
c.Digest.SeverityCeiling = 2
|
c.Digest.SeverityCeiling = 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Absent block stays nil (⇒ silent detection). Present-but-partial gets the
|
||||||
|
// cooldown default, so `{"notify": true}` is enough to switch it on.
|
||||||
|
if c.PatternProposals != nil && c.PatternProposals.Cooldown <= 0 {
|
||||||
|
c.PatternProposals.Cooldown = Duration(DefaultProposalCooldown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rule: absent stays nil (⇒ no evaluation loop), present gets defaults
|
||||||
|
// so `{}` is a valid "on with the plan's cadence".
|
||||||
|
if c.MemoryEval != nil && c.MemoryEval.Interval <= 0 {
|
||||||
|
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same rule again: absent stays nil (⇒ mail ingestion refused), present gets
|
||||||
|
// the timeout default so `{}` is a valid "on with the defaults".
|
||||||
|
if c.Email != nil && c.Email.Timeout <= 0 {
|
||||||
|
c.Email.Timeout = Duration(DefaultEmailTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A feeds block with no sources is the same as no block: nothing to poll,
|
||||||
|
// nothing wired. Normalising it to nil keeps that "off" in one place.
|
||||||
|
if c.Feeds != nil && len(c.Feeds.Sources) == 0 {
|
||||||
|
c.Feeds = nil
|
||||||
|
}
|
||||||
|
|
||||||
if c.Voice != nil {
|
if c.Voice != nil {
|
||||||
if c.Voice.RouterThreshold <= 0 {
|
if c.Voice.RouterThreshold <= 0 {
|
||||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||||
@@ -486,9 +704,24 @@ func (c *Config) applyDefaults() {
|
|||||||
if c.Voice.QueryMinScore <= 0 {
|
if c.Voice.QueryMinScore <= 0 {
|
||||||
c.Voice.QueryMinScore = DefaultQueryMinScore
|
c.Voice.QueryMinScore = DefaultQueryMinScore
|
||||||
}
|
}
|
||||||
|
// Unset ⇒ default. Negative is how you turn the margin off on purpose,
|
||||||
|
// so it is clamped to 0 rather than replaced by the default.
|
||||||
|
switch {
|
||||||
|
case c.Voice.QueryMinMargin == 0:
|
||||||
|
c.Voice.QueryMinMargin = DefaultQueryMinMargin
|
||||||
|
case c.Voice.QueryMinMargin < 0:
|
||||||
|
c.Voice.QueryMinMargin = 0
|
||||||
|
}
|
||||||
|
if c.Voice.ClarifyMaxAttempts <= 0 {
|
||||||
|
c.Voice.ClarifyMaxAttempts = DefaultClarifyMaxAttempts
|
||||||
|
}
|
||||||
if c.Voice.ToolTimeout <= 0 {
|
if c.Voice.ToolTimeout <= 0 {
|
||||||
c.Voice.ToolTimeout = Duration(DefaultToolTimeout)
|
c.Voice.ToolTimeout = Duration(DefaultToolTimeout)
|
||||||
}
|
}
|
||||||
|
if c.Voice.LLMRouter == nil {
|
||||||
|
on := DefaultLLMRouter
|
||||||
|
c.Voice.LLMRouter = &on
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// routines: default severity to care-class (1) — the safe floor: a
|
// routines: default severity to care-class (1) — the safe floor: a
|
||||||
@@ -507,6 +740,15 @@ func (c *Config) applyDefaults() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UseLLMRouter reports whether to route with the resident model. Unset means
|
||||||
|
// on; only an explicit false in the config turns it off.
|
||||||
|
func (v *VoiceConfig) UseLLMRouter() bool {
|
||||||
|
if v == nil || v.LLMRouter == nil {
|
||||||
|
return DefaultLLMRouter
|
||||||
|
}
|
||||||
|
return *v.LLMRouter
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
if c.Phraser != nil {
|
if c.Phraser != nil {
|
||||||
if c.Phraser.ModelPath == "" {
|
if c.Phraser.ModelPath == "" {
|
||||||
|
|||||||
@@ -35,6 +35,27 @@ func TestLoadDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nudges come from templates unless the config says otherwise.
|
||||||
|
func TestPhraserLLMNudgesDefaultsOff(t *testing.T) {
|
||||||
|
p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`)
|
||||||
|
c, err := Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.Phraser.LLMNudges {
|
||||||
|
t.Error("llm_nudges defaults on; templates must be the default")
|
||||||
|
}
|
||||||
|
|
||||||
|
p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`)
|
||||||
|
c, err = Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if !c.Phraser.LLMNudges {
|
||||||
|
t.Error("llm_nudges:true did not parse")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadDurationsParse(t *testing.T) {
|
func TestLoadDurationsParse(t *testing.T) {
|
||||||
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
||||||
c, err := Load(p)
|
c, err := Load(p)
|
||||||
@@ -171,14 +192,26 @@ func TestWeatherConfigNilOK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMRouterDefaultsOff(t *testing.T) {
|
func TestLLMRouterDefaultsOn(t *testing.T) {
|
||||||
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`)
|
||||||
c, err := Load(p)
|
c, err := Load(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
if c.Voice.LLMRouter {
|
if !c.Voice.UseLLMRouter() {
|
||||||
t.Error("voice.llm_router absent should mean false")
|
t.Error("voice.llm_router absent should mean on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing and explicitly false must not mean the same thing.
|
||||||
|
func TestLLMRouterExplicitFalseTurnsItOff(t *testing.T) {
|
||||||
|
p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":false}}`)
|
||||||
|
c, err := Load(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.Voice.UseLLMRouter() {
|
||||||
|
t.Error("voice.llm_router false should turn it off")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +221,7 @@ func TestLLMRouterRead(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load: %v", err)
|
t.Fatalf("Load: %v", err)
|
||||||
}
|
}
|
||||||
if !c.Voice.LLMRouter {
|
if !c.Voice.UseLLMRouter() {
|
||||||
t.Error("voice.llm_router true was not read")
|
t.Error("voice.llm_router true was not read")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,3 +243,53 @@ func TestDurationRoundTrip(t *testing.T) {
|
|||||||
t.Errorf("round-trip = %v, want %v", d2, d)
|
t.Errorf("round-trip = %v, want %v", d2, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Both new opt-in capabilities follow the same rule: absent block ⇒ nil ⇒ the
|
||||||
|
// behaviour does not exist. Presence is the enable act, so a bare `{}` block is
|
||||||
|
// valid and gets the defaults filled in.
|
||||||
|
func TestOptInBlocksAbsentStayNil(t *testing.T) {
|
||||||
|
c, err := Load(writeConfig(t, `{}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.PatternProposals != nil {
|
||||||
|
t.Errorf("pattern_proposals absent but got %+v", c.PatternProposals)
|
||||||
|
}
|
||||||
|
if c.PatternProposals.AnnounceProposals() {
|
||||||
|
t.Error("AnnounceProposals() true with no config block")
|
||||||
|
}
|
||||||
|
if c.MemoryEval != nil {
|
||||||
|
t.Errorf("memory_eval absent but got %+v", c.MemoryEval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOptInBlocksGetDefaultsWhenPresent(t *testing.T) {
|
||||||
|
c, err := Load(writeConfig(t, `{"pattern_proposals":{"notify":true},"memory_eval":{}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if !c.PatternProposals.AnnounceProposals() {
|
||||||
|
t.Error("notify:true did not enable announcements")
|
||||||
|
}
|
||||||
|
if time.Duration(c.PatternProposals.Cooldown) != DefaultProposalCooldown {
|
||||||
|
t.Errorf("proposal cooldown = %v, want %v", c.PatternProposals.Cooldown, DefaultProposalCooldown)
|
||||||
|
}
|
||||||
|
if time.Duration(c.MemoryEval.Interval) != DefaultMemoryEvalInterval {
|
||||||
|
t.Errorf("memory eval interval = %v, want %v", c.MemoryEval.Interval, DefaultMemoryEvalInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify is off even when the block exists — the block is where you tune it,
|
||||||
|
// notify:true is the act that lets her speak.
|
||||||
|
func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
|
||||||
|
c, err := Load(writeConfig(t, `{"pattern_proposals":{"cooldown":"6h"}}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if c.PatternProposals.AnnounceProposals() {
|
||||||
|
t.Error("notify defaulted to on")
|
||||||
|
}
|
||||||
|
if time.Duration(c.PatternProposals.Cooldown) != 6*time.Hour {
|
||||||
|
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -137,9 +137,10 @@ func NewDispatcher(cfg Config) *Dispatcher {
|
|||||||
// picks for (severity, presence), sends via the matching sink, and records
|
// picks for (severity, presence), sends via the matching sink, and records
|
||||||
// one nudge row per successful send. returns the dispatches (one per channel).
|
// one nudge row per successful send. returns the dispatches (one per channel).
|
||||||
//
|
//
|
||||||
// a Drop channel = no send, no record (the nudge was suppressed by routing,
|
// a Drop channel = no send (the nudge was suppressed by routing, not by a
|
||||||
// not by a failure — "a missed water nudge is noise"). a nil sink = channel
|
// failure — "a missed water nudge is noise"), but it does leave a 'dropped'
|
||||||
// not wired, skip silently. a send error stops the dispatch and returns what
|
// outbox row so the suppression is visible. a nil sink = channel not wired,
|
||||||
|
// skip silently. a send error stops the dispatch and returns what
|
||||||
// got through — the daemon decides whether to retry.
|
// got through — the daemon decides whether to retry.
|
||||||
func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now time.Time) ([]Dispatch, error) {
|
func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now time.Time) ([]Dispatch, error) {
|
||||||
c := pn.Candidate
|
c := pn.Candidate
|
||||||
@@ -148,6 +149,16 @@ func (d *Dispatcher) DispatchNudge(ctx context.Context, pn PhrasedNudge, now tim
|
|||||||
for i := 0; i < len(channels); i++ {
|
for i := 0; i < len(channels); i++ {
|
||||||
ch := channels[i]
|
ch := channels[i]
|
||||||
if ch == ChannelDrop {
|
if ch == ChannelDrop {
|
||||||
|
// the routing table suppressed this nudge on purpose (a care nudge
|
||||||
|
// while you're away is noise). that stays — but it must not be
|
||||||
|
// invisible, or "she dropped it" and "the rule never fired" look
|
||||||
|
// the same afterwards. no nudges row: that table feeds the
|
||||||
|
// ignored_rate signal, and a nudge nobody could see must not
|
||||||
|
// count as ignored.
|
||||||
|
id := d.beginOutbox(ctx, "nudge", c.Rule.Name, 0, ch, pn.Summary, now)
|
||||||
|
d.completeOutbox(ctx, id, store.DeliveryDropped, now)
|
||||||
|
log.Printf("dispatcher: dropped %s (sev%d, presence=%s) — routing table suppressed it",
|
||||||
|
c.Rule.Name, c.Severity, c.State.Presence)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s := Sendable{
|
s := Sendable{
|
||||||
@@ -396,6 +407,13 @@ func messageForChannel(s Sendable) string {
|
|||||||
if !isAway(s.Channel) {
|
if !isAway(s.Channel) {
|
||||||
return s.Body
|
return s.Body
|
||||||
}
|
}
|
||||||
|
return AwayMessage(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AwayMessage — the only text an off-box channel may ever carry. Exported so
|
||||||
|
// the away sinks share this one rule instead of each inventing a fallback: the
|
||||||
|
// summary if we have one, otherwise a fixed generic line. Never the body.
|
||||||
|
func AwayMessage(s Sendable) string {
|
||||||
if s.Summary != "" {
|
if s.Summary != "" {
|
||||||
return s.Summary
|
return s.Summary
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ func TestVoiceNoSessionFallthroughLeavesOutboxTrail(t *testing.T) {
|
|||||||
[]string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
[]string{"voice", "ntfy"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||||
{"sev4 falls through to telegram", loop.Sev4,
|
{"sev4 falls through to telegram", loop.Sev4,
|
||||||
[]string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
[]string{"voice", "telegram"}, []string{store.DeliveryFailed, store.DeliverySent}},
|
||||||
{"sev1 does not fall through", loop.Sev1,
|
// care severities still don't reach an away channel; since #370 the
|
||||||
[]string{"voice"}, []string{store.DeliveryFailed}},
|
// drop itself is a visible row instead of nothing.
|
||||||
{"sev2 does not fall through", loop.Sev2,
|
{"sev1 drops instead of falling through", loop.Sev1,
|
||||||
[]string{"voice"}, []string{store.DeliveryFailed}},
|
[]string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}},
|
||||||
|
{"sev2 drops instead of falling through", loop.Sev2,
|
||||||
|
[]string{"voice", "drop"}, []string{store.DeliveryFailed, store.DeliveryDropped}},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
//
|
//
|
||||||
// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard)
|
// ntfy is the away-channel for sev3 (ops soft) nudges, sev4 (ops hard)
|
||||||
// nudges when present (alongside voice), and reminders when away. the
|
// nudges when present (alongside voice), and reminders when away. the
|
||||||
// message body is the Sendable's Summary — the minimal-body rule from the
|
// message body is delivery.AwayMessage — the minimal-body rule from the
|
||||||
// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through
|
// spec ("disk low on homesrv," not detail; no shoulder-surf exfil through
|
||||||
// the relay). voice gets Body; away channels get Summary, enforced at the
|
// the relay). the dispatcher already strips detail off away sendables; the
|
||||||
// sink so a phraser bug can't exfil.
|
// sink uses the same helper so it can't leak the body on its own either.
|
||||||
//
|
//
|
||||||
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
|
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
|
||||||
// with a dedicated user (write-only to maven-* topics) — the credential is a
|
// with a dedicated user (write-only to maven-* topics) — the credential is a
|
||||||
@@ -69,18 +69,14 @@ func New(cfg Config) (*Sink, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send publishes one notification to ntfy. the body is the Sendable's Summary
|
// Send publishes one notification to ntfy. the body is the minimal away
|
||||||
// (minimal body); Title is "maven" (consistent sender identity on the lock
|
// message (never the full body); Title is "maven" (consistent sender identity
|
||||||
// screen — the content is in the body). Priority maps from severity/kind so
|
// on the lock screen — the content is in the body). Priority maps from severity/kind so
|
||||||
// the phone client can ring differently for an alarm vs a soft ops nudge.
|
// the phone client can ring differently for an alarm vs a soft ops nudge.
|
||||||
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||||
body := d.Summary
|
// never fall back to d.Body: ntfy leaves the box, so an empty summary gets
|
||||||
if body == "" {
|
// a generic line instead of the full detail.
|
||||||
body = d.Body // terse full message beats no message
|
body := delivery.AwayMessage(d)
|
||||||
}
|
|
||||||
if body == "" {
|
|
||||||
return fmt.Errorf("ntfysink: empty message for %s", d.Channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body))
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.topicURL(), strings.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -147,9 +147,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
|
||||||
// a terse full message is better than no message; the phraser should
|
// #368: this used to fall back to the full body. ntfy leaves the box, so
|
||||||
// produce a summary for away-bound severities, but don't silently drop.
|
// an empty summary gets a fixed generic line plus the rule name instead.
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -160,12 +160,15 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
|||||||
t.Fatalf("Send: %v", err)
|
t.Fatalf("Send: %v", err)
|
||||||
}
|
}
|
||||||
_, _, body, _, _, _ := rs.snapshot()
|
_, _, body, _, _, _ := rs.snapshot()
|
||||||
if body != s.Body {
|
want := delivery.GenericAwayMessage + ": service_down"
|
||||||
t.Fatalf("fallback body: want %q, got %q", s.Body, body)
|
if body != want {
|
||||||
|
t.Fatalf("body: want %q, got %q", want, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendRejectsEmptyMessage(t *testing.T) {
|
func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
|
||||||
|
// with nothing at all to say we still send the generic line — an away
|
||||||
|
// channel can never carry detail, but it also never goes out blank.
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -173,9 +176,13 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
|
|||||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven"})
|
||||||
s := nudgeSendable(loop.Sev3, "")
|
s := nudgeSendable(loop.Sev3, "")
|
||||||
s.Body = ""
|
s.Body = ""
|
||||||
err := sink.Send(context.Background(), s)
|
s.RuleName = ""
|
||||||
if err == nil {
|
if err := sink.Send(context.Background(), s); err != nil {
|
||||||
t.Fatal("want error for empty message")
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
_, _, body, _, _, _ := rs.snapshot()
|
||||||
|
if body != delivery.GenericAwayMessage {
|
||||||
|
t.Fatalf("body: want %q, got %q", delivery.GenericAwayMessage, body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,10 +208,9 @@ func TestAwayChannelsGetMinimalBody(t *testing.T) {
|
|||||||
// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water
|
// TestCareAwayDropIsRecorded — DESIGN.md's drop is a decision ("a missed water
|
||||||
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
// nudge is noise, a missed backup failure isn't"), so it should be visible
|
||||||
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
// rather than vanish. Today drop is a bare `continue`: no nudge row, no outbox
|
||||||
// attempt, no log — nothing an operator can see afterwards.
|
// attempt, no log — nothing an operator can see afterwards. now it leaves a
|
||||||
|
// 'dropped' outbox row.
|
||||||
func TestCareAwayDropIsRecorded(t *testing.T) {
|
func TestCareAwayDropIsRecorded(t *testing.T) {
|
||||||
t.Skip("not implemented: dispatcher.go:149-151 skips a Drop channel with no record; there is no 'dropped' outcome in store/delivery.go:16-21")
|
|
||||||
|
|
||||||
ob := &fakeOutbox{}
|
ob := &fakeOutbox{}
|
||||||
d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob})
|
d := NewDispatcher(Config{Voice: &fakeSink{}, Nudges: &fakeNudgeRecorder{}, Outbox: ob})
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
//
|
//
|
||||||
// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm
|
// telegram is the away-channel for sev4 (ops hard) nudges — "disk-fire alarm
|
||||||
// at 2am routes to telegram, repeat til ack." the message body is the
|
// at 2am routes to telegram, repeat til ack." the message body is the
|
||||||
// Sendable's Summary — the minimal-body rule from the spec ("disk low on
|
// delivery.AwayMessage — the minimal-body rule from the spec ("disk low on
|
||||||
// homesrv," not detail; no shoulder-surf exfil through the relay). voice gets
|
// homesrv," not detail; no shoulder-surf exfil through the relay). the
|
||||||
// Body; away channels get Summary, enforced at the sink so a phraser bug can't
|
// dispatcher already strips detail off away sendables; the sink uses the same
|
||||||
// exfil. additionally, protect_content=true is passed on every send so the
|
// helper so it can't leak the body on its own either. additionally,
|
||||||
// message can't be forwarded out of the chat — locks the minimal body further.
|
// protect_content=true is passed on every send so the message can't be
|
||||||
|
// forwarded out of the chat — locks the minimal body further.
|
||||||
//
|
//
|
||||||
// telegram's bot API is region-restricted for this homesrv — direct egress to
|
// telegram's bot API is region-restricted for this homesrv — direct egress to
|
||||||
// api.telegram.org is unreliable. the spec's "away channels leave the box —
|
// api.telegram.org is unreliable. the spec's "away channels leave the box —
|
||||||
@@ -140,18 +141,13 @@ type telegramResp struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send publishes one message to the configured telegram chat. the body is the
|
// Send publishes one message to the configured telegram chat. the body is the
|
||||||
// Sendable's Summary (minimal body); empty Summary falls back to Body (terse
|
// minimal away message (never the full body). protect_content=true so even
|
||||||
// full message beats no message). protect_content=true so a phraser bug (Body
|
// that can't be forwarded onward by the user or a chat observer — locks the
|
||||||
// leaking detail through Summary) can't be forwarded onward by the user or a
|
// minimal-body rule at the channel's own last mile.
|
||||||
// chat observer — locks the minimal-body rule at the channel's own last mile.
|
|
||||||
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||||
body := d.Summary
|
// never fall back to d.Body: telegram leaves the box, so an empty summary
|
||||||
if body == "" {
|
// gets a generic line instead of the full detail.
|
||||||
body = d.Body
|
body := delivery.AwayMessage(d)
|
||||||
}
|
|
||||||
if body == "" {
|
|
||||||
return fmt.Errorf("telegramsink: empty message for %s", d.Channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
payload := sendMessageReq{
|
payload := sendMessageReq{
|
||||||
ChatID: s.cfg.ChatID,
|
ChatID: s.cfg.ChatID,
|
||||||
|
|||||||
@@ -173,9 +173,9 @@ func TestSendBodyIsSummaryNotFullBody(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
func TestSendNeverSendsTheBodyWhenSummaryEmpty(t *testing.T) {
|
||||||
// terse full message beats none; the phraser should produce a summary for
|
// #368: this used to fall back to the full body. telegram leaves the box,
|
||||||
// away-bound severities, but don't silently drop.
|
// so an empty summary gets a fixed generic line plus the rule name.
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -188,12 +188,14 @@ func TestSendFallsBackToBodyWhenSummaryEmpty(t *testing.T) {
|
|||||||
_, _, body, _, _ := rs.snapshot()
|
_, _, body, _, _ := rs.snapshot()
|
||||||
var req sendMessageReq
|
var req sendMessageReq
|
||||||
_ = json.Unmarshal([]byte(body), &req)
|
_ = json.Unmarshal([]byte(body), &req)
|
||||||
if req.Text != s.Body {
|
want := delivery.GenericAwayMessage + ": service_down"
|
||||||
t.Fatalf("fallback text: want %q, got %q", s.Body, req.Text)
|
if req.Text != want {
|
||||||
|
t.Fatalf("text: want %q, got %q", want, req.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSendRejectsEmptyMessage(t *testing.T) {
|
func TestSendNeverSendsAnEmptyMessage(t *testing.T) {
|
||||||
|
// with nothing at all to say we still send the generic line.
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -201,9 +203,15 @@ func TestSendRejectsEmptyMessage(t *testing.T) {
|
|||||||
sink, _ := New(sinkCfg(srv.URL))
|
sink, _ := New(sinkCfg(srv.URL))
|
||||||
s := nudgeSendable(loop.Sev4, "")
|
s := nudgeSendable(loop.Sev4, "")
|
||||||
s.Body = ""
|
s.Body = ""
|
||||||
err := sink.Send(context.Background(), s)
|
s.RuleName = ""
|
||||||
if err == nil {
|
if err := sink.Send(context.Background(), s); err != nil {
|
||||||
t.Fatal("want error for empty message")
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
_, _, body, _, _ := rs.snapshot()
|
||||||
|
var req sendMessageReq
|
||||||
|
_ = json.Unmarshal([]byte(body), &req)
|
||||||
|
if req.Text != delivery.GenericAwayMessage {
|
||||||
|
t.Fatalf("text: want %q, got %q", delivery.GenericAwayMessage, req.Text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ const (
|
|||||||
SlotText Slot = "text" // Slots.Text
|
SlotText Slot = "text" // Slots.Text
|
||||||
)
|
)
|
||||||
|
|
||||||
// MaxAttempts is 1 because Maven is not a nag (DESIGN.md § Non-goals). She asks
|
// DefaultMaxAttempts — how many questions she may ask about one request.
|
||||||
// one clarifying question. If the answer still leaves the slot empty she drops
|
// Three, because after three tries the likely problem is that she misheard the
|
||||||
// the request instead of asking again.
|
// whole request, not one slot — so another question about that slot won't help.
|
||||||
const MaxAttempts = 1
|
// Configurable: voice.clarify_max_attempts.
|
||||||
|
const DefaultMaxAttempts = 3
|
||||||
|
|
||||||
// PendingQuestion is what Maven holds while she waits for an answer to an open
|
// PendingQuestion is what Maven holds while she waits for an answer to an open
|
||||||
// question. Unlike the yes/no confirms in cmd/mavend/voice.go, the answer here
|
// question. Unlike the yes/no confirms in cmd/mavend/voice.go, the answer here
|
||||||
@@ -32,7 +33,17 @@ type PendingQuestion struct {
|
|||||||
Utterance string // the user's original raw words
|
Utterance string // the user's original raw words
|
||||||
Asked time.Time
|
Asked time.Time
|
||||||
TTL time.Duration
|
TTL time.Duration
|
||||||
Attempts int // questions already asked; capped by MaxAttempts
|
Attempts int // questions already asked
|
||||||
|
// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
|
||||||
|
MaxAttempts int
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxAttempts is MaxAttempts with the default filled in.
|
||||||
|
func (q *PendingQuestion) maxAttempts() int {
|
||||||
|
if q.MaxAttempts <= 0 {
|
||||||
|
return DefaultMaxAttempts
|
||||||
|
}
|
||||||
|
return q.MaxAttempts
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *PendingQuestion) IsExpired(now time.Time) bool {
|
func (q *PendingQuestion) IsExpired(now time.Time) bool {
|
||||||
@@ -41,12 +52,9 @@ func (q *PendingQuestion) IsExpired(now time.Time) bool {
|
|||||||
|
|
||||||
// CanAsk reports whether Maven may ask another question about this request.
|
// CanAsk reports whether Maven may ask another question about this request.
|
||||||
func (q *PendingQuestion) CanAsk() bool {
|
func (q *PendingQuestion) CanAsk() bool {
|
||||||
return q.Attempts < MaxAttempts
|
return q.Attempts < q.maxAttempts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: the daemon will phrase the question text from Missing (one short ru
|
|
||||||
// question per Slot, feminine self-reference) and speak it here.
|
|
||||||
|
|
||||||
// ClarifyStore holds the parked questions. Same shape and locking as
|
// ClarifyStore holds the parked questions. Same shape and locking as
|
||||||
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
// SessionStore: keyed by dialogue id, expired entries dropped on read.
|
||||||
type ClarifyStore struct {
|
type ClarifyStore struct {
|
||||||
@@ -67,8 +75,7 @@ func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: the daemon will Put a question here when Decision.Clarify fires, in
|
// Put parks a question. Called on a clarify decision (cmd/mavend/clarify.go).
|
||||||
// place of the flat "не разобрала" reply (cmd/mavend/voice.go).
|
|
||||||
func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
|
func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
|
||||||
if q.TTL <= 0 {
|
if q.TTL <= 0 {
|
||||||
q.TTL = s.defaultTTL
|
q.TTL = s.defaultTTL
|
||||||
@@ -78,8 +85,7 @@ func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: the daemon will Get on the next turn, parse that turn into Slots, call
|
// Get returns the live parked question, or nil when there is none.
|
||||||
// Answer, and Delete — the open-question twin of resolveConfirm.
|
|
||||||
func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
|
func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
q, ok := s.questions[id]
|
q, ok := s.questions[id]
|
||||||
@@ -94,6 +100,21 @@ func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
|
|||||||
return q
|
return q
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TakeExpired reports whether a question was parked here but its TTL ran out,
|
||||||
|
// and drops it. Get drops such a question silently, which leaves the user
|
||||||
|
// thinking his request is still alive — the caller uses this to tell him it is
|
||||||
|
// gone before treating his words as a fresh utterance.
|
||||||
|
func (s *ClarifyStore) TakeExpired(id string, now time.Time) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
q, ok := s.questions[id]
|
||||||
|
if !ok || !q.IsExpired(now) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(s.questions, id)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ClarifyStore) Delete(id string) {
|
func (s *ClarifyStore) Delete(id string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.questions, id)
|
delete(s.questions, id)
|
||||||
@@ -101,44 +122,45 @@ func (s *ClarifyStore) Delete(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Answer merges the slots parsed from the user's answer into the parked ones.
|
// Answer merges the slots parsed from the user's answer into the parked ones.
|
||||||
// Only the slots listed in Missing are filled, and an already filled slot is
|
// Only the slots listed in Missing are touched. Within those, a value the answer
|
||||||
// never overwritten — the answer completes the original request, it does not
|
// carries WINS over what was parked: she asked about this slot, so «нет, в пять»
|
||||||
// restate it. Parsing the answer text into `answer` is the caller's job; this
|
// after «в три» must replace the time, not be thrown away.
|
||||||
// package must stay free of internal/router.
|
//
|
||||||
|
// This is the clarify answer only. A correction in a fresh turn ("вообще-то
|
||||||
|
// перенеси на пять") is a different code path (followUpMerge) — not here.
|
||||||
|
//
|
||||||
|
// Parsing the answer text into `answer` is the caller's job; this package must
|
||||||
|
// stay free of internal/router.
|
||||||
func (q *PendingQuestion) Answer(text string, answer Slots) Slots {
|
func (q *PendingQuestion) Answer(text string, answer Slots) Slots {
|
||||||
out := q.Slots
|
out := q.Slots
|
||||||
for _, slot := range q.Missing {
|
for _, slot := range q.Missing {
|
||||||
switch slot {
|
switch slot {
|
||||||
case SlotTime:
|
case SlotTime:
|
||||||
if !out.HasTime && answer.HasTime {
|
if answer.HasTime {
|
||||||
out.Time = answer.Time
|
out.Time = answer.Time
|
||||||
out.HasTime = true
|
out.HasTime = true
|
||||||
}
|
}
|
||||||
case SlotKey:
|
case SlotKey:
|
||||||
if !out.HasKey && answer.HasKey {
|
if answer.HasKey {
|
||||||
out.Key = answer.Key
|
out.Key = answer.Key
|
||||||
out.HasKey = true
|
out.HasKey = true
|
||||||
}
|
}
|
||||||
case SlotValue:
|
case SlotValue:
|
||||||
if out.Value == "" && answer.Value != "" {
|
if answer.Value != "" {
|
||||||
out.Value = answer.Value
|
out.Value = answer.Value
|
||||||
}
|
}
|
||||||
case SlotFn:
|
case SlotFn:
|
||||||
if !out.HasFn && answer.HasFn {
|
if answer.HasFn {
|
||||||
out.Fn = answer.Fn
|
out.Fn = answer.Fn
|
||||||
out.HasFn = true
|
out.HasFn = true
|
||||||
if len(out.Args) == 0 {
|
out.Args = append([]string(nil), answer.Args...)
|
||||||
out.Args = append([]string(nil), answer.Args...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
case SlotText:
|
case SlotText:
|
||||||
if out.Text == "" {
|
if answer.Text != "" {
|
||||||
if answer.Text != "" {
|
out.Text = answer.Text
|
||||||
out.Text = answer.Text
|
} else if out.Text == "" {
|
||||||
} else {
|
// No parse for a text slot — the raw answer IS the text.
|
||||||
// No parse for a text slot — the raw answer IS the text.
|
out.Text = text
|
||||||
out.Text = text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dialogue
|
package dialogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -59,6 +60,28 @@ func TestClarifyStoreGetPutDelete(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestClarifyStoreTakeExpired — TakeExpired reports (and drops) only a question
|
||||||
|
// whose TTL ran out.
|
||||||
|
func TestClarifyStoreTakeExpired(t *testing.T) {
|
||||||
|
s := NewClarifyStore(time.Minute)
|
||||||
|
if s.TakeExpired("voice", base) {
|
||||||
|
t.Fatal("nothing parked ⇒ nothing expired")
|
||||||
|
}
|
||||||
|
s.Put("voice", &PendingQuestion{Missing: []Slot{SlotTime}, Asked: base, TTL: time.Minute})
|
||||||
|
if s.TakeExpired("voice", base.Add(30*time.Second)) {
|
||||||
|
t.Fatal("a live question must not report as expired")
|
||||||
|
}
|
||||||
|
if s.Get("voice", base.Add(30*time.Second)) == nil {
|
||||||
|
t.Fatal("a live question must survive TakeExpired")
|
||||||
|
}
|
||||||
|
if !s.TakeExpired("voice", base.Add(2*time.Minute)) {
|
||||||
|
t.Fatal("a stale question must report as expired")
|
||||||
|
}
|
||||||
|
if s.TakeExpired("voice", base.Add(2*time.Minute)) {
|
||||||
|
t.Fatal("TakeExpired must drop the question, so the second call is false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewClarifyStoreDefaultTTL(t *testing.T) {
|
func TestNewClarifyStoreDefaultTTL(t *testing.T) {
|
||||||
s := NewClarifyStore(0)
|
s := NewClarifyStore(0)
|
||||||
q := &PendingQuestion{Asked: base}
|
q := &PendingQuestion{Asked: base}
|
||||||
@@ -89,12 +112,13 @@ func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
|
|||||||
want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true},
|
want: Slots{Text: "напомни позвонить", Time: answerTime, HasTime: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "does not overwrite a filled time",
|
// He restated it: «нет, в пять». The new value wins.
|
||||||
|
name: "a restated time overwrites the parked one",
|
||||||
parked: Slots{Time: other, HasTime: true},
|
parked: Slots{Time: other, HasTime: true},
|
||||||
missing: []Slot{SlotTime},
|
missing: []Slot{SlotTime},
|
||||||
text: "в три",
|
text: "нет, в три",
|
||||||
answer: Slots{Time: answerTime, HasTime: true},
|
answer: Slots{Time: answerTime, HasTime: true},
|
||||||
want: Slots{Time: other, HasTime: true},
|
want: Slots{Time: answerTime, HasTime: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "ignores slots that were not missing",
|
name: "ignores slots that were not missing",
|
||||||
@@ -121,12 +145,12 @@ func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
|
|||||||
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "keeps existing args when fn was already known",
|
name: "a restated fn replaces the fn and its args",
|
||||||
parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
parked: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||||
missing: []Slot{SlotFn},
|
missing: []Slot{SlotFn},
|
||||||
text: "останови postgres",
|
text: "останови postgres",
|
||||||
answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
|
answer: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
|
||||||
want: Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
want: Slots{Fn: "stop", Args: []string{"postgres"}, HasFn: true},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "raw answer becomes the text when nothing was parsed",
|
name: "raw answer becomes the text when nothing was parsed",
|
||||||
@@ -157,36 +181,34 @@ func TestAnswerFillsOnlyMissingSlots(t *testing.T) {
|
|||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base}
|
q := &PendingQuestion{Slots: tc.parked, Missing: tc.missing, Asked: base}
|
||||||
got := q.Answer(tc.text, tc.answer)
|
// Whole-struct compare: a new field in Slots is covered for free.
|
||||||
if got.Time != tc.want.Time || got.HasTime != tc.want.HasTime ||
|
if got := q.Answer(tc.text, tc.answer); !reflect.DeepEqual(got, tc.want) {
|
||||||
got.Key != tc.want.Key || got.HasKey != tc.want.HasKey ||
|
|
||||||
got.Value != tc.want.Value || got.Text != tc.want.Text ||
|
|
||||||
got.Fn != tc.want.Fn || got.HasFn != tc.want.HasFn {
|
|
||||||
t.Fatalf("Answer = %+v, want %+v", got, tc.want)
|
t.Fatalf("Answer = %+v, want %+v", got, tc.want)
|
||||||
}
|
}
|
||||||
if len(got.Args) != len(tc.want.Args) {
|
|
||||||
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
|
||||||
}
|
|
||||||
for i := range got.Args {
|
|
||||||
if got.Args[i] != tc.want.Args[i] {
|
|
||||||
t.Fatalf("Args = %v, want %v", got.Args, tc.want.Args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCanAskCapsAtOneQuestion(t *testing.T) {
|
func TestCanAskAllowsThreeQuestionsByDefault(t *testing.T) {
|
||||||
if MaxAttempts != 1 {
|
if DefaultMaxAttempts != 3 {
|
||||||
t.Fatalf("MaxAttempts = %d, want 1 (Maven asks once, she is not a nag)", MaxAttempts)
|
t.Fatalf("DefaultMaxAttempts = %d, want 3", DefaultMaxAttempts)
|
||||||
}
|
}
|
||||||
q := &PendingQuestion{Asked: base}
|
q := &PendingQuestion{Asked: base} // MaxAttempts unset ⇒ the default
|
||||||
if !q.CanAsk() {
|
for i := 0; i < 3; i++ {
|
||||||
t.Fatal("a fresh question should be askable")
|
if !q.CanAsk() {
|
||||||
|
t.Fatalf("question %d should be allowed", i+1)
|
||||||
|
}
|
||||||
|
q.Attempts++
|
||||||
}
|
}
|
||||||
q.Attempts = MaxAttempts
|
|
||||||
if q.CanAsk() {
|
if q.CanAsk() {
|
||||||
t.Fatal("the question should not be asked twice")
|
t.Fatal("a fourth question must not be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAskHonoursConfiguredMax(t *testing.T) {
|
||||||
|
q := &PendingQuestion{Asked: base, MaxAttempts: 1, Attempts: 1}
|
||||||
|
if q.CanAsk() {
|
||||||
|
t.Fatal("MaxAttempts 1 means one question only")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package dialogue
|
package dialogue
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Intent string
|
type Intent string
|
||||||
@@ -50,10 +54,22 @@ func (s *Session) IsExpired(now time.Time) bool {
|
|||||||
return now.After(s.Timestamp.Add(s.TTL))
|
return now.After(s.Timestamp.Add(s.TTL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionPersister — the bit of the store the session needs, as an interface
|
||||||
|
// so tests can swap it out. Data is an opaque blob: the store never looks
|
||||||
|
// inside, we encode the session as JSON here.
|
||||||
|
type SessionPersister interface {
|
||||||
|
SaveDialogueSession(ctx context.Context, id string, data []byte, ts time.Time, ttl time.Duration) error
|
||||||
|
DeleteDialogueSession(ctx context.Context, id string) error
|
||||||
|
LoadDialogueSessions(ctx context.Context, now time.Time) ([]store.DialogueSessionRow, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionStore keeps the live sessions in a map (the fast path) and mirrors
|
||||||
|
// every write to the persister, so a daemon restart can load them back.
|
||||||
type SessionStore struct {
|
type SessionStore struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
sessions map[string]*Session
|
sessions map[string]*Session
|
||||||
defaultTTL time.Duration
|
defaultTTL time.Duration
|
||||||
|
persist SessionPersister // may be nil: memory only (tests, no-store paths)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSessionStore(defaultTTL time.Duration) *SessionStore {
|
func NewSessionStore(defaultTTL time.Duration) *SessionStore {
|
||||||
@@ -66,6 +82,43 @@ func NewSessionStore(defaultTTL time.Duration) *SessionStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewPersistentSessionStore — same store, but writes also go to the DB.
|
||||||
|
// Call Load once after this to bring back sessions from a previous run.
|
||||||
|
func NewPersistentSessionStore(defaultTTL time.Duration, p SessionPersister) *SessionStore {
|
||||||
|
s := NewSessionStore(defaultTTL)
|
||||||
|
s.persist = p
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load — read the saved sessions back into memory. Anything past its TTL is
|
||||||
|
// dropped (and deleted from the DB by the store), never revived.
|
||||||
|
func (s *SessionStore) Load(ctx context.Context, now time.Time) error {
|
||||||
|
if s.persist == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := s.persist.LoadDialogueSessions(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
for _, r := range rows {
|
||||||
|
var sess Session
|
||||||
|
if err := json.Unmarshal(r.Data, &sess); err != nil {
|
||||||
|
// A blob we can't read is not worth failing a startup over.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sess.TTL <= 0 {
|
||||||
|
sess.TTL = r.TTL
|
||||||
|
}
|
||||||
|
if sess.IsExpired(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.sessions[r.ID] = &sess
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SessionStore) Get(id string, now time.Time) *Session {
|
func (s *SessionStore) Get(id string, now time.Time) *Session {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
sess, ok := s.sessions[id]
|
sess, ok := s.sessions[id]
|
||||||
@@ -87,12 +140,33 @@ func (s *SessionStore) Put(id string, sess *Session) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.sessions[id] = sess
|
s.sessions[id] = sess
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
s.save(id, sess)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SessionStore) Delete(id string) {
|
func (s *SessionStore) Delete(id string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.sessions, id)
|
delete(s.sessions, id)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
if s.persist != nil {
|
||||||
|
_ = s.persist.DeleteDialogueSession(context.Background(), id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// save — mirror one session to the DB. Best effort: memory already has it, so
|
||||||
|
// a write error costs us the restart safety net, not the current turn.
|
||||||
|
func (s *SessionStore) save(id string, sess *Session) {
|
||||||
|
if s.persist == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(sess)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ts := sess.Timestamp
|
||||||
|
if ts.IsZero() {
|
||||||
|
ts = time.Now()
|
||||||
|
}
|
||||||
|
_ = s.persist.SaveDialogueSession(context.Background(), id, data, ts, sess.TTL)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InheritSlots(prev, cur Slots) Slots {
|
func InheritSlots(prev, cur Slots) Slots {
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package dialogue
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openStore — a store on disk, so a second handle can reopen the same file.
|
||||||
|
func openStore(t *testing.T, path string) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := store.Open(context.Background(), path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open store: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = s.Close() })
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// A session written before a restart comes back and still merges a follow-up.
|
||||||
|
func TestSessionSurvivesRestart(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "maven_test.db")
|
||||||
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||||
|
|
||||||
|
first := openStore(t, path)
|
||||||
|
before := NewPersistentSessionStore(2*time.Minute, first)
|
||||||
|
before.Put("voice", &Session{
|
||||||
|
Intent: IntentReminder,
|
||||||
|
Slots: Slots{Text: "полить цветы", Time: now.Add(time.Hour), HasTime: true},
|
||||||
|
Timestamp: now,
|
||||||
|
TTL: 2 * time.Minute,
|
||||||
|
})
|
||||||
|
if err := first.Close(); err != nil {
|
||||||
|
t.Fatalf("close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fresh handle, fresh in-memory map — as after a daemon restart
|
||||||
|
after := openStore(t, path)
|
||||||
|
reloaded := NewPersistentSessionStore(2*time.Minute, after)
|
||||||
|
if err := reloaded.Load(ctx, now.Add(10*time.Second)); err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
sess := reloaded.Get("voice", now.Add(10*time.Second))
|
||||||
|
if sess == nil {
|
||||||
|
t.Fatal("session did not survive the restart")
|
||||||
|
}
|
||||||
|
if sess.Intent != IntentReminder {
|
||||||
|
t.Fatalf("intent = %q, want reminder", sess.Intent)
|
||||||
|
}
|
||||||
|
// the follow-up carries no text of its own; it must inherit the old one
|
||||||
|
merged := InheritSlots(sess.Slots, Slots{Time: now.Add(2 * time.Hour), HasTime: true})
|
||||||
|
if merged.Text != "полить цветы" {
|
||||||
|
t.Fatalf("merged text = %q, want the earlier turn's text", merged.Text)
|
||||||
|
}
|
||||||
|
if !merged.Time.Equal(now.Add(2 * time.Hour)) {
|
||||||
|
t.Fatalf("merged time = %v, want the follow-up's time", merged.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A session past its TTL is dead: a restart must not bring it back.
|
||||||
|
func TestExpiredSessionNotResurrected(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "maven_test.db")
|
||||||
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||||
|
|
||||||
|
first := openStore(t, path)
|
||||||
|
before := NewPersistentSessionStore(2*time.Minute, first)
|
||||||
|
before.Put("voice", &Session{
|
||||||
|
Intent: IntentReminder,
|
||||||
|
Slots: Slots{Text: "полить цветы"},
|
||||||
|
Timestamp: now,
|
||||||
|
TTL: time.Minute,
|
||||||
|
})
|
||||||
|
if err := first.Close(); err != nil {
|
||||||
|
t.Fatalf("close: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
after := openStore(t, path)
|
||||||
|
reloaded := NewPersistentSessionStore(2*time.Minute, after)
|
||||||
|
later := now.Add(5 * time.Minute) // well past the 1-min TTL
|
||||||
|
if err := reloaded.Load(ctx, later); err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if sess := reloaded.Get("voice", later); sess != nil {
|
||||||
|
t.Fatalf("expired session came back: %+v", sess)
|
||||||
|
}
|
||||||
|
// and it is gone from the DB too, not just from memory
|
||||||
|
rows, err := after.LoadDialogueSessions(ctx, later)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDialogueSessions: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("expired row still in the DB: %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the row as well, so an ended conversation stays ended.
|
||||||
|
func TestDeleteRemovesPersistedSession(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "maven_test.db")
|
||||||
|
now := time.Now().UTC().Truncate(time.Millisecond)
|
||||||
|
|
||||||
|
s := openStore(t, path)
|
||||||
|
ss := NewPersistentSessionStore(2*time.Minute, s)
|
||||||
|
ss.Put("voice", &Session{Intent: IntentChat, Timestamp: now, TTL: time.Minute})
|
||||||
|
ss.Delete("voice")
|
||||||
|
rows, err := s.LoadDialogueSessions(ctx, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadDialogueSessions: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 0 {
|
||||||
|
t.Fatalf("row survived Delete: %+v", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/persona"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extraction — turning one mail into task CANDIDATES, and nothing else.
|
||||||
|
//
|
||||||
|
// The output of this file can only ever become rows in `tasks` with status
|
||||||
|
// "candidate" (store.TaskCandidate), written through the one intake seam
|
||||||
|
// (ipc.CaptureTaskReq, Vikunja #130). That bound is the whole design:
|
||||||
|
//
|
||||||
|
// - No reminder. A reminder FIRES; it speaks to him unprompted. A 1.7B that
|
||||||
|
// misreads "встреча была в четверг" as a future appointment would then wake
|
||||||
|
// him up about it. A candidate that is wrong is a line on a review page he
|
||||||
|
// dismisses in one click, which is the correct cost of a model being wrong
|
||||||
|
// about someone's mail.
|
||||||
|
// - No fact. A fact is a claim Maven will later recite as true. Nothing read
|
||||||
|
// out of a marketing mail deserves that standing.
|
||||||
|
// - No calendar event, no note, no action. Extraction writes candidates or
|
||||||
|
// writes nothing.
|
||||||
|
//
|
||||||
|
// The due date the model may return is stored on the candidate (tasks.due_ts),
|
||||||
|
// which no scheduler reads — it is there so the review page can sort by it.
|
||||||
|
//
|
||||||
|
// Privacy: the mail text goes to the resident model on this box and nowhere
|
||||||
|
// else. It is never search input (CLAUDE.md: "his notes and facts are never
|
||||||
|
// search input" — mail is the same class), and Evidence keeps only the subject
|
||||||
|
// line, so the review page shows him where a candidate came from without the
|
||||||
|
// store growing a copy of his mailbox.
|
||||||
|
|
||||||
|
// MaxCandidates — at most this many candidates per message, enforced by the
|
||||||
|
// grammar. A mail with four tasks in it is a mail he has to read himself; a
|
||||||
|
// model allowed ten will produce ten.
|
||||||
|
const MaxCandidates = 3
|
||||||
|
|
||||||
|
// SourcePrefix — provenance for everything this package captures. The mailbox
|
||||||
|
// name is appended: "email:INBOX". Same vocabulary as tap:voice / poll:netdata.
|
||||||
|
const SourcePrefix = "email:"
|
||||||
|
|
||||||
|
// Candidate — one piece of work the model thinks the mail is asking for.
|
||||||
|
type Candidate struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
// Due — "YYYY-MM-DD" or empty. A date the model read out of the text, not a
|
||||||
|
// date it computed: relative wording ("до пятницы") is left in Text, because
|
||||||
|
// a small model resolving "пятница" against today's date gets it wrong often
|
||||||
|
// enough that a stored wrong date is worse than no date.
|
||||||
|
Due string `json:"due"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Completer — the llama-server seam, same shape memeval and the router use, so
|
||||||
|
// the one resident model serves this caller too.
|
||||||
|
type Completer interface {
|
||||||
|
Complete(ctx context.Context, r llm.Req) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extractor reads a message and returns candidates. It holds no store and no
|
||||||
|
// writer on purpose: this type cannot persist anything, so "extraction never
|
||||||
|
// acts" is a property of the code, not of a review.
|
||||||
|
type Extractor struct {
|
||||||
|
llm Completer
|
||||||
|
// MaxCandidates — 0 ⇒ MaxCandidates.
|
||||||
|
max int
|
||||||
|
// ContextBlock — the shared persona block, optional. Extraction output is
|
||||||
|
// not spoken, so the persona matters less here than in the phraser; it is
|
||||||
|
// wired anyway so a candidate reads in her voice on the review page.
|
||||||
|
contextBlock func() string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExtractor(c Completer, max int, contextBlock func() string) *Extractor {
|
||||||
|
if max <= 0 || max > MaxCandidates {
|
||||||
|
max = MaxCandidates
|
||||||
|
}
|
||||||
|
return &Extractor{llm: c, max: max, contextBlock: contextBlock}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractGrammar — GBNF pinning the answer to a bounded array of fixed-shape
|
||||||
|
// candidates. Same reasoning as memeval's evalGrammar and the router's
|
||||||
|
// routeGrammar: the shape and the length bound are what keep a small model from
|
||||||
|
// drifting into prose or spending the token budget repeating one field.
|
||||||
|
//
|
||||||
|
// The empty array is reachable, deliberately: most mail contains no task, and a
|
||||||
|
// model with no way to say "nothing" invents something.
|
||||||
|
const extractGrammar = `
|
||||||
|
root ::= "[" ws (item ("," ws item){0,2})? ws "]"
|
||||||
|
item ::= "{" ws "\"text\"" ws ":" ws text "," ws "\"due\"" ws ":" ws due ws "}"
|
||||||
|
text ::= "\"" ([^"\\] | "\\" .){1,120} "\""
|
||||||
|
due ::= "\"\"" | "\"" [0-9]{4} "-" [0-9]{2} "-" [0-9]{2} "\""
|
||||||
|
ws ::= [ \t\n]*
|
||||||
|
`
|
||||||
|
|
||||||
|
// extractSystem — the extraction prompt.
|
||||||
|
//
|
||||||
|
// Written around the two failure modes a small model has on this task: it
|
||||||
|
// summarises when asked to extract (turning a mail into "письмо от Антона"),
|
||||||
|
// and it invents an obligation from any polite closing sentence. Hence the
|
||||||
|
// insistence on a verb phrase, and the explicit permission to return [].
|
||||||
|
const extractSystem = `Ты читаешь одно письмо из его почты и достаёшь из него дела, которые письмо от него требует.
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
- Отвечай ТОЛЬКО массивом JSON. Каждый элемент: {"text": "...", "due": "ГГГГ-ММ-ДД" или ""}.
|
||||||
|
- text — короткая формулировка дела по-русски, с глаголом: "оплатить счёт за интернет", "отправить акт". Не пересказывай письмо и не описывай его.
|
||||||
|
- Дело — это то, что должен сделать ОН. Рассылка, реклама, уведомление, отчёт, письмо «просто к сведению» — дел не содержат.
|
||||||
|
- Если письмо ничего от него не требует, верни пустой массив []. Это нормальный ответ, так бывает чаще всего.
|
||||||
|
- Ничего не придумывай. Если срока в письме нет — "".
|
||||||
|
- due заполняй только когда в письме стоит конкретная дата. Слова вроде «до пятницы» оставь в text, дату не вычисляй.
|
||||||
|
- Максимум три дела. Лучше одно точное, чем три общих.`
|
||||||
|
|
||||||
|
// Extract returns the candidates in one message.
|
||||||
|
//
|
||||||
|
// Junk is refused without an LLM call — cheapest possible defence, and the
|
||||||
|
// reason the header filter exists. An empty message (no subject, no body) is
|
||||||
|
// likewise not worth a round trip.
|
||||||
|
//
|
||||||
|
// A parse failure is an error the caller logs and moves past. It is never
|
||||||
|
// silently turned into zero candidates, because "the model went off the rails"
|
||||||
|
// and "the mail contains no task" want different reactions from a human reading
|
||||||
|
// the log.
|
||||||
|
func (e *Extractor) Extract(ctx context.Context, msg Message) ([]Candidate, error) {
|
||||||
|
if msg.Junk {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
user := renderForModel(msg)
|
||||||
|
if user == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
raw, err := e.llm.Complete(ctx, llm.Req{
|
||||||
|
System: persona.Prepend(e.contextBlock, extractSystem),
|
||||||
|
User: user,
|
||||||
|
Grammar: extractGrammar,
|
||||||
|
MaxTokens: 512,
|
||||||
|
RepeatPenalty: 1.1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("email: extract: %w", err)
|
||||||
|
}
|
||||||
|
items, err := parseCandidates(raw)
|
||||||
|
if err != nil {
|
||||||
|
// The raw reply is NOT in the error: it is a transformation of his mail,
|
||||||
|
// and this error reaches the daemon log.
|
||||||
|
return nil, fmt.Errorf("email: extract: unparsable reply (%d bytes)", len(raw))
|
||||||
|
}
|
||||||
|
out := make([]Candidate, 0, len(items))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, it := range items {
|
||||||
|
it.Text = strings.TrimSpace(it.Text)
|
||||||
|
if it.Text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.Join(strings.Fields(it.Text), " "))
|
||||||
|
if seen[key] {
|
||||||
|
continue // the model repeating itself is not two tasks
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
if _, ok := ParseDue(it.Due); !ok {
|
||||||
|
it.Due = "" // a date the grammar allowed but the calendar does not
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
if len(out) >= e.max {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderForModel is the user turn: subject, sender and body, labelled. Only
|
||||||
|
// these three fields — no headers, no recipient list, no message-id, nothing
|
||||||
|
// that would let the model start reasoning about routing metadata.
|
||||||
|
func renderForModel(msg Message) string {
|
||||||
|
var b strings.Builder
|
||||||
|
if msg.From != "" {
|
||||||
|
fmt.Fprintf(&b, "От: %s\n", msg.From)
|
||||||
|
}
|
||||||
|
if msg.Subject != "" {
|
||||||
|
fmt.Fprintf(&b, "Тема: %s\n", msg.Subject)
|
||||||
|
}
|
||||||
|
if msg.Body != "" {
|
||||||
|
fmt.Fprintf(&b, "\n%s\n", msg.Body)
|
||||||
|
}
|
||||||
|
if msg.Subject == "" && msg.Body == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCandidates decodes the grammar-constrained reply, tolerating the
|
||||||
|
// wrappers a Thinking model sometimes leaves around it (a fenced block, or
|
||||||
|
// leading reasoning before the array).
|
||||||
|
func parseCandidates(raw string) ([]Candidate, error) {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if i := strings.Index(s, "["); i > 0 {
|
||||||
|
s = s[i:]
|
||||||
|
}
|
||||||
|
if j := strings.LastIndex(s, "]"); j >= 0 {
|
||||||
|
s = s[:j+1]
|
||||||
|
}
|
||||||
|
var out []Candidate
|
||||||
|
if err := json.Unmarshal([]byte(s), &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseDue turns the model's "YYYY-MM-DD" into a time in UTC. Exported because
|
||||||
|
// the daemon-side intake stores it on the candidate.
|
||||||
|
//
|
||||||
|
// The zero-value/empty case returns ok=false rather than an error: no date is
|
||||||
|
// the common answer, not a failure.
|
||||||
|
func ParseDue(s string) (time.Time, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
t, err := time.Parse("2006-01-02", s)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return t, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeLLM returns a canned reply and records the request, so a test can assert
|
||||||
|
// on the grammar and on what of the mail was sent.
|
||||||
|
type fakeLLM struct {
|
||||||
|
reply string
|
||||||
|
err error
|
||||||
|
got llm.Req
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||||
|
f.calls++
|
||||||
|
f.got = r
|
||||||
|
return f.reply, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func msgFor(subject, body string) Message {
|
||||||
|
return Message{UID: 1, From: "anton@example.org", Subject: subject, Body: body}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractCandidates(t *testing.T) {
|
||||||
|
f := &fakeLLM{reply: `[{"text":"отправить акт","due":""},{"text":"оплатить счёт","due":"2026-08-05"}]`}
|
||||||
|
e := NewExtractor(f, 0, nil)
|
||||||
|
got, err := e.Extract(context.Background(), msgFor("Акт и счёт", "Надо отправить акт и оплатить счёт до 5 августа."))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extract: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Text != "отправить акт" || got[1].Due != "2026-08-05" {
|
||||||
|
t.Errorf("candidates = %+v", got)
|
||||||
|
}
|
||||||
|
if f.got.Grammar == "" {
|
||||||
|
t.Error("extraction must be grammar-constrained")
|
||||||
|
}
|
||||||
|
// The subject and body go to the model; nothing else about the message does.
|
||||||
|
if !strings.Contains(f.got.User, "Акт и счёт") || !strings.Contains(f.got.User, "оплатить счёт") {
|
||||||
|
t.Errorf("user turn = %q", f.got.User)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEmptyArrayIsNotAnError(t *testing.T) {
|
||||||
|
f := &fakeLLM{reply: "[]"}
|
||||||
|
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("FYI", "Просто к сведению."))
|
||||||
|
if err != nil || len(got) != 0 {
|
||||||
|
t.Fatalf("got (%v, %v), want (empty, nil) — no task is the normal answer", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Junk must never reach the model: the header filter exists so the resident
|
||||||
|
// model is not spent on newsletters.
|
||||||
|
func TestExtractSkipsJunkWithoutCallingModel(t *testing.T) {
|
||||||
|
f := &fakeLLM{reply: `[{"text":"купить всё со скидкой","due":""}]`}
|
||||||
|
msg := msgFor("Скидки", "Sale!")
|
||||||
|
msg.Junk = true
|
||||||
|
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msg)
|
||||||
|
if err != nil || got != nil {
|
||||||
|
t.Fatalf("got (%v, %v), want (nil, nil)", got, err)
|
||||||
|
}
|
||||||
|
if f.calls != 0 {
|
||||||
|
t.Errorf("model called %d times for junk, want 0", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractEmptyMessageIsNotSent(t *testing.T) {
|
||||||
|
f := &fakeLLM{reply: "[]"}
|
||||||
|
if _, err := NewExtractor(f, 0, nil).Extract(context.Background(), Message{UID: 3}); err != nil {
|
||||||
|
t.Fatalf("extract: %v", err)
|
||||||
|
}
|
||||||
|
if f.calls != 0 {
|
||||||
|
t.Errorf("model called %d times for an empty message, want 0", f.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractCaps(t *testing.T) {
|
||||||
|
f := &fakeLLM{reply: `[{"text":"a","due":""},{"text":"b","due":""},{"text":"c","due":""}]`}
|
||||||
|
got, err := NewExtractor(f, 2, nil).Extract(context.Background(), msgFor("s", "b"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extract: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Errorf("got %d, want the configured cap of 2", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDropsRepeatsAndBadDates(t *testing.T) {
|
||||||
|
f := &fakeLLM{reply: `[{"text":"Отправить акт","due":"2026-02-31"},{"text":"отправить акт","due":""},{"text":" ","due":""}]`}
|
||||||
|
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("s", "b"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extract: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d candidates, want 1 (repeat and blank dropped): %+v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Due != "" {
|
||||||
|
t.Errorf("due = %q, want empty — 2026-02-31 is not a date", got[0].Due)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Thinking model sometimes wraps the array; and when it emits something
|
||||||
|
// unparsable the caller must hear about it rather than see "no tasks".
|
||||||
|
func TestParseCandidatesTolerance(t *testing.T) {
|
||||||
|
got, err := parseCandidates("думаю... [{\"text\":\"x\",\"due\":\"\"}] всё")
|
||||||
|
if err != nil || len(got) != 1 || got[0].Text != "x" {
|
||||||
|
t.Fatalf("got (%+v, %v)", got, err)
|
||||||
|
}
|
||||||
|
if _, err := parseCandidates("нет никакого JSON"); err == nil {
|
||||||
|
t.Error("unparsable output must be an error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractParseErrorHidesMailText(t *testing.T) {
|
||||||
|
f := &fakeLLM{reply: "он просил отправить акт, вот такой ответ"}
|
||||||
|
_, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("Акт", "секретный текст"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("want an error")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "акт") || strings.Contains(err.Error(), "секретный") {
|
||||||
|
t.Errorf("error text leaks mail content: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseDue(t *testing.T) {
|
||||||
|
if _, ok := ParseDue(""); ok {
|
||||||
|
t.Error("empty due must be (zero, false)")
|
||||||
|
}
|
||||||
|
if got, ok := ParseDue("2026-08-05"); !ok || got.Year() != 2026 || got.Month() != 8 || got.Day() != 5 {
|
||||||
|
t.Errorf("ParseDue = (%v, %v)", got, ok)
|
||||||
|
}
|
||||||
|
if _, ok := ParseDue("05.08.2026"); ok {
|
||||||
|
t.Error("a non-ISO date must not parse")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FetchSince is the whole read path in one call: connect, log in, examine the
|
||||||
|
// mailbox read-only, list what arrived since a date, fetch and parse the ones
|
||||||
|
// the caller has not seen, log out.
|
||||||
|
//
|
||||||
|
// It is a function rather than a long-lived object because a mail poller should
|
||||||
|
// not hold an authenticated session (and therefore his credential in a live TLS
|
||||||
|
// state) between polls. Connect, read, drop.
|
||||||
|
//
|
||||||
|
// skip decides which UIDs are already known — the poller's seen-set. max bounds
|
||||||
|
// one poll: a mailbox that received 400 messages overnight must not turn into
|
||||||
|
// 400 LLM calls, and the newest max are the ones a task could still be hiding
|
||||||
|
// in. Junk messages are returned too, flagged, so the caller can mark them seen
|
||||||
|
// without a second protocol round.
|
||||||
|
type FetchSince struct {
|
||||||
|
Addr string // host or host:993
|
||||||
|
User string
|
||||||
|
Mailbox string // e.g. "INBOX"
|
||||||
|
Timeout time.Duration
|
||||||
|
Since time.Time
|
||||||
|
Max int
|
||||||
|
Skip func(uid uint32) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run performs one read. password is passed here, not stored in the struct, so
|
||||||
|
// the configuration of a mailbox and the secret for it are never the same value
|
||||||
|
// sitting in the same place.
|
||||||
|
func (f FetchSince) Run(password string) ([]Message, error) {
|
||||||
|
return f.RunWith(password, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWith is Run with an explicit connection function, which is how the reader
|
||||||
|
// daemon and the tests substitute an in-process server. nil ⇒ Dial, i.e.
|
||||||
|
// implicit TLS with certificate verification; there is no configuration path
|
||||||
|
// that reaches this, so no deployment can end up talking cleartext IMAP.
|
||||||
|
func (f FetchSince) RunWith(password string, dial func(addr string, timeout time.Duration) (*Conn, error)) ([]Message, error) {
|
||||||
|
if f.Addr == "" || f.User == "" || f.Mailbox == "" {
|
||||||
|
return nil, fmt.Errorf("email: mailbox not configured (addr/user/mailbox)")
|
||||||
|
}
|
||||||
|
if dial == nil {
|
||||||
|
dial = Dial
|
||||||
|
}
|
||||||
|
c, err := dial(f.Addr, f.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
if err := c.Login(f.User, password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer c.Logout()
|
||||||
|
if err := c.Select(f.Mailbox); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uids, err := c.SearchSince(f.Since)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest UIDs first — IMAP hands them back ascending, and when Max clips the
|
||||||
|
// list the recent mail is what matters.
|
||||||
|
wanted := make([]uint32, 0, len(uids))
|
||||||
|
for i := len(uids) - 1; i >= 0; i-- {
|
||||||
|
if f.Skip != nil && f.Skip(uids[i]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wanted = append(wanted, uids[i])
|
||||||
|
if f.Max > 0 && len(wanted) >= f.Max {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]Message, 0, len(wanted))
|
||||||
|
for _, uid := range wanted {
|
||||||
|
raw, err := c.Fetch(uid)
|
||||||
|
if err != nil {
|
||||||
|
// One unreadable message does not abandon the poll; the rest of the
|
||||||
|
// mailbox is still worth reading. The error names the UID, not the
|
||||||
|
// message.
|
||||||
|
return out, fmt.Errorf("email: fetch uid %d: %w", uid, err)
|
||||||
|
}
|
||||||
|
if len(raw) == 0 {
|
||||||
|
continue // vanished between SEARCH and FETCH
|
||||||
|
}
|
||||||
|
msg, err := ParseMessage(uid, raw)
|
||||||
|
if err != nil {
|
||||||
|
continue // unparsable headers — nothing to review, skip silently
|
||||||
|
}
|
||||||
|
out = append(out, msg)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchSinceRun(t *testing.T) {
|
||||||
|
mk := func(subject string) string {
|
||||||
|
return "Subject: " + subject + "\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nbody\r\n"
|
||||||
|
}
|
||||||
|
f := &fakeIMAP{
|
||||||
|
uids: []uint32{1, 2, 3},
|
||||||
|
msgs: map[uint32]string{1: mk("one"), 2: mk("two"), 3: mk("three")},
|
||||||
|
}
|
||||||
|
fs := FetchSince{
|
||||||
|
Addr: "mail.example:993", User: "kami", Mailbox: "INBOX",
|
||||||
|
Timeout: 5 * time.Second,
|
||||||
|
Since: time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC),
|
||||||
|
Max: 2,
|
||||||
|
Skip: func(uid uint32) bool { return uid == 3 },
|
||||||
|
}
|
||||||
|
msgs, err := fs.RunWith("secret", func(addr string, timeout time.Duration) (*Conn, error) {
|
||||||
|
cli, srv := net.Pipe()
|
||||||
|
go f.serve(t, srv)
|
||||||
|
return NewConn(cli, timeout)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run: %v", err)
|
||||||
|
}
|
||||||
|
// Newest first, the already-seen UID skipped, Max respected.
|
||||||
|
if len(msgs) != 2 {
|
||||||
|
t.Fatalf("got %d messages, want 2: %+v", len(msgs), msgs)
|
||||||
|
}
|
||||||
|
if msgs[0].Subject != "two" || msgs[1].Subject != "one" {
|
||||||
|
t.Errorf("subjects = %q,%q, want two,one (newest first)", msgs[0].Subject, msgs[1].Subject)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(f.cmds, " "), "UID FETCH 3") {
|
||||||
|
t.Error("a skipped UID must not be fetched again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchSinceRequiresConfig(t *testing.T) {
|
||||||
|
if _, err := (FetchSince{}).Run("secret"); err == nil {
|
||||||
|
t.Fatal("an unconfigured mailbox must not be read")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user