Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ee3e6a9eaf | |||
| 8acb8a97c6 |
@@ -6,6 +6,7 @@
|
||||
/mavweb
|
||||
/mavpoll
|
||||
/mavcaldav
|
||||
/mavwaked
|
||||
|
||||
# Certs (private keys, don't commit)
|
||||
certs/
|
||||
|
||||
@@ -7,9 +7,20 @@ talking over unix sockets; one resident small model for routing + phrasing; whis
|
||||
Deploy target is a Ryzen laptop (homesrv) with Vulkan offload to the Vega iGPU (`n_gpu_layers: 99`,
|
||||
compose passes `/dev/dri` + the render gid) — the resident model stays ≤1.7B either way.
|
||||
|
||||
**Resident model:** currently **Qwen3.5-0.8B** (`Q4_K_M`), the smallest checkpoint in the gguf
|
||||
library, picked for CPU/iGPU latency. The **target** is the locally CPT'd **Qwen3-1.7B**; that
|
||||
training is still in flight (Vikunja #122), so no such gguf exists yet. Model files live in
|
||||
**Resident model:** currently **Qwen3-1.7B** (`UD-Q4_K_XL`), stock — not yet the CPT'd one.
|
||||
It replaced Qwen3.5-0.8B on 2026-07-31 because it measured better on both fixtures we have:
|
||||
67.5% vs 59.7% intent-only on the 77-case RU routing fixture, and 20/27 vs 11-17/27 on the
|
||||
talk fixture. See `MODEL-BAKEOFF-31-07-2026.md`. It is a Thinking variant, so `n_ctx` is 4096
|
||||
— reasoning tokens need the room, and 4096 is what the scores above were measured at.
|
||||
|
||||
The **target** is still the locally CPT'd **Qwen3-1.7B** (Vikunja #122, training in flight).
|
||||
Stock already speaks good Russian; what it gets wrong is the persona — it writes `я рад`,
|
||||
masculine, where Maven needs `рада`. That is what the CPT is for.
|
||||
|
||||
**Do not bother with sub-500M models.** LFM2.5-230M and 350M were measured on 2026-07-31 and
|
||||
both are unusable in Russian: the 350M routes at 5.2% (worse than guessing) and answers
|
||||
"столица Франции?" with the invented non-word "Сторзит"; the 230M replies to Russian in
|
||||
Spanish. Their strong published IFEval/BFCL numbers are English-only. Model files live in
|
||||
`/mnt/hdd1/llms`, bind-mounted to `/opt/maven/models/llm` — which **shadows** the repo's
|
||||
`models/llm/`, so the LFM2.5 gguf sitting there is not loaded by anything. Swapping the resident
|
||||
model is a one-line change to `phraser.model_path` in `deploy/mavend.json`.
|
||||
@@ -58,19 +69,41 @@ protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from g
|
||||
|
||||
## Routing — read this before touching the router
|
||||
|
||||
`internal/router/` has TWO layered engines and the committed default is an **interim
|
||||
stopgap, not the intended design** (see memory `routing-architecture-target`):
|
||||
`internal/router/` has TWO layered engines. **The LLM router is now the default and it is
|
||||
on in deploy** — this section used to say it was wired `nil`, which stopped being true on
|
||||
2026-07-31.
|
||||
|
||||
- **Target (REARCH.md):** LLM-as-router. One resident Qwen3-1.7B (`llmrouter.go`) emits
|
||||
GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is demoted
|
||||
from a routing gate to a RAG hint.
|
||||
- **Current stopgap:** `llmrouter` is wired `nil` (around `voice.go`), so the
|
||||
`classifier.go` + `embedder.go` nearest-neighbour cascade actually runs. It routes by
|
||||
similarity to frozen seed phrases — the known cause of weak RU query handling.
|
||||
- **LLM router (the intended design, REARCH.md):** the resident Qwen3-1.7B (`llmrouter.go`)
|
||||
emits GBNF-constrained structured JSON, and the SAME model phrases replies. Embedder is
|
||||
demoted from a routing gate to a RAG hint. Wired at `voice.go:214` via
|
||||
`pickLLMRouter(cfg.Voice.UseLLMRouter(), llmClient)`; the flag is `voice.llm_router`
|
||||
(`config.go`), `DefaultLLMRouter` is **on**, and `deploy/mavend.json` sets it `true`.
|
||||
- **Classifier cascade (the failure floor, not dead code):** `classifier.go` +
|
||||
`embedder.go` nearest-neighbour over frozen seed phrases. It runs when the LLM router is
|
||||
off, when there is no llama-server to talk to (`pickLLMRouter` logs that and degrades),
|
||||
and on any per-turn LLM error. Do not delete it — routing by seed similarity is the known
|
||||
cause of weak RU query handling, but a turn must never break on the model.
|
||||
|
||||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||||
|
||||
Measured on the 77-case RU fixture (`MODEL-BAKEOFF-31-07-2026.md`): the classifier scores
|
||||
36.8% full accuracy at p50 31ms; Qwen3-1.7B scores 67.5% intent-only / 72.7% through the
|
||||
cascade at p50 ≈2.7s. Accuracy roughly doubled, latency is ~90× worse, and that trade was
|
||||
accepted deliberately. `Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||||
no allowlisted fn) feeding the same stage-3 gate the classifier path already had — see
|
||||
`gateLLMDecision` in `router.go`. Note the second half of that bug: the LLM branch never
|
||||
consulted `r.threshold` at all, so a correct low confidence would have been discarded anyway.
|
||||
|
||||
Re-measured on the fixture after the fix: **missed clarify 6/6 → 1**, at the cost of 3 false
|
||||
clarifies and 2.6pt of full accuracy (72.7% → 70.1%, intent-only 67.5% → 74.0%). Two of the
|
||||
three false clarifies are acts the model mis-routed and the gate caught — asking beats wrongly
|
||||
executing, so the fixture and the daemon disagree about what is correct there. The third,
|
||||
`"поужинал"`, is a real defect: **the single-token rule is an English intuition and does not
|
||||
transfer to Russian**, where one word is routinely a whole sentence. Narrow or drop it.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
@@ -82,8 +115,26 @@ workspace enforces that the Go and relabelling prompts remain identical.
|
||||
|
||||
## Non-goals (hard constraints)
|
||||
|
||||
Never phones home. Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
self-reference must use feminine forms (the user is male; see memory `maven-persona-gender`).
|
||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
||||
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
||||
(she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name
|
||||
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||||
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
||||
|
||||
**"Never phones home" is DEPRECATED** (owner's call, 2026-07-31). It used to be a hard
|
||||
constraint and it is not one any more: a 0.8B — and a 1.7B — does not know enough to answer
|
||||
world questions, so she needs to read external sources. What replaces it:
|
||||
|
||||
- **No telemetry, no cloud model, no third-party account.** That part never changes. Nothing
|
||||
about Maven is reported to anyone, and inference stays on the box.
|
||||
- **Local sources first.** Kiwix ZIMs on homesrv (Wikipedia, ifixit) before anything on the
|
||||
network. Reading beats recalling for a small model, and a local read costs nothing.
|
||||
- **External search is allowed and off unless configured**, like the weather and telegram
|
||||
capabilities.
|
||||
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
|
||||
his stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||||
out, never the persona block, history, or matched notes.
|
||||
|
||||
## Web UI conventions
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
|
||||
**Maven** — self-hosted personal assistant. Manages your day, acts on your
|
||||
homelab. One daemon on homesrv (always-on, not the workstation), multiple
|
||||
client surfaces. All local, never phones home.
|
||||
client surfaces. Inference and data stay on the box; she may READ external
|
||||
sources (see Non-goals — "never phones home" is deprecated).
|
||||
|
||||
Primary name is "Maven", with feminine-gendered Russian self-reference
|
||||
("она", "меня", "помогла"). Clients may choose their own UI label. Consistent
|
||||
@@ -35,8 +36,13 @@ Inside boundary — the ones that actually constrain the build:
|
||||
she records. A confident wrong fact is worse than a known gap.
|
||||
- **Not a nag** — she'd rather miss a nudge than be mutable. Shuts up when
|
||||
uncertain. Load-bearing.
|
||||
- **Not a stranger** — runs on your stuff, your model, your data. Never
|
||||
phones home.
|
||||
- **Not a stranger** — runs on your stuff, your model, your data. No
|
||||
telemetry, no cloud model, no third-party account. She may READ external
|
||||
sources to answer world questions (Kiwix first, then optional search); she
|
||||
never reports anything about you to anyone, and your notes and facts are
|
||||
never used as search input. **"Never phones home" as an absolute is
|
||||
deprecated** — owner's call, 2026-07-31: a small model does not know enough
|
||||
to be useful without reading.
|
||||
- **Not a relationship** — mom-tone is a function that makes nudges land, not
|
||||
emotional company. Names the drift a warm small model falls into.
|
||||
|
||||
@@ -458,7 +464,7 @@ decides *insistence*. Both are needed.
|
||||
|
||||
sev ≤ 2 drops on away, sev ≥ 3 holds: a missed water nudge is noise, a missed
|
||||
backup failure isn't. Away-channels (ntfy/telegram) leave the box — the one
|
||||
path that crosses "never phones home," through your own relay. **Minimal
|
||||
path that leaves the box for a person to see, through your own relay. **Minimal
|
||||
body** — "disk low on homesrv," not detail; don't make notifications a
|
||||
shoulder-surf exfil surface.
|
||||
|
||||
|
||||
+126
-4
@@ -1,15 +1,28 @@
|
||||
# Resident model bake-off — 31-07-2026
|
||||
|
||||
**Recommendation: keep Qwen3.5-0.8B.** LFM2.5-1.2B is worse at routing (52.6% vs 60.5%
|
||||
intent accuracy), and the loss is almost entirely Russian (18/61 vs 22/61 RU, while EN is a
|
||||
wash). It is also 2.4× slower. The Thinking variant is far worse again.
|
||||
**Outcome: the resident model is stock Qwen3-1.7B** (`UD-Q4_K_XL`). Two sweeps ran this
|
||||
evening and the second one changed the answer — read to the end before acting on any table
|
||||
here. [Second sweep](#second-sweep-same-evening--five-models-and-a-resident-model-change)
|
||||
is the one that holds.
|
||||
|
||||
## First sweep — LFM2.5-1.2B vs Qwen3.5-0.8B
|
||||
|
||||
**Verdict, scoped to this pair: keep Qwen3.5-0.8B over LFM2.5-1.2B.** LFM2.5-1.2B is worse
|
||||
at routing (52.6% vs 60.5% intent accuracy), and the loss is almost entirely Russian
|
||||
(18/61 vs 22/61 RU, while EN is a wash). It is also 2.4× slower. The Thinking variant is
|
||||
far worse again. This verdict still stands as written — it rejects LFM2.5-1.2B. It is
|
||||
**not** a recommendation to keep 0.8B as the resident model; the second sweep replaced it
|
||||
with Qwen3-1.7B.
|
||||
|
||||
Settles Vikunja **#278 / #250**.
|
||||
|
||||
- Same fixture and scorer as `ROUTING-EVAL-31-07-2026.md`: `internal/router/eval/`
|
||||
(`ru_routing_v1.json`, 76 held-out cases).
|
||||
- Reproduce: `MAVEN_LLM_URL=http://127.0.0.1:<port> make eval-router`
|
||||
(`TestLLMRouterBaseline`). Note: there is no `make eval-models` target.
|
||||
(`TestLLMRouterBaseline`). (This line used to say there is no `make eval-models` target.
|
||||
There is one now — start a server with the gguf you want, then
|
||||
`make eval-models MAVEN_LLM_URL=http://127.0.0.1:<port>`. It runs only the LLM test, since
|
||||
the classifier baselines do not depend on the model.)
|
||||
- All three models served by the same `llama-server` flags — `-c 2048 -ngl 99 -t 6`, only
|
||||
`-m` and `--port` differ. One server at a time on an otherwise idle box, so latencies are
|
||||
real and not contention.
|
||||
@@ -99,3 +112,112 @@ thinking trace costs time without buying accuracy on a short enum classification
|
||||
Routing only. LFM2.5 might still phrase better, and phrasing is the resident model's other
|
||||
job — that needs its own fixture. But routing is the load-bearing path and Maven is
|
||||
Russian-first, so on the evidence here the switch is not worth making.
|
||||
|
||||
---
|
||||
|
||||
# Second sweep, same evening — five models, and a resident-model change
|
||||
|
||||
The sections above compared LFM2.5-1.2B against Qwen3.5-0.8B on routing and concluded
|
||||
"the switch is not worth making". That still holds. This sweep asked a different
|
||||
question — whether a *smaller* model could work, since LFM2.5's published
|
||||
instruction-following scores beat Qwen3.5-0.8B badly — and answered it, plus found a
|
||||
better resident model by accident.
|
||||
|
||||
**Outcome: the resident model is now stock Qwen3-1.7B.** Sub-500M is a dead end.
|
||||
|
||||
## Routing — 77 Russian cases, one run each
|
||||
|
||||
| model | on disk | llm-only (full) | llm-only (intent) | cascade + fallback |
|
||||
|---|---|---|---|---|
|
||||
| LFM2.5-230M-Q8_0 | 246 MB | 23.4% | 33.8% | 36.4% |
|
||||
| LFM2.5-350M-Q8_0 | 379 MB | 2.6% | **5.2%** | 20.8% |
|
||||
| Qwen3.5-0.8B-Q4_K_M | 527 MB | 36.4% | 59.7% | 61.0% |
|
||||
| Qwen3.5-2B-UD-Q4_K_XL | 1.34 GB | 42.9% | 62.3% | 63.6% |
|
||||
| **Qwen3-1.7B-UD-Q4_K_XL (stock)** | 1.13 GB | **44.2%** | **67.5%** | **72.7%** |
|
||||
|
||||
Qwen3-1.7B wins every column, including against a model 20% larger than it.
|
||||
|
||||
## Talk fixture — 27 cases, three runs each, idle box
|
||||
|
||||
| | Qwen3.5-0.8B | Qwen3-1.7B stock |
|
||||
|---|---|---|
|
||||
| composite | 13, 11, 8 | **20, 21, 18** |
|
||||
| address | 21, 18, 18 | **26, 25, 23** |
|
||||
| feminine | 27, 25, 26 | 26, 27, 26 |
|
||||
| lang | 27, 27, 26 | 26, 27, 27 |
|
||||
| ontopic | 16, 19, 19 | **22, 23, 23** |
|
||||
| canned fallbacks | 8, 5, 6 | **0, 2, 0** |
|
||||
|
||||
This also fills the row `TALK-EVAL-31-07-2026.md` had to void for contamination:
|
||||
**600ch/1024tok on Qwen3.5-0.8B scores 13, 11, 8.**
|
||||
|
||||
`address` is the headline. It sat at 18-22 of 27 on the 0.8B no matter how the prompt
|
||||
was worded — the prompt explicitly forbids "вы" and the model writes `вашей`,
|
||||
`подождите`, `делаете` anyway. That was read as "prompting is out of levers", and it
|
||||
was really "0.8B is out of capacity". The 1.7B mostly holds the constraint.
|
||||
|
||||
The fallback column matters too: 5-8 of 27 turns on the 0.8B end in a hardcoded
|
||||
`"не знаю."`, meaning it failed to emit parseable JSON about a quarter of the time.
|
||||
The 1.7B does that 0-2 times.
|
||||
|
||||
## Latency — the long tail is not the Thinking block
|
||||
|
||||
| | p50 | p95 |
|
||||
|---|---|---|
|
||||
| Qwen3.5-0.8B | 2.4s, 2.9s, 2.0s | 17.4s, 17.6s, 17.4s |
|
||||
| Qwen3-1.7B stock | 2.7s, 2.6s, 2.8s | 16.4s, 6.6s, 3.9s |
|
||||
|
||||
p50 is flat across a 2× size difference. The first instinct on seeing the 1.7B's
|
||||
16s p95 was "that is the reasoning trace, cap it" — wrong. The 0.8B's p95 is a
|
||||
consistent 17s and the 1.7B beat it in two of three runs. The tail is shared and
|
||||
lives somewhere else. Do not spend time on `/no_think` on this evidence.
|
||||
|
||||
## Sub-500M: not close, and the benchmarks say otherwise for a reason
|
||||
|
||||
LFM2.5-350M publishes IFEval 76.96 against Qwen3.5-0.8B's 59.94, and BFCLv3 44.11
|
||||
against 35.08 — better at instruction-following and structured output, at 2/3 the
|
||||
size. Those numbers are real and they are **English**. Every benchmark in that
|
||||
table except Multi-IF is English-only.
|
||||
|
||||
In Russian, with a 300-token budget and temperature 0:
|
||||
|
||||
- **350M**, «Столица Франции? Ответь кратко.» → *«Сторзит в Париже.»* — `Сторзит` is
|
||||
not a word; it is invented morphology.
|
||||
- **350M**, asked to read back a reminder → a fortune cookie about being attentive
|
||||
and confident. No reminder in it.
|
||||
- **230M**, «Привет, как дела?» → answered **in Spanish**.
|
||||
|
||||
The 230M beating the 350M six-fold on routing (33.8% vs 5.2%) is the other tell:
|
||||
when the larger sibling collapses like that it is format compliance failing, not
|
||||
reasoning.
|
||||
|
||||
This is a pretraining gap, not a fine-tuning gap. Teaching Russian to a 350M from
|
||||
near-zero is not an afternoon on a Colab, which was the premise worth checking.
|
||||
|
||||
## Why this vindicates the 1.7B CPT
|
||||
|
||||
Stock Qwen3-1.7B, untrained and unprompted, answers all three probes in fluent
|
||||
correct Russian. What it gets wrong is the persona: *«Привет! Я рад, что ты здесь»*
|
||||
— `рад` is masculine and Maven needs `рада`. That is the right kind of remaining
|
||||
problem, and it is exactly what the CPT (Vikunja #122) is for.
|
||||
|
||||
The 1.7B was the correct model choice. What was wrong was treating it as a
|
||||
**blocker**: stock already beats what was deployed, so it ships now and gets
|
||||
swapped again when the CPT lands.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Routing is one run per model, not three. The gaps between families are far larger
|
||||
than the run-to-run spread seen on the talk fixture, but the 2B-vs-1.7B gap (62.3
|
||||
vs 67.5) is not safe to call on one run.
|
||||
- ~~The routing numbers only reach production once the LLM router is wired on. It is
|
||||
still `nil`.~~ **Resolved the same evening:** the LLM router is wired at `voice.go:214`
|
||||
behind `voice.llm_router`, the default is on, and `deploy/mavend.json` sets it `true`.
|
||||
These numbers are the production path now, so the p50 ≈2.7s is a real per-turn cost and
|
||||
not a bench artifact.
|
||||
- ~~`/mnt/hdd1/llms/LFM2.5/Qwen3-1.7B-UD-Q4_K_XL.gguf` is a 293 MB truncated download
|
||||
in the wrong directory.~~ **Deleted 2026-07-31.** The good 1.13 GB copy in `qwen3/` is
|
||||
what `deploy/mavend.json` loads.
|
||||
- Harness: `scratchpad/bakeoff.sh`, one server at a time, health-checked before each
|
||||
run, `/v1/models` recorded per run. Never run two LLM consumers at once — see the
|
||||
contamination note in `TALK-EVAL-31-07-2026.md`.
|
||||
|
||||
@@ -90,4 +90,7 @@ later* is the worker + RAG.
|
||||
4. **Deferred work** — larger reasoner, custom Piper voice and other expansions.
|
||||
|
||||
## Non-goals (unchanged)
|
||||
Never phones home. Not a nag. Not autonomous. Feminine-gendered RU self-ref.
|
||||
Not a nag. Not autonomous. Feminine-gendered RU self-ref. No telemetry, no
|
||||
cloud model, no third-party account — but she MAY read external sources to
|
||||
answer world questions (Kiwix first, search optional). "Never phones home" as
|
||||
an absolute is deprecated, owner's call 2026-07-31; see CLAUDE.md § Non-goals.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Conversational phrasing eval — 31-07-2026
|
||||
|
||||
Every score measured tonight, on the three paths the nudge eval never touched:
|
||||
chat, query-with-notes, and general knowledge.
|
||||
|
||||
**Short version: the plumbing got fixed and the score barely moved.** Grammar and
|
||||
Russian prompts together took the composite from ~9 to ~14 of 27. Everything
|
||||
still failing is the model not knowing things or not holding a constraint, and
|
||||
prompting is out of levers. Settles the measurement half of Vikunja #395 / #398 /
|
||||
#400.
|
||||
|
||||
## How to reproduce
|
||||
|
||||
```sh
|
||||
# llama-server: -c 4096 -ngl 99 -t 6, model /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf
|
||||
MAVEN_LLM_URL=http://127.0.0.1:18099 no_proxy=127.0.0.1,localhost \
|
||||
deps/go/go/bin/go test -count=1 -timeout 40m \
|
||||
-run TestLLMTalkBaseline ./internal/phraser/eval/ -v
|
||||
```
|
||||
|
||||
Three runs per configuration, always. The fixture is 27 cases, so one reply
|
||||
changing moves the composite by 3.7 points — a single run cannot tell a real
|
||||
change from sampling noise. This was learned the expensive way: an earlier claim
|
||||
that "one nudge case fails every run" turned out to be three different cases
|
||||
across three runs.
|
||||
|
||||
**Run the box otherwise idle.** See the contamination note at the bottom.
|
||||
|
||||
## Composite, per configuration
|
||||
|
||||
| config | overall /27 | chat /9 | query /9 | knowledge /9 | canned fallbacks |
|
||||
|---|---|---|---|---|---|
|
||||
| baseline, no grammar | 7, 12, 7 | 1, 1, 0 | 2, 4, 2 | 4, 7, 5 | 0, 0, 0 |
|
||||
| + GBNF grammar (#398) | 14, 15, 8 | 1, 3, 0 | 5, 6, 3 | 8, 6, 5 | 0, 0, 0 |
|
||||
| + Russian prompts (#400) | 11, 17, 15 | 1, 5, 3 | 5, 6, 8 | 5, 6, 4 | 0, 0, 0 |
|
||||
| + truncation fix, 1000ch/768tok | 12, 13, 10 | 2, 2, 1 | 7, 7, 5 | 3, 4, 4 | 3, 3, 6 |
|
||||
| + rebalanced, 600ch/1024tok | **void — contaminated** | | | | |
|
||||
|
||||
"Canned fallbacks" counts replies that came back as the hardcoded `"не знаю."`
|
||||
or `"поговорили."`. It is not a check, it is a health signal: those strings mean
|
||||
the phraser gave up, and the eval scores them as ordinary bad replies.
|
||||
|
||||
## Per-check
|
||||
|
||||
| check | no grammar | + grammar | + RU prompts | + truncation fix |
|
||||
|---|---|---|---|---|
|
||||
| nonempty | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||
| ellipsis | 20, 19, 23 | 27, 27, 27 | 27, 27, 27 | 27, 27, 27 |
|
||||
| lang | 13, 16, 15 | 23, 26, 26 | 25, 26, 25 | 26, 27, 27 |
|
||||
| feminine | — | — | 25, 24, 26 | 25, 25, 27 |
|
||||
| address | — | — | 21, 22, 22 | 22, 21, 22 |
|
||||
| ontopic | — | — | 17, 24, 18 | 17, 19, 14 |
|
||||
|
||||
`nonempty` reading 27/27 everywhere is not good news — it was a broken check.
|
||||
It tested for a non-blank string, so replies of literally `{` and `"15-16"`
|
||||
passed it. Fixed on `overnight/fix-truncation`; it needs a letter now.
|
||||
|
||||
## What each change actually bought
|
||||
|
||||
**GBNF grammar (#398) — the biggest single win.** Qwen3.5-0.8B writes
|
||||
`Thinking Process:` as plain text with no tags, `stripThink` only handles
|
||||
`</think>`, so the JSON never closed and the plain-text fallback shipped the
|
||||
literal reasoning. `ellipsis` went 20→27 and `lang` 13→26. The router had been
|
||||
using a grammar for ages; the phraser asking nicely in the prompt was the
|
||||
oversight.
|
||||
|
||||
**Russian prompts (#400) — modest, plus a large latency win.** Chat 1.3→3.0
|
||||
average, query 4.7→6.3, knowledge 6.3→5.0. All inside the run-to-run spread, so
|
||||
"probably better on the paths it targeted, not provable in three runs". p50
|
||||
latency dropped from ~11.5s to ~2.3s and that part is consistent across all
|
||||
three runs — shorter prompts, and she stopped emitting English reasoning first.
|
||||
|
||||
**Truncation fix — necessary, and did not help the score.** Two real bugs
|
||||
(replies of `{`, and a `nonempty` check that passed them), both fixed, and the
|
||||
composite went nowhere. A complete rambling wrong answer fails the same checks a
|
||||
truncated one did. Worth doing anyway: the daemon was shipping `{` to a
|
||||
text-to-speech voice.
|
||||
|
||||
## The truncation bug, since the cause was counter-intuitive
|
||||
|
||||
The grammar's `string ::= ... {0,400}` rule was the cause, not the token cap.
|
||||
Measured against Qwen3.5-0.8B at three caps — 256, 768 and 2048 — the reply came
|
||||
back **exactly 400 characters every time, cut mid-word** (`"Нужно записать и,"`).
|
||||
|
||||
Then I raised the bound to 1000 while the cap was 768 tokens and made it worse:
|
||||
Russian runs ~1.5 characters per token here, so generation died on the *token*
|
||||
cap instead, mid-object, and the new guard correctly refused it and shipped
|
||||
`"не знаю."` — 3, 3 and 6 fallbacks per run, from zero. **The two limits have to
|
||||
agree.** 600 characters needs ~400 tokens; the cap is 1024.
|
||||
|
||||
## Where the remaining failures live
|
||||
|
||||
`address` is stuck at 21-22 of 27 and `ontopic` at 14-19. Both resist prompting.
|
||||
|
||||
**The prompt now explicitly forbids exactly what she does.** It says never "вы",
|
||||
use the singular — and she writes `вашей`, `подождите`, `делаете`, `хотите`,
|
||||
`напишите`. Telling a 0.8B "never do X" does not work. Same for
|
||||
`feminine`: `я готов`, `я понял`, `я нашел`, `я заметил`, `я сказал`.
|
||||
|
||||
**Some of `ontopic` is the fixture, not the model.** `chat-how-are-you` got
|
||||
`"Привет! Я здесь, чтобы поговорить. Как дела сегодня?"` — a fine reply that
|
||||
fails because `want_any` is `[норм, хорош, порядк, тут, работ]`. It fails in
|
||||
every run, so it inflates the count. The `ontopic` column currently measures the
|
||||
fixture as much as the model. Not fixed yet, deliberately: changing it would
|
||||
break comparability with the runs above.
|
||||
|
||||
**Two replies worth reading, because they are not fixable by prompting:**
|
||||
|
||||
- Thunder and lightning: *"Скорость молнии — 8-10 тысяч километров в секунду, но
|
||||
звук — 300 метров в секунду, что делает молнию громче."* Confidently wrong,
|
||||
and it concludes lightning is *louder* rather than sound being *slower*.
|
||||
- "расскажи обо мне": *"Ты — прекрасное существо, с душой и вниманием… Спасибо за
|
||||
твою улыбку… О тебе — заповедь любви."* Sycophantic filler, zero information,
|
||||
and precisely the "not a relationship" non-goal.
|
||||
- Boiling an egg: `"15-16"` one run, `"1"` another. No unit, wrong number.
|
||||
|
||||
The first argues for reading instead of recalling (#403 — Kiwix retrieval scores
|
||||
8/8 on the same questions given English keywords). The second and third argue
|
||||
for templates on the paths where correctness matters (#392).
|
||||
|
||||
## Contamination note — how the last row got voided
|
||||
|
||||
I started the query-rewrite agent against the same llama-server the sweep was
|
||||
using, and assumed contention would only affect latency. It did not. The
|
||||
knowledge path collapsed to 0 of 9 with eight canned `"не знаю."` replies, p95
|
||||
tripled to 23.7s, and **the report still said "0 errors"**.
|
||||
|
||||
That is Vikunja #397, and it is worse than filed: a merely *busy* server
|
||||
produces a clean-looking report with a third of the fixture silently answering
|
||||
`"не знаю."`. `PhraseChat` and `PhraseQuery` swallow every failure and return a
|
||||
hardcoded string, so infrastructure trouble is indistinguishable from bad
|
||||
phrasing in the score. The talk test guards the *start* and *end* of a run with
|
||||
a model check, which catches a dead server but not a loaded one.
|
||||
|
||||
**Until #397 is fixed, treat any run made on a busy box as void.**
|
||||
|
||||
## Next
|
||||
|
||||
- Re-run 600ch/1024tok clean, to fill the void row.
|
||||
- Score `Qwen3.5-2B-UD-Q4_K_XL` (already at `/mnt/hdd1/llms/qwen3.5/`, never
|
||||
measured) on this fixture and the router fixture. Not the 4B — too big for
|
||||
this box, owner's call.
|
||||
- Newer sub-500M candidates (LFM2.5 200M/300M) are worth a run for routing.
|
||||
Note `MODEL-BAKEOFF-31-07-2026.md` found LFM2.5-**1.2B** worse than
|
||||
Qwen3.5-0.8B at Russian routing and 2.4× slower — but those are a different,
|
||||
older generation, so that result does not predict the small ones.
|
||||
- Fix `chat-how-are-you`'s `want_any`, and re-baseline once, so `ontopic`
|
||||
measures the model.
|
||||
- #397 first if anything, since it decides whether any of the above is
|
||||
trustworthy.
|
||||
+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
|
||||
// `facts (kind=env, source=poll:caldav)` through core's IPC socket.
|
||||
// Key-free, restart-free, fail-independent — crashes can't touch the
|
||||
// store key, worst case a stale calendar_busy fact until the next poll.
|
||||
// READ side (unchanged behaviour): polls a Radicale (or any CalDAV) server for
|
||||
// today's events and writes `facts (kind=env, source=poll:caldav)` through
|
||||
// core's IPC socket. Key-free, restart-free, fail-independent — crashes can't
|
||||
// touch the store key, worst case a stale calendar_busy fact until the next
|
||||
// poll. Two facts:
|
||||
//
|
||||
// Two facts written:
|
||||
// - calendar_busy ("true"/"false") — read by the loop gate to suppress
|
||||
// nudges during meetings
|
||||
// - calendar_event ("<summary> @ <start>-<end>") — per-event for query
|
||||
//
|
||||
// Append-only discipline: a fact is written only when its value CHANGED
|
||||
// vs the latest for that key+source.
|
||||
// Append-only discipline: a fact is written only when its value CHANGED vs the
|
||||
// latest for that key+source.
|
||||
//
|
||||
// RENDER side (Vikunja #127, off unless -render-url is given): publishes each
|
||||
// pending reminder as a single-event iCal resource in a collection maven owns.
|
||||
// The calendar is a view, sqlite is the store — see render.go. The render URL
|
||||
// must differ from the read URL, checked at startup, so the render target can
|
||||
// never be a calendar maven is only supposed to read.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -27,6 +34,7 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
@@ -43,6 +51,10 @@ func run(args []string) error {
|
||||
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
||||
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
|
||||
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
|
||||
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
|
||||
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
|
||||
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
@@ -54,6 +66,9 @@ func run(args []string) error {
|
||||
if *url == "" || *user == "" || *pass == "" {
|
||||
return fmt.Errorf("-url, -user, -pass are required")
|
||||
}
|
||||
if err := checkRenderTarget(*url, *renderURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -64,16 +79,36 @@ func run(args []string) error {
|
||||
}
|
||||
defer core.Close()
|
||||
|
||||
hc := &http.Client{Timeout: *timeout}
|
||||
p := &poller{
|
||||
core: core,
|
||||
http: &http.Client{Timeout: *timeout},
|
||||
http: hc,
|
||||
url: strings.TrimRight(*url, "/"),
|
||||
user: *user,
|
||||
pass: *pass,
|
||||
}
|
||||
|
||||
var rend *renderer
|
||||
if *renderURL != "" {
|
||||
ru, rp := *renderUser, *renderPass
|
||||
if ru == "" {
|
||||
ru = *user
|
||||
}
|
||||
if rp == "" {
|
||||
rp = *pass
|
||||
}
|
||||
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
||||
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
||||
}
|
||||
|
||||
log.Printf("mavcaldav: polling %s every %s", *url, *interval)
|
||||
p.pollOnce(ctx) // fire immediately
|
||||
tick := func() {
|
||||
p.pollOnce(ctx)
|
||||
if rend != nil {
|
||||
rend.renderOnce(ctx)
|
||||
}
|
||||
}
|
||||
tick() // fire immediately
|
||||
t := time.NewTicker(*interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
@@ -82,11 +117,30 @@ func run(args []string) error {
|
||||
log.Printf("mavcaldav: bye")
|
||||
return nil
|
||||
case <-t.C:
|
||||
p.pollOnce(ctx)
|
||||
tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkRenderTarget refuses a render URL that is also 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 {
|
||||
core ipc.CoreAPI
|
||||
http *http.Client
|
||||
@@ -95,12 +149,6 @@ type poller struct {
|
||||
pass string
|
||||
}
|
||||
|
||||
type icalEvent struct {
|
||||
start time.Time
|
||||
end time.Time
|
||||
summary string
|
||||
}
|
||||
|
||||
func (p *poller) pollOnce(ctx context.Context) {
|
||||
now := time.Now()
|
||||
events, err := p.fetchEvents(ctx, now)
|
||||
@@ -109,38 +157,30 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
busy := false
|
||||
for _, e := range events {
|
||||
if !now.Before(e.start) && now.Before(e.end) {
|
||||
busy = true
|
||||
break
|
||||
}
|
||||
}
|
||||
busyVal := "false"
|
||||
if busy {
|
||||
if calendar.Busy(events, now) {
|
||||
busyVal = "true"
|
||||
}
|
||||
|
||||
// Write calendar_busy on change.
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", "poll:caldav", busyVal, now); err != nil {
|
||||
if err := p.writeIfChanged(ctx, "calendar_busy", calendar.SourcePersonal, busyVal, now, 1.0); err != nil {
|
||||
log.Printf("mavcaldav: write calendar_busy: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Write per-event facts (one per event, keyed by event summary + start).
|
||||
// Write per-event facts (one per event, keyed by day + event summary).
|
||||
// This lets the note RAG path answer "what's on my calendar" without
|
||||
// reaching back to Radicale.
|
||||
for _, e := range events {
|
||||
val := fmt.Sprintf("%s @ %s-%s", e.summary, e.start.Format("15:04"), e.end.Format("15:04"))
|
||||
eventKey := fmt.Sprintf("calendar_event_%s_%s", e.start.Format("20060102"), safeKey(e.summary))
|
||||
if err := p.writeIfChanged(ctx, eventKey, "poll:caldav", val, e.start); err != nil {
|
||||
log.Printf("mavcaldav: write %s: %v", eventKey, err)
|
||||
key := calendar.FactKey(e)
|
||||
if err := p.writeIfChanged(ctx, key, calendar.SourcePersonal, calendar.FactValue(e), e.Start, 1.0); err != nil {
|
||||
log.Printf("mavcaldav: write %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]icalEvent, error) {
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -162,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 parseICal(body, now), nil
|
||||
}
|
||||
|
||||
// parseICal scans iCal text for VEVENT components. Returns events that overlap
|
||||
// with today (UTC day boundaries) to keep the response manageable.
|
||||
func parseICal(body []byte, now time.Time) []icalEvent {
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
todayEnd := todayStart.AddDate(0, 0, 1)
|
||||
|
||||
var events []icalEvent
|
||||
text := string(body)
|
||||
for {
|
||||
veventStart := strings.Index(text, "BEGIN:VEVENT")
|
||||
if veventStart < 0 {
|
||||
break
|
||||
}
|
||||
text = text[veventStart+len("BEGIN:VEVENT"):]
|
||||
veventEnd := strings.Index(text, "END:VEVENT")
|
||||
if veventEnd < 0 {
|
||||
break
|
||||
}
|
||||
block := text[:veventEnd]
|
||||
text = text[veventEnd+len("END:VEVENT"):]
|
||||
|
||||
e := parseVEVENT(block)
|
||||
if e == nil {
|
||||
continue
|
||||
}
|
||||
// Only keep events overlapping today.
|
||||
if e.end.After(todayStart) && e.start.Before(todayEnd) {
|
||||
events = append(events, *e)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// parseVEVENT extracts start, end, summary from a VEVENT block.
|
||||
// Supports both UTC (DTEND:20260703T100000Z) and local (DTSTART;TZID=...:...)
|
||||
// formats. Returns nil for all-day events (no DTSTART/DTEND time component) or
|
||||
// parse failures.
|
||||
func parseVEVENT(block string) *icalEvent {
|
||||
var e icalEvent
|
||||
lines := strings.Split(block, "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
if t, ok := parseDT(line); ok {
|
||||
e.start = t
|
||||
}
|
||||
case strings.HasPrefix(line, "DTEND"):
|
||||
if t, ok := parseDT(line); ok {
|
||||
e.end = t
|
||||
}
|
||||
case strings.HasPrefix(line, "SUMMARY"):
|
||||
if idx := strings.Index(line, ":"); idx >= 0 {
|
||||
e.summary = strings.TrimSpace(line[idx+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if e.start.IsZero() || e.end.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &e
|
||||
}
|
||||
|
||||
// parseDT parses a DTSTART/DTEND value. Supports:
|
||||
// - UTC: DTEND:20260703T100000Z
|
||||
// - Local: DTSTART;TZID=Europe/Moscow:20260703T130000
|
||||
// - Value-date (all-day): DTSTART;VALUE=DATE:20260703 (returns zero time)
|
||||
func parseDT(line string) (time.Time, bool) {
|
||||
if strings.Contains(line, "VALUE=DATE:") {
|
||||
return time.Time{}, false // all-day, skip
|
||||
}
|
||||
idx := strings.LastIndex(line, ":")
|
||||
if idx < 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
val := line[idx+1:]
|
||||
val = strings.TrimSuffix(val, "Z")
|
||||
|
||||
// Try UTC first (has Z suffix, or ended in Z before TrimSuffix).
|
||||
if strings.HasSuffix(line, "Z") {
|
||||
t, err := time.Parse("20060102T150405", val)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t.UTC(), true
|
||||
}
|
||||
|
||||
// Local time — treat as UTC for simplicity (CalDAV server and poller
|
||||
// run in the same timezone; the gate only needs busy/not-busy accuracy).
|
||||
t, err := time.Parse("20060102T150405", val)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t.UTC(), true
|
||||
}
|
||||
|
||||
// safeKey makes an event summary safe to use as a fact key (alphanumeric + dash).
|
||||
func safeKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
b.WriteRune(r)
|
||||
} else if r == ' ' || r == '_' {
|
||||
b.WriteRune('-')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
return calendar.ParseICalDay(body, now), nil
|
||||
}
|
||||
|
||||
// writeIfChanged writes a fact only when the value differs from the latest.
|
||||
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)
|
||||
switch {
|
||||
case err == nil && prev.Value == val:
|
||||
@@ -289,7 +220,7 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, ts
|
||||
Key: key,
|
||||
Value: val,
|
||||
Source: source,
|
||||
Confidence: 1.0,
|
||||
Confidence: confidence,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
|
||||
+6
-165
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
type fakeCore struct {
|
||||
ipc.CoreAPI
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts map[string]ipc.Fact // composite key "key|source" → Fact
|
||||
writeLog []ipc.WriteFactReq
|
||||
writeErr error
|
||||
@@ -51,165 +51,6 @@ func (f *fakeCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, er
|
||||
return int64(len(f.writeLog)), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParseICal(t *testing.T) {
|
||||
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
body := []byte(`BEGIN:VCALENDAR
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260703T090000Z
|
||||
DTEND:20260703T100000Z
|
||||
SUMMARY:Morning standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260703T140000Z
|
||||
DTEND:20260703T150000Z
|
||||
SUMMARY:Team sync
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260702T140000Z
|
||||
DTEND:20260702T150000Z
|
||||
SUMMARY:Yesterday retro
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART:20260704T090000Z
|
||||
DTEND:20260704T100000Z
|
||||
SUMMARY:Tomorrow standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DTSTART;VALUE=DATE:20260704
|
||||
DTEND;VALUE=DATE:20260705
|
||||
SUMMARY:All-day event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`)
|
||||
|
||||
events := parseICal(body, now)
|
||||
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d events, want 2 (today events, no all-day/past/future)", len(events))
|
||||
}
|
||||
|
||||
// Morning standup — overlaps today.
|
||||
if events[0].summary != "Morning standup" {
|
||||
t.Errorf("events[0].summary = %q, want %q", events[0].summary, "Morning standup")
|
||||
}
|
||||
wantStart0 := time.Date(2026, 7, 3, 9, 0, 0, 0, time.UTC)
|
||||
if !events[0].start.Equal(wantStart0) {
|
||||
t.Errorf("events[0].start = %v, want %v", events[0].start, wantStart0)
|
||||
}
|
||||
wantEnd0 := time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC)
|
||||
if !events[0].end.Equal(wantEnd0) {
|
||||
t.Errorf("events[0].end = %v, want %v", events[0].end, wantEnd0)
|
||||
}
|
||||
|
||||
// Team sync — overlaps today.
|
||||
if events[1].summary != "Team sync" {
|
||||
t.Errorf("events[1].summary = %q, want %q", events[1].summary, "Team sync")
|
||||
}
|
||||
wantStart1 := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
if !events[1].start.Equal(wantStart1) {
|
||||
t.Errorf("events[1].start = %v, want %v", events[1].start, wantStart1)
|
||||
}
|
||||
wantEnd1 := time.Date(2026, 7, 3, 15, 0, 0, 0, time.UTC)
|
||||
if !events[1].end.Equal(wantEnd1) {
|
||||
t.Errorf("events[1].end = %v, want %v", events[1].end, wantEnd1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVEVENT(t *testing.T) {
|
||||
// Normal event with TZID in DTSTART and UTC DTEND.
|
||||
block := "DTSTART;TZID=Europe/Moscow:20260703T130000\nDTEND:20260703T140000Z\nSUMMARY:Stand up meeting"
|
||||
e := parseVEVENT(block)
|
||||
if e == nil {
|
||||
t.Fatal("expected non-nil icalEvent")
|
||||
}
|
||||
wantStart := time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC)
|
||||
if !e.start.Equal(wantStart) {
|
||||
t.Errorf("start = %v, want %v", e.start, wantStart)
|
||||
}
|
||||
wantEnd := time.Date(2026, 7, 3, 14, 0, 0, 0, time.UTC)
|
||||
if !e.end.Equal(wantEnd) {
|
||||
t.Errorf("end = %v, want %v", e.end, wantEnd)
|
||||
}
|
||||
if e.summary != "Stand up meeting" {
|
||||
t.Errorf("summary = %q, want %q", e.summary, "Stand up meeting")
|
||||
}
|
||||
|
||||
// All-day event (VALUE=DATE) → nil.
|
||||
allDay := "DTSTART;VALUE=DATE:20260703\nDTEND;VALUE=DATE:20260704\nSUMMARY:All-day"
|
||||
if e2 := parseVEVENT(allDay); e2 != nil {
|
||||
t.Error("expected nil for all-day event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDT(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
line string
|
||||
want time.Time
|
||||
wantOK bool
|
||||
}{
|
||||
{
|
||||
name: "UTC",
|
||||
line: "DTEND:20260703T100000Z",
|
||||
want: time.Date(2026, 7, 3, 10, 0, 0, 0, time.UTC),
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "local time",
|
||||
line: "DTSTART;TZID=Europe/Moscow:20260703T130000",
|
||||
want: time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC),
|
||||
wantOK: true,
|
||||
},
|
||||
{
|
||||
name: "all-day",
|
||||
line: "DTSTART;VALUE=DATE:20260703",
|
||||
want: time.Time{},
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "invalid",
|
||||
line: "DTSTART:garbage",
|
||||
want: time.Time{},
|
||||
wantOK: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseDT(tt.line)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ok = %v, want %v", ok, tt.wantOK)
|
||||
}
|
||||
if !got.Equal(tt.want) {
|
||||
t.Errorf("got = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Stand up meeting", "Stand-up-meeting"},
|
||||
{"Hello_World", "Hello-World"},
|
||||
{"special@#$chars!!", "specialchars"},
|
||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := safeKey(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("safeKey(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core logic tests
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -221,7 +62,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
t.Run("no previous fact writes", func(t *testing.T) {
|
||||
fc := &fakeCore{}
|
||||
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 {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -249,7 +90,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -265,7 +106,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
},
|
||||
}
|
||||
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 {
|
||||
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) {
|
||||
fc := &fakeCore{readErr: fmt.Errorf("connection refused")}
|
||||
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 {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
@@ -292,7 +133,7 @@ func TestWriteIfChanged(t *testing.T) {
|
||||
writeErr: fmt.Errorf("disk full"),
|
||||
}
|
||||
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 {
|
||||
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,41 @@
|
||||
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 {
|
||||
// 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,275 @@
|
||||
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/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},
|
||||
{"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")
|
||||
}
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
+57
-6
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
@@ -21,8 +23,12 @@ const clarifyTTL = 90 * time.Second
|
||||
// raw utterance, chat and system have nothing to fill in. For those a clarify
|
||||
// decision keeps the canned "не поняла" reply — inventing a question for noise
|
||||
// is worse than admitting she missed it.
|
||||
// A reminder wants BOTH what to remind about and when. Subject first: "напомни
|
||||
// в 11" has a time and nothing to say at 11, and a reminder with no subject is
|
||||
// not worth setting. Order here is the order she asks in — she still only asks
|
||||
// about the first one missing.
|
||||
var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
router.IntentReminder: {dialogue.SlotTime},
|
||||
router.IntentReminder: {dialogue.SlotText, dialogue.SlotTime},
|
||||
router.IntentFact: {dialogue.SlotKey},
|
||||
router.IntentAct: {dialogue.SlotFn},
|
||||
}
|
||||
@@ -35,7 +41,8 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
||||
// questions, so there is no gender agreement to get wrong; the feminine
|
||||
// self-reference lives in the reply she gives when she drops the request.
|
||||
var clarifyQuestions = map[dialogue.Slot]string{
|
||||
dialogue.SlotTime: "На когда напомнить?",
|
||||
dialogue.SlotTime: "Когда?",
|
||||
dialogue.SlotText: "О чём напомнить?",
|
||||
dialogue.SlotKey: "Что записать?",
|
||||
dialogue.SlotFn: "Что сделать?",
|
||||
}
|
||||
@@ -45,11 +52,55 @@ var clarifyQuestions = map[dialogue.Slot]string{
|
||||
// landed. Feminine self-reference ("поняла"), as everywhere.
|
||||
const clarifyGaveUp = "Прости, я не поняла. Скажи, пожалуйста, по-другому."
|
||||
|
||||
// clarifyExpired — his answer came after the TTL, so the parked request is
|
||||
// already gone. Same tone as clarifyGaveUp, different reason: too much time
|
||||
// clarifyExpiredVariants — his answer came after the TTL, so the parked request
|
||||
// is already gone. Same tone as clarifyGaveUp, different reason: too much time
|
||||
// passed, not "I did not understand". Feminine self-reference ("ждала",
|
||||
// "отпустила"); he is addressed with a plain imperative.
|
||||
const clarifyExpired = "Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново."
|
||||
//
|
||||
// Five phrasings, not one. This is the line he hears whenever he walks off
|
||||
// mid-request, so it is the line that repeats most — and the same sentence every
|
||||
// time is what makes a house assistant sound like a kiosk. They all carry the
|
||||
// same two facts (the old request is gone; say it again if it still matters),
|
||||
// because the wording may vary and the meaning may not.
|
||||
//
|
||||
// Fixed templates rather than model output, for the same reason as
|
||||
// clarifyQuestions: this text has to be right every time, and it is not worth a
|
||||
// generation to say something this small.
|
||||
var clarifyExpiredVariants = []string{
|
||||
"Прости, я слишком долго ждала ответа и отпустила прошлую просьбу. Если она ещё нужна, скажи заново.",
|
||||
"Кажется, прошлая просьба уже не важна — я её отпустила. Если я ошибаюсь, повтори.",
|
||||
"Ты как-то резко замолчал, и я не стала ждать дальше. Если та просьба ещё нужна, скажи заново.",
|
||||
"Я не дождалась ответа и убрала прошлую просьбу. Повтори, если она всё ещё нужна.",
|
||||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random.
|
||||
func clarifyExpiredLine() string {
|
||||
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
|
||||
}
|
||||
|
||||
// isClarifyExpired reports whether s opens with any of the expiry lines. The
|
||||
// notice is glued in front of this turn's reply (see withNotice), so a caller
|
||||
// checking for it has to match a prefix, not the whole string.
|
||||
func isClarifyExpired(s string) bool {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
||||
// reply. "" ⇒ the notice was the whole thing.
|
||||
func trimClarifyExpired(s string) string {
|
||||
for _, v := range clarifyExpiredVariants {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||||
// out, and "" when nothing was parked. Call it right after
|
||||
@@ -63,7 +114,7 @@ func (h *reactiveHandler) clarifyExpiredNotice() string {
|
||||
return ""
|
||||
}
|
||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||
return clarifyExpired
|
||||
return clarifyExpiredLine()
|
||||
}
|
||||
|
||||
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
||||
|
||||
@@ -56,10 +56,13 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
||||
want string
|
||||
asked bool
|
||||
}{
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "На когда напомнить?", true},
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
|
||||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||||
{"reminder that already has a time", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "", false},
|
||||
// A time with nothing to say at that time is still half a reminder, so
|
||||
// the subject is what she asks about — not silence.
|
||||
{"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true},
|
||||
{"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false},
|
||||
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
||||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||||
}
|
||||
@@ -78,7 +81,7 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "На когда напомнить?" {
|
||||
if !asked || question != "Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
@@ -152,7 +155,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
if !handled {
|
||||
t.Fatalf("answer %d must be consumed as an answer", i)
|
||||
}
|
||||
if reply != "На когда напомнить?" {
|
||||
if reply != "Когда?" {
|
||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
@@ -304,17 +307,17 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||
*now = now.Add(clarifyTTL + time.Second)
|
||||
|
||||
reply := h.handleText(ctx, "как дела")
|
||||
if !strings.HasPrefix(reply, clarifyExpired) {
|
||||
if !isClarifyExpired(reply) {
|
||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||
}
|
||||
if strings.TrimSpace(strings.TrimPrefix(reply, clarifyExpired)) == "" {
|
||||
if trimClarifyExpired(reply) == "" {
|
||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("the expired question must be gone")
|
||||
}
|
||||
// The notice is said once, not on every later utterance.
|
||||
if reply := h.handleText(ctx, "как дела"); strings.Contains(reply, clarifyExpired) {
|
||||
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) {
|
||||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,143 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 + "."
|
||||
}
|
||||
+42
-96
@@ -97,96 +97,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// lockedAPI is a dummy CoreAPI used while the daemon is locked. Every method
|
||||
// returns errLocked. The wire protocol's StoreAPI methods all go through the
|
||||
// Server dispatch on CoreAPI, so returning errLocked from each is correct.
|
||||
type lockedAPI struct{}
|
||||
|
||||
var _ ipc.CoreAPI = (*lockedAPI)(nil)
|
||||
|
||||
func (l *lockedAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) LatestFact(ctx context.Context, key string) (ipc.Fact, error) {
|
||||
return ipc.Fact{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) LatestFactBySource(ctx context.Context, key, source string) (ipc.Fact, error) {
|
||||
return ipc.Fact{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) Presence(ctx context.Context) (ipc.Presence, error) {
|
||||
return ipc.Presence{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
return errLocked
|
||||
}
|
||||
func (l *lockedAPI) ListReminders(ctx context.Context, n int) ([]ipc.Reminder, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||
return errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentFacts(ctx context.Context, n int) ([]ipc.Fact, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]ipc.Fact, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentNudges(ctx context.Context, n int) ([]ipc.Nudge, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
return 0, errLocked
|
||||
}
|
||||
func (l *lockedAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]ipc.Note, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RecentNotes(ctx context.Context, n int) ([]ipc.Note, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||
return false, errLocked
|
||||
}
|
||||
func (l *lockedAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||
return errLocked
|
||||
}
|
||||
func (l *lockedAPI) DisableTool(ctx context.Context, name string) error { return errLocked }
|
||||
func (l *lockedAPI) DeleteTool(ctx context.Context, name string) error { return errLocked }
|
||||
func (l *lockedAPI) ListProposedRoutines(ctx context.Context) ([]ipc.ProposedRoutine, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) DismissProposedRoutine(ctx context.Context, id int64) error { return errLocked }
|
||||
func (l *lockedAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return errLocked
|
||||
}
|
||||
func (l *lockedAPI) LookupTool(ctx context.Context, name string) (ipc.Tool, error) {
|
||||
return ipc.Tool{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) ListTools(ctx context.Context, status string) ([]ipc.Tool, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
func (l *lockedAPI) RevertFact(ctx context.Context, key string) (int64, error) { return 0, errLocked }
|
||||
func (l *lockedAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return "", errLocked
|
||||
}
|
||||
func (l *lockedAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
|
||||
return ipc.TickTrace{}, errLocked
|
||||
}
|
||||
func (l *lockedAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
return nil, errLocked
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
@@ -259,6 +169,7 @@ func run(args []string) error {
|
||||
coreAPI ipc.CoreAPI
|
||||
eco *ecosystemWiring
|
||||
factWorker *factEnrichmentWorker
|
||||
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||
)
|
||||
|
||||
if !locked {
|
||||
@@ -278,6 +189,7 @@ func run(args []string) error {
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
@@ -350,21 +262,28 @@ func run(args []string) error {
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
|
||||
coreAPI = &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
api.chatFn = voiceW.handler.handleText
|
||||
}
|
||||
} else {
|
||||
// locked mode: dummy CoreAPI that returns errLocked for everything
|
||||
coreAPI = &lockedAPI{}
|
||||
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
||||
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
||||
// refused before it reaches this value. This is just a safe non-nil
|
||||
// placeholder: if the guard is ever bypassed by a bug, calls land
|
||||
// here and fail loudly with ipc.ErrNotImplemented instead of a nil
|
||||
// dereference or, worse, silently succeeding.
|
||||
coreAPI = ipc.UnimplementedCoreAPI{}
|
||||
}
|
||||
|
||||
// ----- IPC boundary (core ↔ modules) -----
|
||||
@@ -375,7 +294,17 @@ func run(args []string) error {
|
||||
|
||||
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
|
||||
|
||||
// Set Server.Check — in locked mode, block everything except unlock-path methods.
|
||||
// Set Server.Check — the single authorization guard, run once by
|
||||
// Server.dispatch before any CoreAPI method is called (see
|
||||
// internal/ipc/server.go). In locked mode this is the ONLY thing
|
||||
// standing between an unauthenticated caller and the store: it must
|
||||
// default-deny, with an explicit allowlist for the two methods the
|
||||
// unlock flow itself needs (MethodAssertStepUp, MethodUnlock — neither
|
||||
// of which touches CoreAPI; dispatch handles them directly via
|
||||
// srv.StepUp/srv.UnlockFn). Forgetting to allowlist a new unlock-path
|
||||
// method fails safe (denied); forgetting to guard a new CoreAPI method
|
||||
// is impossible because there is nothing left to forget — every method
|
||||
// not in the allowlist is refused by construction.
|
||||
if locked {
|
||||
srv.Check = func(ctx context.Context, m ipc.Method, _ json.RawMessage) error {
|
||||
switch m {
|
||||
@@ -448,6 +377,7 @@ func run(args []string) error {
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
@@ -511,14 +441,16 @@ func run(args []string) error {
|
||||
tickInterval := time.Duration(cfg.TickInterval)
|
||||
repeatInterval := time.Duration(cfg.RepeatInterval)
|
||||
autotuneInterval := time.Duration(cfg.AutotuneInterval)
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines))
|
||||
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
|
||||
// Swap the CoreAPI from lockedAPI to the real store adapter.
|
||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||
newAPI := &daemonAPI{
|
||||
CoreAPI: ipc.NewStoreAPI(st),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
@@ -549,6 +481,13 @@ func run(args []string) error {
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
|
||||
// Start background memory evaluation (nil unless configured).
|
||||
if evalWorker != nil {
|
||||
go func() {
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
dl.unlock()
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
return nil
|
||||
@@ -587,6 +526,13 @@ func run(args []string) error {
|
||||
defer wg.Done()
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
if evalWorker != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func newLLMReplier(c completer, block func() string) *llmReplier {
|
||||
return &llmReplier{c: c, stub: voice.NewStubReplier(), block: block}
|
||||
}
|
||||
|
||||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), тепло и по-русски. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
||||
const replySystem = `Ты — Maven, домашняя ассистентка (о себе — в женском роде). Владелец — мужчина, говоришь с ним на "ты", в единственном числе; никогда не "вы"/"ваш" и не "он"/"его". Подтверди действие РОВНО ОДНИМ коротким предложением (≤120 символов), по-русски, спокойно и без официальных формулировок. Не задавай вопросов, не повторяй слова, не добавляй ничего после точки. Отвечай ТОЛЬКО одним объектом JSON с полями "response" (текст) и "mood" (ровно одно из: neutral, happy, thinking, tired, confused).
|
||||
Пример: {"response": "Записала, что ты выпил стакан воды.", "mood": "neutral"}
|
||||
Никогда не пиши "..." в поле response.`
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/pattern"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/routine"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -67,6 +68,14 @@ type tickLoop struct {
|
||||
morningRoutines []morning.Routine
|
||||
morningLast map[string]time.Time
|
||||
|
||||
// proposalCfg — announcement policy for routines the tick inferred itself.
|
||||
// nil ⇒ detect silently, never announce (the default). lastProposalAt is
|
||||
// the cooldown clock, in-memory on purpose: a restart is allowed to permit
|
||||
// one more announcement, and a restart-per-day loop is a bigger problem
|
||||
// than a duplicate proposal notice.
|
||||
proposalCfg *config.PatternProposalConfig
|
||||
lastProposalAt time.Time
|
||||
|
||||
// digestQ — in-memory queue of eligible nudges waiting for batch flush.
|
||||
// populated when digestCfg != nil && digestCfg.Enabled.
|
||||
digestQ []QueuedNudge
|
||||
@@ -92,6 +101,7 @@ func newTickLoop(
|
||||
digestCfg *config.DigestConfig,
|
||||
routines []routine.Routine,
|
||||
morningRoutines []morning.Routine,
|
||||
proposalCfg *config.PatternProposalConfig,
|
||||
) *tickLoop {
|
||||
return &tickLoop{
|
||||
store: st,
|
||||
@@ -108,6 +118,7 @@ func newTickLoop(
|
||||
routineLast: make(map[string]time.Time),
|
||||
morningRoutines: morningRoutines,
|
||||
morningLast: make(map[string]time.Time),
|
||||
proposalCfg: proposalCfg,
|
||||
lastPhrase: make(map[string]delivery.PhrasedNudge),
|
||||
}
|
||||
}
|
||||
@@ -180,6 +191,17 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// (with dedup) avoids re-queueing the same rule after a flush.
|
||||
t.maybeFlush(ctx, now, state)
|
||||
|
||||
// gate-suppressed digest (Vikunja #281): rules the restraint gate held
|
||||
// back this tick (quiet hours / away / calendar-busy), not because they
|
||||
// weren't due, but because it wasn't the moment. Some of those are worth
|
||||
// resurfacing later instead of just being lost — loop.DigestEligible
|
||||
// draws that line. This is a SEPARATE mechanism from the in-memory
|
||||
// digestQ above: that one batches candidates the gate already ALLOWED to
|
||||
// fire; this one durably holds candidates the gate BLOCKED.
|
||||
t.enqueueSuppressedDigest(ctx, trace, state, now)
|
||||
t.expireStaleDigest(ctx, now)
|
||||
t.maybeDrainDigest(ctx, state, now)
|
||||
|
||||
// routines: operator-declared scheduled behaviors. fire the ones whose cron
|
||||
// crossed since last fire, delivered through the normal routing (voice when
|
||||
// present, away channels otherwise). bodies are literal operator text — not
|
||||
@@ -195,6 +217,14 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// nudge time. See internal/morning for the "why not four timers" rationale.
|
||||
t.fireMorningRoutines(ctx, now, state)
|
||||
|
||||
// pattern detection: scan every action+object pair with recorded events
|
||||
// and propose a routine for any stable one not already decided (Vikunja
|
||||
// #43). This used to only run as a side effect of the voice fact-write
|
||||
// path, so a pattern already sitting in history went unnoticed until he
|
||||
// happened to mention it again by voice. See patterns.go and
|
||||
// detectPatterns below for how idempotence and dismissal are respected.
|
||||
t.detectPatterns(ctx, now, state)
|
||||
|
||||
// reminders: gate-bypassing class. fired once, marked after a successful
|
||||
// delivery. a failed send leaves the reminder pending — the next tick
|
||||
// re-gathers and re-attempts.
|
||||
@@ -342,6 +372,235 @@ func (t *tickLoop) flushDigest(ctx context.Context, now time.Time, state loop.St
|
||||
t.digestQ = nil
|
||||
}
|
||||
|
||||
// detectPatterns runs the pattern detector proactively over every
|
||||
// action+object pair that has ever produced an event, independent of
|
||||
// whichever fact write (or channel) last touched it (Vikunja #43). This is
|
||||
// what makes pattern inference actually proactive: it fires on the daemon's
|
||||
// own schedule reading accumulated history, not only as a side effect of a
|
||||
// live voice turn.
|
||||
//
|
||||
// Idempotence and noise are handled by the store, not here — this function
|
||||
// is safe to call every tick:
|
||||
// - Same pattern, tick after tick: detectAndPropose's LookupProposedRoutine
|
||||
// check plus proposed_routines' UNIQUE(action, object) constraint (with
|
||||
// CreateProposedRoutine's ON CONFLICT DO NOTHING) mean a pair that
|
||||
// already has a row — in ANY status — produces no second row and no log
|
||||
// spam beyond the one line at genuine creation.
|
||||
// - A DISMISSED proposal must never come back. DismissProposedRoutine flips
|
||||
// status in place; the row is never deleted. So the same Lookup check
|
||||
// that stops a duplicate "proposed" also stops a "dismissed" one from
|
||||
// resurrecting — there is nothing tick-specific to get right here beyond
|
||||
// calling the same shared path the voice route already used.
|
||||
//
|
||||
// By default this only creates a row for the /routines page to show: it does
|
||||
// not notify, ring, or speak. Detection is not the same act as disturbing him
|
||||
// about it, and Maven is "not a nag, not autonomous" (CLAUDE.md). Announcing
|
||||
// is opt-in through the pattern_proposals config block — see announceProposal
|
||||
// for the restraints that apply even then. A proposal only starts producing
|
||||
// recurring nudges once he accepts it (fireAcceptedRoutines).
|
||||
func (t *tickLoop) detectPatterns(ctx context.Context, now time.Time, state loop.State) {
|
||||
pairs, err := t.store.DistinctEventPairs(ctx)
|
||||
if err != nil {
|
||||
log.Printf("tick: distinct event pairs: %v", err)
|
||||
return
|
||||
}
|
||||
announced := false
|
||||
for _, p := range pairs {
|
||||
r, _, err := detectAndPropose(ctx, t.store, p.Action, p.Object, now)
|
||||
if err != nil {
|
||||
log.Printf("tick: detect pattern %s/%s: %v", p.Action, p.Object, err)
|
||||
continue
|
||||
}
|
||||
if r == nil {
|
||||
continue // no stable pattern, or already proposed/accepted/dismissed
|
||||
}
|
||||
log.Printf("tick: proposed routine: %s/%s every %.1f days", r.Action, r.Object, r.IntervalDays)
|
||||
// One announcement per tick at most, whatever the scan turned up. The
|
||||
// rest are on /routines; they are not lost, they are just not shouted.
|
||||
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.
|
||||
// Validation (cron parses, name/body present, severity defaulted) already ran
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// (autotuneInterval, see run) so it doesn't write a fact every tick. for each
|
||||
// rule:
|
||||
@@ -609,6 +939,7 @@ type daemonAPI struct {
|
||||
ipc.CoreAPI
|
||||
getTrace func() *loop.TickTrace
|
||||
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
|
||||
getDayPlan func(ctx context.Context) ipc.DayPlan
|
||||
chatFn func(ctx context.Context, text string) string
|
||||
}
|
||||
|
||||
@@ -634,6 +965,13 @@ func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStat
|
||||
return d.getMorningStatus(ctx), nil
|
||||
}
|
||||
|
||||
func (d *daemonAPI) DayPlan(ctx context.Context) (ipc.DayPlan, error) {
|
||||
if d.getDayPlan == nil {
|
||||
return ipc.DayPlan{}, errors.New("mavend: day plan not available")
|
||||
}
|
||||
return d.getDayPlan(ctx), nil
|
||||
}
|
||||
|
||||
func toIPCTickTrace(t loop.TickTrace) ipc.TickTrace {
|
||||
rules := make([]ipc.RuleTrace, len(t.RuleTraces))
|
||||
for i, r := range t.RuleTraces {
|
||||
|
||||
@@ -46,7 +46,7 @@ func newTestTickLoop(t *testing.T, st *store.Store, sink delivery.Sink, digestCf
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil)
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, digestCfg, nil, nil, nil)
|
||||
}
|
||||
|
||||
func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||
@@ -63,7 +63,7 @@ func TestTickFiresRoutineWhenScheduleCrosses(t *testing.T) {
|
||||
sink := &fakeSink{}
|
||||
d := delivery.NewDispatcher(delivery.Config{Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st})
|
||||
rs := []routine.Routine{{Name: "morning", Cron: "0 12 * * *", Body: "полдень, время воды", Severity: 1}}
|
||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil)
|
||||
tl := newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, rs, nil, nil)
|
||||
|
||||
// first tick: seeds, does not fire the routine.
|
||||
tl.tick(ctx, now)
|
||||
|
||||
+44
-1625
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,434 @@
|
||||
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,
|
||||
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,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
|
||||
// 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
|
||||
// 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 {
|
||||
ipc.CoreAPI
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
proposed, enabled []ipc.Tool
|
||||
listErr error
|
||||
@@ -62,6 +63,18 @@ type fakeCore struct {
|
||||
// for handleTrace tests
|
||||
tickTrace ipc.TickTrace
|
||||
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 {
|
||||
@@ -1044,3 +1057,64 @@ func TestHandleRoutines_NilCore_503(t *testing.T) {
|
||||
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, "привет")
|
||||
}
|
||||
}
|
||||
|
||||
+65
-10
@@ -314,7 +314,7 @@ func noCache(h http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
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)")
|
||||
// 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
|
||||
@@ -329,11 +329,15 @@ func main() {
|
||||
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)")
|
||||
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)")
|
||||
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)")
|
||||
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()
|
||||
|
||||
var core ipc.CoreAPI
|
||||
@@ -381,6 +385,14 @@ func main() {
|
||||
mux.HandleFunc("/api/signal", func(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
handleDash(w, r, core)
|
||||
})
|
||||
@@ -434,9 +446,9 @@ func main() {
|
||||
}
|
||||
if stepUpSession == nil {
|
||||
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 {
|
||||
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 +466,26 @@ func main() {
|
||||
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
|
||||
})
|
||||
|
||||
// /api/revert voids the latest fact for a key — a store mutation, so it
|
||||
// sits behind the same passkey step-up as tool enable (nil session ⇒
|
||||
// WebAuthn unconfigured ⇒ transport-level auth only, same as /tools).
|
||||
// State-changing routes on this server, and their gate (Vikunja #317):
|
||||
//
|
||||
// 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) {
|
||||
handleChatPage(w, r, core)
|
||||
})
|
||||
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) {
|
||||
handleRevert(w, r, core, stepUpSession, *requireStepUp)
|
||||
@@ -935,12 +959,32 @@ func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
http.Error(w, "core read failed", http.StatusBadGateway)
|
||||
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")
|
||||
if err := morningTmpl.Execute(w, status); err != nil {
|
||||
if err := morningTmpl.Execute(w, view); err != nil {
|
||||
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) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := voiceTmpl.Execute(w, nil); err != nil {
|
||||
@@ -1258,7 +1302,14 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
@@ -1267,6 +1318,10 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
http.Error(w, "chat disabled (no -core)", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if !stepUpOK(session, requireStepUp) {
|
||||
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(r.FormValue("text"))
|
||||
if text == "" {
|
||||
http.Redirect(w, r, "/chat", http.StatusSeeOther)
|
||||
|
||||
+20
-2
@@ -1,9 +1,27 @@
|
||||
{{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>
|
||||
{{if not .}}
|
||||
{{if not .Routines}}
|
||||
<div class=hint>no morning routines configured</div>
|
||||
{{else}}
|
||||
{{range .}}
|
||||
{{range .Routines}}
|
||||
<div class="mb-4">
|
||||
<div><strong>{{.Name}}</strong>
|
||||
<span class={{if .Active}}green{{else}}gray{{end}}>{{if .Active}}active now{{else}}outside window{{end}}</span>
|
||||
|
||||
@@ -8,6 +8,18 @@
|
||||
#
|
||||
# Maven's own compose joins this same network (add `ecosystem` as an external
|
||||
# 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
|
||||
|
||||
services:
|
||||
|
||||
@@ -1,13 +1,71 @@
|
||||
# Reverse-proxy the three sibling admin UIs. Drop into your nginx sites (or the
|
||||
# nginx-panel app) and reload. Assumes the compose publishes each service on
|
||||
# 127.0.0.1:<port>. Add TLS (certbot / your existing cert block) per server.
|
||||
# Reverse-proxy Maven's own web UI plus the three sibling admin UIs. Drop into
|
||||
# your nginx sites (or the nginx-panel app) and reload. Assumes the compose
|
||||
# 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
|
||||
# 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 {
|
||||
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;
|
||||
|
||||
allow 10.42.0.0/24;
|
||||
allow 192.168.1.0/24;
|
||||
deny all;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9740;
|
||||
proxy_set_header Host $host;
|
||||
@@ -18,8 +76,14 @@ server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 10.42.0.1:80;
|
||||
listen 192.168.1.104:80;
|
||||
server_name praxis.kvmx.ru;
|
||||
|
||||
allow 10.42.0.0/24;
|
||||
allow 192.168.1.0/24;
|
||||
deny all;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8989;
|
||||
proxy_set_header Host $host;
|
||||
@@ -30,8 +94,14 @@ server {
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen 10.42.0.1:80;
|
||||
listen 192.168.1.104:80;
|
||||
server_name hexis.kvmx.ru;
|
||||
|
||||
allow 10.42.0.0/24;
|
||||
allow 192.168.1.0/24;
|
||||
deny all;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:9741;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
+9
-3
@@ -6,11 +6,12 @@
|
||||
"state_dir": "/var/lib/maven",
|
||||
|
||||
"phraser": {
|
||||
"model_path": "/opt/maven/models/llm/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf",
|
||||
"model_path": "/opt/maven/models/llm/qwen3/Qwen3-1.7B-UD-Q4_K_XL.gguf",
|
||||
"bin_path": "llama-server",
|
||||
"n_gpu_layers": 99,
|
||||
"n_ctx": 2048,
|
||||
"timeout": "60s"
|
||||
"n_ctx": 4096,
|
||||
"timeout": "60s",
|
||||
"llm_nudges": false
|
||||
},
|
||||
|
||||
"telegram": {
|
||||
@@ -25,6 +26,11 @@
|
||||
"severity_ceiling": 2
|
||||
},
|
||||
|
||||
"pattern_proposals": {
|
||||
"notify": false,
|
||||
"cooldown": "24h"
|
||||
},
|
||||
|
||||
"nexus": { "url": "http://nexus:9740" },
|
||||
"praxis": { "url": "http://praxis:8989" },
|
||||
"hexis": { "url": "http://hexis:9741" },
|
||||
|
||||
@@ -25,3 +25,46 @@
|
||||
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`
|
||||
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.
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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
|
||||
// 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 {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
writes int
|
||||
chats int
|
||||
}
|
||||
@@ -369,89 +372,7 @@ func (r *recordingAPI) WriteFact(_ context.Context, _ ipc.WriteFactReq) (int64,
|
||||
r.writes++
|
||||
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) {
|
||||
r.chats++
|
||||
return "echo: " + text, nil
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -140,6 +140,16 @@ type Config struct {
|
||||
// item. See internal/morning for the evaluation engine. Empty ⇒ disabled.
|
||||
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"`
|
||||
|
||||
// Praxis — the ecosystem attention-state service. When configured, maven
|
||||
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
|
||||
// Maven never touches Praxis's database directly (ecosystem invariant: no
|
||||
@@ -352,6 +362,62 @@ type DigestConfig struct {
|
||||
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
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
|
||||
// as a managed subprocess and sends chat-completion requests to phrase nudge
|
||||
// and reminder messages. nil ⇒ the template-based Stub is used instead.
|
||||
@@ -369,6 +435,12 @@ type PhraserConfig struct {
|
||||
NGpuLayers int `json:"n_gpu_layers,omitempty"`
|
||||
NCtx int `json:"n_ctx,omitempty"`
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
|
||||
// LLMNudges — let the model word nudges again. Off by default: nudges are
|
||||
// worded from hand-written Russian templates now (the model broke the
|
||||
// persona and invented units). Chat, query and reminder phrasing always go
|
||||
// through the model regardless. See phraser.Config.LLMNudges.
|
||||
LLMNudges bool `json:"llm_nudges,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
|
||||
@@ -442,6 +514,15 @@ const (
|
||||
DefaultLLMRouter = true
|
||||
|
||||
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
|
||||
@@ -514,6 +595,18 @@ func (c *Config) applyDefaults() {
|
||||
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)
|
||||
}
|
||||
|
||||
if c.Voice != nil {
|
||||
if c.Voice.RouterThreshold <= 0 {
|
||||
c.Voice.RouterThreshold = DefaultRouterThreshold
|
||||
|
||||
@@ -35,6 +35,27 @@ func TestLoadDefaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Nudges come from templates unless the config says otherwise.
|
||||
func TestPhraserLLMNudgesDefaultsOff(t *testing.T) {
|
||||
p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`)
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if c.Phraser.LLMNudges {
|
||||
t.Error("llm_nudges defaults on; templates must be the default")
|
||||
}
|
||||
|
||||
p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`)
|
||||
c, err = Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !c.Phraser.LLMNudges {
|
||||
t.Error("llm_nudges:true did not parse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDurationsParse(t *testing.T) {
|
||||
p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`)
|
||||
c, err := Load(p)
|
||||
@@ -222,3 +243,53 @@ func TestDurationRoundTrip(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,14 @@ type CoreAPI interface {
|
||||
// TickTrace.
|
||||
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
|
||||
|
||||
// DayPlan returns today's ordered plan — calendar events, pending
|
||||
// reminders and any morning checklist still outstanding (see
|
||||
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
|
||||
// Read-only: asking for the plan never dispatches or schedules anything.
|
||||
// The store adapter returns an error (the plan needs the daemon's routine
|
||||
// config) — same shape as TickTrace and MorningStatus.
|
||||
DayPlan(ctx context.Context) (DayPlan, error)
|
||||
|
||||
// Chat routes a text utterance through the reactive handler's core path
|
||||
// (router → dialogue → action → replier) and returns the reply text.
|
||||
// No audio or stt/tts — for text channels (mavweb, telegram).
|
||||
@@ -359,6 +367,26 @@ type MorningRoutineStatus struct {
|
||||
Items []MorningRoutineItem `json:"items"`
|
||||
}
|
||||
|
||||
// DayPlanItem — one line of the day plan. Kind is "event", "reminder" or
|
||||
// "checklist"; Uncertain marks an item whose provenance is below a full
|
||||
// calendar read (a meeting relayed off a phone notification), so a UI can hedge
|
||||
// the same way the spoken form does.
|
||||
type DayPlanItem struct {
|
||||
At time.Time `json:"at"`
|
||||
Text string `json:"text"`
|
||||
Kind string `json:"kind"`
|
||||
Uncertain bool `json:"uncertain,omitempty"`
|
||||
}
|
||||
|
||||
// DayPlan — the plan for one calendar day. Spoken is the RU sentence maven
|
||||
// says when asked, rendered core-side so the voice reply and the web view can
|
||||
// never drift apart.
|
||||
type DayPlan struct {
|
||||
Date time.Time `json:"date"`
|
||||
Items []DayPlanItem `json:"items"`
|
||||
Spoken string `json:"spoken"`
|
||||
}
|
||||
|
||||
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
|
||||
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
|
||||
type storeEncryptionKeyReq struct {
|
||||
|
||||
@@ -71,6 +71,7 @@ var readOnlyMethods = map[Method]bool{
|
||||
MethodListProposedRoutines: true,
|
||||
MethodTickTrace: true,
|
||||
MethodMorningStatus: true,
|
||||
MethodDayPlan: true,
|
||||
}
|
||||
|
||||
// Dial connects to a core socket at path and returns a Client. The module
|
||||
@@ -458,6 +459,14 @@ func (c *Client) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, err
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *Client) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
var p DayPlan
|
||||
if err := c.call(ctx, MethodDayPlan, nil, &p); err != nil {
|
||||
return DayPlan{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c *Client) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
var result struct {
|
||||
NewID int64 `json:"new_id"`
|
||||
|
||||
@@ -410,95 +410,12 @@ func TestChatViaClient(t *testing.T) {
|
||||
}
|
||||
|
||||
// chatTestAPI — a minimal CoreAPI that only implements Chat for testing.
|
||||
type chatTestAPI struct{}
|
||||
// Embeds UnimplementedCoreAPI so every other method fails loudly with
|
||||
// ErrNotImplemented instead of needing 27 hand-written no-op stubs.
|
||||
type chatTestAPI struct {
|
||||
UnimplementedCoreAPI
|
||||
}
|
||||
|
||||
func (a *chatTestAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
|
||||
return 0, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
|
||||
return Fact{}, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
||||
return Fact{}, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||
return 0, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) Presence(ctx context.Context) (Presence, error) {
|
||||
return Presence{}, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
||||
return 0, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
return ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||
return 0, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||
return ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
return 0, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||
return false, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||
return ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) DisableTool(ctx context.Context, name string) error {
|
||||
return ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
func (a *chatTestAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||
return Tool{}, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
return 0, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return TickTrace{}, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||
return nil, ErrUnknownMethod
|
||||
}
|
||||
func (a *chatTestAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
if text == "привет" {
|
||||
return "и тебе привет!", nil
|
||||
|
||||
+248
-306
@@ -211,6 +211,10 @@ func (a *storeAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, e
|
||||
return nil, errors.New("store: morning status not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, errors.New("store: day plan not available via direct store API")
|
||||
}
|
||||
|
||||
func (a *storeAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||
ts, err := a.s.ListTools(ctx, status)
|
||||
if err != nil {
|
||||
@@ -501,6 +505,242 @@ func (s *Server) safeDispatch(ctx context.Context, req Request) (result json.Raw
|
||||
return s.dispatch(ctx, req)
|
||||
}
|
||||
|
||||
// handlerFunc — one table entry's shape: unmarshal req.Params (if it wants
|
||||
// any), call the matching CoreAPI method against the api passed in, marshal
|
||||
// the result. api is a parameter, not a closed-over field, precisely so a
|
||||
// table built once at package init never pins a stale CoreAPI — see the note
|
||||
// on methodTable below about SetAPI.
|
||||
type handlerFunc func(ctx context.Context, api CoreAPI, raw json.RawMessage) (json.RawMessage, error)
|
||||
|
||||
// withParams adapts a (typed params, typed result) CoreAPI call into a
|
||||
// handlerFunc: unmarshal into P, call fn, marshal R. On error the result is
|
||||
// dropped (marshalResult's output is never read when err != nil — see
|
||||
// serveConn) so every entry can uniformly return early on error without
|
||||
// re-deriving what the pre-table per-arm code used to return in that case.
|
||||
func withParams[P any, R any](fn func(ctx context.Context, api CoreAPI, p P) (R, error)) handlerFunc {
|
||||
return func(ctx context.Context, api CoreAPI, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var p P
|
||||
if err := unmarshalParams(raw, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r, err := fn(ctx, api, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(r), nil
|
||||
}
|
||||
}
|
||||
|
||||
// withParamsVoid is withParams for the error-only methods (mark/resolve/
|
||||
// enable/disable/...): params in, no result out, wire reply is always null.
|
||||
func withParamsVoid[P any](fn func(ctx context.Context, api CoreAPI, p P) error) handlerFunc {
|
||||
return func(ctx context.Context, api CoreAPI, raw json.RawMessage) (json.RawMessage, error) {
|
||||
var p P
|
||||
if err := unmarshalParams(raw, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), fn(ctx, api, p)
|
||||
}
|
||||
}
|
||||
|
||||
// withoutParams is withParams for the handful of methods that take no
|
||||
// params at all (Presence, TickTrace, MorningStatus, ListProposedRoutines).
|
||||
// It does NOT call unmarshalParams — matching the pre-table arms, which
|
||||
// never touched req.Params for these four methods.
|
||||
func withoutParams[R any](fn func(ctx context.Context, api CoreAPI) (R, error)) handlerFunc {
|
||||
return func(ctx context.Context, api CoreAPI, _ json.RawMessage) (json.RawMessage, error) {
|
||||
r, err := fn(ctx, api)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(r), nil
|
||||
}
|
||||
}
|
||||
|
||||
// methodTable — one entry per CoreAPI-backed method. Built once at package
|
||||
// init, not per-Server and not per-dispatch: entries close over nothing but
|
||||
// the CoreAPI method being called, and dispatch passes in the *current*
|
||||
// api (loaded fresh via s.api.Load() every call, same as before the table
|
||||
// existed) as an argument — so SetAPI's runtime swap (the unlock transition)
|
||||
// is still honored on the very next request with no extra plumbing here.
|
||||
//
|
||||
// MethodAssertStepUp, MethodStoreEncryptionKey and MethodUnlock are NOT in
|
||||
// this table: they bypass CoreAPI entirely (s.StepUp / s.WrapKeyFn /
|
||||
// s.UnlockFn), so dispatch special-cases them before consulting the table.
|
||||
var methodTable = map[Method]handlerFunc{
|
||||
MethodWriteFact: withParams(func(ctx context.Context, api CoreAPI, p WriteFactReq) (idResp, error) {
|
||||
id, err := api.WriteFact(ctx, p)
|
||||
return idResp{ID: id}, err
|
||||
}),
|
||||
MethodLatestFact: withParams(func(ctx context.Context, api CoreAPI, p keyReq) (Fact, error) {
|
||||
return api.LatestFact(ctx, p.Key)
|
||||
}),
|
||||
MethodLatestFactBySource: withParams(func(ctx context.Context, api CoreAPI, p keySourceReq) (Fact, error) {
|
||||
return api.LatestFactBySource(ctx, p.Key, p.Source)
|
||||
}),
|
||||
MethodSince: withParams(func(ctx context.Context, api CoreAPI, p sinceReq) (sinceResp, error) {
|
||||
d, err := api.Since(ctx, p.Key, p.Now)
|
||||
return sinceResp{Dur: d}, err
|
||||
}),
|
||||
MethodPresence: withoutParams(func(ctx context.Context, api CoreAPI) (Presence, error) {
|
||||
return api.Presence(ctx)
|
||||
}),
|
||||
MethodCreateReminder: withParams(func(ctx context.Context, api CoreAPI, p createReminderReq) (idResp, error) {
|
||||
id, err := api.CreateReminder(ctx, p.Fire, p.Payload, p.Cron)
|
||||
return idResp{ID: id}, err
|
||||
}),
|
||||
MethodMarkReminder: withParamsVoid(func(ctx context.Context, api CoreAPI, p markReminderReq) error {
|
||||
return api.MarkReminder(ctx, p.ID, p.Status)
|
||||
}),
|
||||
MethodListReminders: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Reminder, error) {
|
||||
out, err := api.ListReminders(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Reminder{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodRecordNudge: withParams(func(ctx context.Context, api CoreAPI, p recordNudgeReq) (idResp, error) {
|
||||
id, err := api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts)
|
||||
return idResp{ID: id}, err
|
||||
}),
|
||||
MethodResolveNudge: withParamsVoid(func(ctx context.Context, api CoreAPI, p resolveNudgeReq) error {
|
||||
return api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts)
|
||||
}),
|
||||
MethodRecentOutcomes: withParams(func(ctx context.Context, api CoreAPI, p outcomesReq) ([]string, error) {
|
||||
out, err := api.RecentOutcomes(ctx, p.Rule, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{} // stable non-null on the wire
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodRecentFacts: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Fact, error) {
|
||||
out, err := api.RecentFacts(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Fact{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodCalendarEvents: withParams(func(ctx context.Context, api CoreAPI, p calendarEventsReq) ([]Fact, error) {
|
||||
out, err := api.CalendarEvents(ctx, p.From, p.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Fact{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodRecentNudges: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Nudge, error) {
|
||||
out, err := api.RecentNudges(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Nudge{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodWriteNote: withParams(func(ctx context.Context, api CoreAPI, p writeNoteReq) (idResp, error) {
|
||||
id, err := api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source)
|
||||
return idResp{ID: id}, err
|
||||
}),
|
||||
MethodQueryNotes: withParams(func(ctx context.Context, api CoreAPI, p queryNotesReq) ([]Note, error) {
|
||||
out, err := api.QueryNotes(ctx, p.Embedding, p.K)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodRecentNotes: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]Note, error) {
|
||||
out, err := api.RecentNotes(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
MethodProposeTool: withParams(func(ctx context.Context, api CoreAPI, p proposeToolReq) (proposeToolResp, error) {
|
||||
ok, err := api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts)
|
||||
return proposeToolResp{Proposed: ok}, err
|
||||
}),
|
||||
MethodEnableTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p enableToolReq) error {
|
||||
return api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Scope, p.Ts)
|
||||
}),
|
||||
MethodDisableTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
|
||||
return api.DisableTool(ctx, p.Name)
|
||||
}),
|
||||
MethodLookupTool: withParams(func(ctx context.Context, api CoreAPI, p lookupToolReq) (Tool, error) {
|
||||
return api.LookupTool(ctx, p.Name)
|
||||
}),
|
||||
MethodListTools: withParams(func(ctx context.Context, api CoreAPI, p listToolsReq) (listToolsResp, error) {
|
||||
out, err := api.ListTools(ctx, p.Status)
|
||||
if err != nil {
|
||||
return listToolsResp{}, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Tool{}
|
||||
}
|
||||
return listToolsResp{Tools: out}, nil
|
||||
}),
|
||||
// MethodDeleteTool shares disableToolReq — both take just a tool name.
|
||||
MethodDeleteTool: withParamsVoid(func(ctx context.Context, api CoreAPI, p disableToolReq) error {
|
||||
return api.DeleteTool(ctx, p.Name)
|
||||
}),
|
||||
MethodListProposedRoutines: withoutParams(func(ctx context.Context, api CoreAPI) (listProposedRoutinesResp, error) {
|
||||
out, err := api.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
return listProposedRoutinesResp{}, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []ProposedRoutine{}
|
||||
}
|
||||
return listProposedRoutinesResp{Routines: out}, nil
|
||||
}),
|
||||
MethodDismissProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p dismissProposedRoutineReq) error {
|
||||
return api.DismissProposedRoutine(ctx, p.ID)
|
||||
}),
|
||||
MethodAcceptProposedRoutine: withParamsVoid(func(ctx context.Context, api CoreAPI, p acceptProposedRoutineReq) error {
|
||||
return api.AcceptProposedRoutine(ctx, p.ID)
|
||||
}),
|
||||
MethodRevertFact: withParams(func(ctx context.Context, api CoreAPI, p revertReq) (map[string]int64, error) {
|
||||
newID, err := api.RevertFact(ctx, p.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]int64{"new_id": newID}, nil
|
||||
}),
|
||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||
reply, err := api.Chat(ctx, p.Text)
|
||||
return chatResp{Reply: reply}, err
|
||||
}),
|
||||
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
||||
return api.TickTrace(ctx)
|
||||
}),
|
||||
// MorningStatus intentionally has no nil→[]T{} normalization here — the
|
||||
// pre-table arm marshaled api.MorningStatus's result as-is (a nil slice
|
||||
// serializes as JSON null), and this preserves that exact wire shape.
|
||||
MethodDayPlan: withoutParams(func(ctx context.Context, api CoreAPI) (DayPlan, error) {
|
||||
return api.DayPlan(ctx)
|
||||
}),
|
||||
MethodMorningStatus: withoutParams(func(ctx context.Context, api CoreAPI) ([]MorningRoutineStatus, error) {
|
||||
return api.MorningStatus(ctx)
|
||||
}),
|
||||
}
|
||||
|
||||
// dispatch unmarshals params for req.Method and calls the matching CoreAPI
|
||||
// method. Unknown method ⇒ ErrUnknownMethod; a malformed params payload ⇒
|
||||
// ErrBadParams with the underlying text (local, server-side, not shipped to
|
||||
@@ -517,312 +757,11 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// These three bypass CoreAPI entirely — they drive Server fields set
|
||||
// directly by the daemon (StepUp / WrapKeyFn / UnlockFn), not store
|
||||
// state, so they can never be table entries keyed on a CoreAPI method.
|
||||
switch req.Method {
|
||||
case MethodWriteFact:
|
||||
var p WriteFactReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := api.WriteFact(ctx, p)
|
||||
return marshalResult(idResp{ID: id}), err
|
||||
|
||||
case MethodLatestFact:
|
||||
var p keyReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := api.LatestFact(ctx, p.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(f), nil
|
||||
|
||||
case MethodLatestFactBySource:
|
||||
var p keySourceReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := api.LatestFactBySource(ctx, p.Key, p.Source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(f), nil
|
||||
|
||||
case MethodSince:
|
||||
var p sinceReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := api.Since(ctx, p.Key, p.Now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(sinceResp{Dur: d}), nil
|
||||
|
||||
case MethodPresence:
|
||||
pres, err := api.Presence(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(pres), nil
|
||||
|
||||
case MethodCreateReminder:
|
||||
var p createReminderReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := api.CreateReminder(ctx, p.Fire, p.Payload, p.Cron)
|
||||
return marshalResult(idResp{ID: id}), err
|
||||
|
||||
case MethodMarkReminder:
|
||||
var p markReminderReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := api.MarkReminder(ctx, p.ID, p.Status)
|
||||
return marshalResult(nil), err
|
||||
|
||||
case MethodListReminders:
|
||||
var p nReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := api.ListReminders(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Reminder{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodRecordNudge:
|
||||
var p recordNudgeReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := api.RecordNudge(ctx, p.Rule, p.Channel, p.Message, p.Ts)
|
||||
return marshalResult(idResp{ID: id}), err
|
||||
|
||||
case MethodResolveNudge:
|
||||
var p resolveNudgeReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err := api.ResolveNudge(ctx, p.ID, p.Outcome, p.Ts)
|
||||
return marshalResult(nil), err
|
||||
|
||||
case MethodRecentOutcomes:
|
||||
var p outcomesReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := api.RecentOutcomes(ctx, p.Rule, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []string{} // stable non-null on the wire
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodRecentFacts:
|
||||
var p nReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := api.RecentFacts(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Fact{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodCalendarEvents:
|
||||
var p calendarEventsReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := api.CalendarEvents(ctx, p.From, p.To)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Fact{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodRecentNudges:
|
||||
var p nReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := api.RecentNudges(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Nudge{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodWriteNote:
|
||||
var p writeNoteReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := api.WriteNote(ctx, p.Ts, p.Text, p.Embedding, p.Source)
|
||||
return marshalResult(idResp{ID: id}), err
|
||||
|
||||
case MethodQueryNotes:
|
||||
var p queryNotesReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := api.QueryNotes(ctx, p.Embedding, p.K)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodRecentNotes:
|
||||
var p nReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := api.RecentNotes(ctx, p.N)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Note{}
|
||||
}
|
||||
return marshalResult(out), nil
|
||||
|
||||
case MethodProposeTool:
|
||||
var p proposeToolReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := api.ProposeTool(ctx, p.Name, p.Utterance, p.Scope, p.Ts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(proposeToolResp{Proposed: ok}), nil
|
||||
|
||||
case MethodEnableTool:
|
||||
var p enableToolReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), api.EnableTool(ctx, p.Name, p.Cmd, p.Destructive, p.Scope, p.Ts)
|
||||
|
||||
case MethodDisableTool:
|
||||
var p disableToolReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), api.DisableTool(ctx, p.Name)
|
||||
|
||||
case MethodLookupTool:
|
||||
var p lookupToolReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := api.LookupTool(ctx, p.Name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(t), nil
|
||||
|
||||
case MethodListTools:
|
||||
var p listToolsReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := api.ListTools(ctx, p.Status)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []Tool{}
|
||||
}
|
||||
return marshalResult(listToolsResp{Tools: out}), nil
|
||||
|
||||
case MethodDeleteTool:
|
||||
var p disableToolReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), api.DeleteTool(ctx, p.Name)
|
||||
|
||||
case MethodListProposedRoutines:
|
||||
out, err := api.ListProposedRoutines(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out == nil {
|
||||
out = []ProposedRoutine{}
|
||||
}
|
||||
return marshalResult(listProposedRoutinesResp{Routines: out}), nil
|
||||
|
||||
case MethodDismissProposedRoutine:
|
||||
var p dismissProposedRoutineReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), api.DismissProposedRoutine(ctx, p.ID)
|
||||
|
||||
case MethodAcceptProposedRoutine:
|
||||
var p acceptProposedRoutineReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(nil), api.AcceptProposedRoutine(ctx, p.ID)
|
||||
|
||||
case MethodRevertFact:
|
||||
var p struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
newID, err := api.RevertFact(ctx, p.Key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(map[string]int64{"new_id": newID}), nil
|
||||
|
||||
case MethodChat:
|
||||
var p chatReq
|
||||
if err := unmarshalParams(req.Params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply, err := api.Chat(ctx, p.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(chatResp{Reply: reply}), nil
|
||||
|
||||
case MethodTickTrace:
|
||||
t, err := api.TickTrace(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(t), nil
|
||||
|
||||
case MethodMorningStatus:
|
||||
s, err := api.MorningStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return marshalResult(s), nil
|
||||
|
||||
case MethodAssertStepUp:
|
||||
if s.StepUp != nil {
|
||||
return marshalResult(nil), s.StepUp(ctx)
|
||||
@@ -848,10 +787,13 @@ func (s *Server) dispatch(ctx context.Context, req Request) (json.RawMessage, er
|
||||
return marshalResult(nil), s.UnlockFn(ctx, p.PublicKey)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
}
|
||||
|
||||
default:
|
||||
h, ok := methodTable[req.Method]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", ErrUnknownMethod, req.Method)
|
||||
}
|
||||
return h(ctx, api, req.Params)
|
||||
}
|
||||
|
||||
func unmarshalParams(raw json.RawMessage, v any) error {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotImplemented is returned by every UnimplementedCoreAPI method. It is
|
||||
// deliberately distinct from ErrUnknownMethod (a wire-level "no such
|
||||
// method exists" verdict) and from any daemon-level "locked" error: this one
|
||||
// means "this method exists on CoreAPI, but the fake/adapter embedding
|
||||
// UnimplementedCoreAPI never got a real implementation for it." A test that
|
||||
// exercises an undeclared method fails loudly on this text instead of
|
||||
// silently nil-panicking or being mistaken for a legitimate failure.
|
||||
var ErrNotImplemented = errors.New("ipc: not implemented (unimplemented CoreAPI stub)")
|
||||
|
||||
// UnimplementedCoreAPI is the gRPC Unimplemented*Server pattern applied to
|
||||
// CoreAPI: embed it in a test double or adapter and override only the
|
||||
// methods you actually exercise. Every method returns ErrNotImplemented, so
|
||||
// a call that reaches an undeclared method fails loudly and specifically,
|
||||
// rather than compiling to a silent no-op or nil-pointer panic. This
|
||||
// replaces the old pattern of hand-writing all 30 no-op stubs per double —
|
||||
// those were compiler-satisfying padding, not tests of anything.
|
||||
type UnimplementedCoreAPI struct{}
|
||||
|
||||
var _ CoreAPI = UnimplementedCoreAPI{}
|
||||
|
||||
func (UnimplementedCoreAPI) WriteFact(ctx context.Context, req WriteFactReq) (int64, error) {
|
||||
return 0, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) LatestFact(ctx context.Context, key string) (Fact, error) {
|
||||
return Fact{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) LatestFactBySource(ctx context.Context, key, source string) (Fact, error) {
|
||||
return Fact{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) Since(ctx context.Context, key string, now time.Time) (time.Duration, error) {
|
||||
return 0, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) Presence(ctx context.Context) (Presence, error) {
|
||||
return Presence{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) CreateReminder(ctx context.Context, fire time.Time, payload, cron string) (int64, error) {
|
||||
return 0, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) MarkReminder(ctx context.Context, id int64, status string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListReminders(ctx context.Context, n int) ([]Reminder, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) RecordNudge(ctx context.Context, rule, channel, message string, ts time.Time) (int64, error) {
|
||||
return 0, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ResolveNudge(ctx context.Context, id int64, outcome string, ts time.Time) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) RecentOutcomes(ctx context.Context, rule string, n int) ([]string, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) RecentNudges(ctx context.Context, n int) ([]Nudge, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
|
||||
return 0, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) QueryNotes(ctx context.Context, embedding []float32, k int) ([]Note, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) RecentNotes(ctx context.Context, n int) ([]Note, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ProposeTool(ctx context.Context, name, utterance, scope string, ts time.Time) (bool, error) {
|
||||
return false, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) EnableTool(ctx context.Context, name string, cmd []string, destructive bool, scope string, ts time.Time) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) DisableTool(ctx context.Context, name string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) DeleteTool(ctx context.Context, name string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListProposedRoutines(ctx context.Context) ([]ProposedRoutine, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) DismissProposedRoutine(ctx context.Context, id int64) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) AcceptProposedRoutine(ctx context.Context, id int64) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) LookupTool(ctx context.Context, name string) (Tool, error) {
|
||||
return Tool{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) ListTools(ctx context.Context, status string) ([]Tool, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) RevertFact(ctx context.Context, key string) (int64, error) {
|
||||
return 0, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return TickTrace{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) DayPlan(ctx context.Context) (DayPlan, error) {
|
||||
return DayPlan{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) Chat(ctx context.Context, text string) (string, error) {
|
||||
return "", ErrNotImplemented
|
||||
}
|
||||
@@ -45,6 +45,7 @@ const (
|
||||
MethodRevertFact Method = "revert_fact"
|
||||
MethodTickTrace Method = "tick_trace"
|
||||
MethodMorningStatus Method = "morning_status"
|
||||
MethodDayPlan Method = "day_plan"
|
||||
MethodChat Method = "chat"
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package kiwix reads a local Kiwix server (offline Wikipedia and friends).
|
||||
//
|
||||
// Why: the resident model is a 0.8B and invents facts. Letting her read a local
|
||||
// article snippet beats letting her recall. Nothing here talks to the internet;
|
||||
// the Kiwix server is on the same box.
|
||||
//
|
||||
// This is search only. Full articles are ~100KB of HTML, far too big for a 4096
|
||||
// token context, so the unit of context is the search snippet (~500 chars).
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result is one search hit.
|
||||
type Result struct {
|
||||
Title string // article title, e.g. "Rayleigh scattering"
|
||||
Path string // e.g. /content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering
|
||||
Snippet string // plain text, tags stripped, entities decoded
|
||||
WordCount int // 0 if the server did not say
|
||||
}
|
||||
|
||||
// Client is a Kiwix HTTP client. Boring on purpose: no retries, no cache.
|
||||
type Client struct {
|
||||
base string
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
// New makes a client for a Kiwix base URL like http://127.0.0.1:8034.
|
||||
func New(baseURL string) *Client {
|
||||
return &Client{
|
||||
base: strings.TrimRight(baseURL, "/"),
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Search runs a keyword search in one ZIM (book) and returns up to limit hits.
|
||||
//
|
||||
// Ranking is keyword based, not semantic: "Rayleigh scattering" finds the right
|
||||
// article, "why is the sky blue" finds a TV episode. Pass keywords, not questions.
|
||||
func (c *Client) Search(ctx context.Context, pattern, book string, limit int) ([]Result, error) {
|
||||
if limit <= 0 {
|
||||
limit = 5
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("pattern", pattern)
|
||||
q.Set("books.name", book)
|
||||
q.Set("format", "xml")
|
||||
q.Set("pageLength", strconv.Itoa(limit))
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+"/search?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("kiwix search: http %d", resp.StatusCode)
|
||||
}
|
||||
return ParseSearchRSS(resp.Body)
|
||||
}
|
||||
|
||||
// rss mirrors just the bits of the RSS 2.0 reply we use.
|
||||
type rss struct {
|
||||
Items []struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
// innerxml keeps the <b> match markers so we can strip them ourselves.
|
||||
Description struct {
|
||||
Inner string `xml:",innerxml"`
|
||||
} `xml:"description"`
|
||||
WordCount string `xml:"wordCount"`
|
||||
} `xml:"channel>item"`
|
||||
}
|
||||
|
||||
var tagRE = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// ParseSearchRSS turns a Kiwix search reply into results. Exported so the parser
|
||||
// is testable from a captured response, with no server running.
|
||||
func ParseSearchRSS(r io.Reader) ([]Result, error) {
|
||||
var doc rss
|
||||
if err := xml.NewDecoder(r).Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("kiwix search: bad xml: %w", err)
|
||||
}
|
||||
out := make([]Result, 0, len(doc.Items))
|
||||
for _, it := range doc.Items {
|
||||
n, _ := strconv.Atoi(strings.ReplaceAll(it.WordCount, ",", ""))
|
||||
out = append(out, Result{
|
||||
Title: strings.TrimSpace(it.Title),
|
||||
Path: strings.TrimSpace(it.Link),
|
||||
Snippet: plainText(it.Description.Inner),
|
||||
WordCount: n,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// plainText drops markup and decodes entities, leaving text a model can read.
|
||||
func plainText(s string) string {
|
||||
s = tagRE.ReplaceAllString(s, "")
|
||||
s = html.UnescapeString(s)
|
||||
return strings.TrimSpace(strings.Join(strings.Fields(s), " "))
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A real reply from the live server, trimmed to two items.
|
||||
const sampleRSS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<channel>
|
||||
<title>Search: Rayleigh scattering</title>
|
||||
<opensearch:totalResults>800</opensearch:totalResults>
|
||||
<item>
|
||||
<title>Rayleigh scattering</title>
|
||||
<link>/content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering</link>
|
||||
<description><b>Rayleigh</b> scattering causes the blue color of the sky & yellow colors near the Sun.[1]</description>
|
||||
<book><title>Wikipedia</title></book>
|
||||
<wordCount>2,818</wordCount>
|
||||
</item>
|
||||
<item>
|
||||
<title>Hyper–Rayleigh scattering</title>
|
||||
<link>/content/wikipedia_en_all_maxi_2026-02/Hyper%E2%80%93Rayleigh_scattering</link>
|
||||
<description>...<b>Rayleigh</b> scattering" is a nonlinear optical counterpart.</description>
|
||||
<book><title>Wikipedia</title></book>
|
||||
<wordCount>914</wordCount>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
func TestParseSearchRSS(t *testing.T) {
|
||||
got, err := ParseSearchRSS(strings.NewReader(sampleRSS))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 results, got %d", len(got))
|
||||
}
|
||||
if got[0].Title != "Rayleigh scattering" {
|
||||
t.Errorf("title = %q", got[0].Title)
|
||||
}
|
||||
if got[0].Path != "/content/wikipedia_en_all_maxi_2026-02/Rayleigh_scattering" {
|
||||
t.Errorf("path = %q", got[0].Path)
|
||||
}
|
||||
if got[0].WordCount != 2818 {
|
||||
t.Errorf("wordCount = %d", got[0].WordCount)
|
||||
}
|
||||
want := "Rayleigh scattering causes the blue color of the sky & yellow colors near the Sun.[1]"
|
||||
if got[0].Snippet != want {
|
||||
t.Errorf("snippet = %q, want %q", got[0].Snippet, want)
|
||||
}
|
||||
if strings.Contains(got[1].Snippet, "<b>") {
|
||||
t.Errorf("second snippet still has tags: %q", got[1].Snippet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSearchRSSBadXML(t *testing.T) {
|
||||
if _, err := ParseSearchRSS(strings.NewReader("not xml at all")); err == nil {
|
||||
t.Fatal("want an error on junk input")
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in: needs a live Kiwix server. CI has none.
|
||||
// MAVEN_KIWIX_URL=http://127.0.0.1:8034 no_proxy=127.0.0.1,localhost go test -run Retrieval -v ./internal/kiwix/
|
||||
func TestRetrievalEval(t *testing.T) {
|
||||
base := os.Getenv("MAVEN_KIWIX_URL")
|
||||
if base == "" {
|
||||
t.Skip("set MAVEN_KIWIX_URL to run the retrieval eval")
|
||||
}
|
||||
noProxyLoopback(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
rep, err := RunRetrievalEval(ctx, New(base), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
// No pass bar on purpose: the number is the finding.
|
||||
t.Log("\n" + rep.String() + rep.Detail())
|
||||
}
|
||||
|
||||
// noProxyLoopback stops the box's SOCKS bridge from eating loopback requests.
|
||||
func noProxyLoopback(t *testing.T) {
|
||||
t.Setenv("no_proxy", "127.0.0.1,localhost")
|
||||
t.Setenv("NO_PROXY", "127.0.0.1,localhost")
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "kiwix-knowledge-v1",
|
||||
"book": "wikipedia_en_all_maxi_2026-02",
|
||||
"note": "The 9 knowledge cases from internal/phraser/eval/talk_v1.json. Queries are hand-written English keywords on purpose: Kiwix ranks by keyword, not meaning, so a natural question fails. Writing them by hand separates 'retrieval is broken' from 'the model writes bad queries'.",
|
||||
"cases": [
|
||||
{
|
||||
"id": "know-sky-blue",
|
||||
"question": "почему небо синее?",
|
||||
"query": "Rayleigh scattering sky blue",
|
||||
"want_titles": ["Rayleigh scattering", "Diffuse sky radiation"]
|
||||
},
|
||||
{
|
||||
"id": "know-boil-egg",
|
||||
"question": "сколько варить яйцо вкрутую?",
|
||||
"query": "boiled egg cooking",
|
||||
"want_titles": ["Boiled egg", "Egg as food"]
|
||||
},
|
||||
{
|
||||
"id": "know-ssd-vs-hdd",
|
||||
"question": "чем ssd отличается от hdd?",
|
||||
"query": "solid-state drive",
|
||||
"want_titles": ["Solid-state drive", "Hard disk drive"]
|
||||
},
|
||||
{
|
||||
"id": "know-cat-purr",
|
||||
"question": "почему кошки мурчат?",
|
||||
"query": "cat purr",
|
||||
"want_titles": ["Purr", "Cat communication"]
|
||||
},
|
||||
{
|
||||
"id": "know-hiccups",
|
||||
"question": "как быстро избавиться от икоты?",
|
||||
"query": "hiccup",
|
||||
"want_titles": ["Hiccup"]
|
||||
},
|
||||
{
|
||||
"id": "know-polite-form",
|
||||
"question": "не могли бы вы объяснить, что такое vpn?",
|
||||
"query": "virtual private network",
|
||||
"want_titles": ["Virtual private network"]
|
||||
},
|
||||
{
|
||||
"id": "know-dont-know",
|
||||
"question": "как зовут моего соседа снизу?",
|
||||
"query": "name of my downstairs neighbour",
|
||||
"want_titles": [],
|
||||
"expect_miss": true,
|
||||
"note": "Unanswerable by design. Retrieval SHOULD find nothing useful. Counted as a hit only when nothing relevant comes back."
|
||||
},
|
||||
{
|
||||
"id": "know-water-per-day",
|
||||
"question": "сколько воды в день надо пить?",
|
||||
"query": "human daily water requirement drinking",
|
||||
"want_titles": ["Drinking water", "Water", "Dehydration", "Hydration"]
|
||||
},
|
||||
{
|
||||
"id": "know-thunder-delay",
|
||||
"question": "почему гром слышно позже молнии?",
|
||||
"query": "thunder speed of sound lightning",
|
||||
"want_titles": ["Thunder", "Lightning"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package kiwix
|
||||
|
||||
// This scores retrieval alone: no LLM. For each general-knowledge question we
|
||||
// hand-write English keywords and ask whether the article that would answer it
|
||||
// comes back in the top N hits. If this score is low, reading Wikipedia cannot
|
||||
// help the model no matter how good the prompt is.
|
||||
//
|
||||
// The unanswerable case (know-dont-know) is not scored. Whether the junk it
|
||||
// returns is "nothing useful" is a human judgement, so the report just prints
|
||||
// the titles and leaves the score to the 8 answerable cases.
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed knowledge_v1.json
|
||||
var knowledgeFixtureJSON []byte
|
||||
|
||||
// EvalCase — one question with hand-written keywords.
|
||||
type EvalCase struct {
|
||||
ID string `json:"id"`
|
||||
Question string `json:"question"`
|
||||
Query string `json:"query"`
|
||||
WantTitles []string `json:"want_titles"`
|
||||
ExpectMiss bool `json:"expect_miss"`
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
Name string `json:"name"`
|
||||
Book string `json:"book"`
|
||||
Cases []EvalCase `json:"cases"`
|
||||
}
|
||||
|
||||
// Outcome — what one case retrieved.
|
||||
type Outcome struct {
|
||||
Case EvalCase
|
||||
Titles []string // titles of the top N hits, in rank order
|
||||
Rank int // 1-based rank of the first wanted title, 0 if none
|
||||
Err error
|
||||
}
|
||||
|
||||
// Hit is true when a wanted title came back.
|
||||
func (o Outcome) Hit() bool { return o.Rank > 0 }
|
||||
|
||||
// Report — the score plus per-case detail.
|
||||
type Report struct {
|
||||
Name string
|
||||
Book string
|
||||
TopN int
|
||||
Scored int // answerable cases
|
||||
Hits int
|
||||
Errors int
|
||||
Outcomes []Outcome
|
||||
}
|
||||
|
||||
// Accuracy over the answerable cases.
|
||||
func (r Report) Accuracy() float64 {
|
||||
if r.Scored == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.Hits) / float64(r.Scored)
|
||||
}
|
||||
|
||||
// RunRetrievalEval searches for every fixture case.
|
||||
func RunRetrievalEval(ctx context.Context, c *Client, topN int) (Report, error) {
|
||||
var f fixture
|
||||
if err := json.Unmarshal(knowledgeFixtureJSON, &f); err != nil {
|
||||
return Report{}, err
|
||||
}
|
||||
rep := Report{Name: f.Name, Book: f.Book, TopN: topN}
|
||||
for _, cs := range f.Cases {
|
||||
res, err := c.Search(ctx, cs.Query, f.Book, topN)
|
||||
o := Outcome{Case: cs, Err: err}
|
||||
if err != nil {
|
||||
rep.Errors++
|
||||
}
|
||||
for i, hit := range res {
|
||||
o.Titles = append(o.Titles, hit.Title)
|
||||
if o.Rank == 0 && matches(cs.WantTitles, hit.Title) {
|
||||
o.Rank = i + 1
|
||||
}
|
||||
}
|
||||
if !cs.ExpectMiss {
|
||||
rep.Scored++
|
||||
if o.Hit() {
|
||||
rep.Hits++
|
||||
}
|
||||
}
|
||||
rep.Outcomes = append(rep.Outcomes, o)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
func matches(want []string, title string) bool {
|
||||
for _, w := range want {
|
||||
if strings.EqualFold(strings.TrimSpace(title), w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// String — the headline number.
|
||||
func (r Report) String() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s: %d/%d answerable questions retrieve a wanted article in top %d (%.1f%%), %d errors\n",
|
||||
r.Name, r.Hits, r.Scored, r.TopN, 100*r.Accuracy(), r.Errors)
|
||||
fmt.Fprintf(&b, " book: %s\n", r.Book)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Detail — per case: what was asked, what was searched, what came back.
|
||||
func (r Report) Detail() string {
|
||||
var b strings.Builder
|
||||
for _, o := range r.Outcomes {
|
||||
mark := "MISS"
|
||||
switch {
|
||||
case o.Case.ExpectMiss:
|
||||
mark = "n/a "
|
||||
case o.Hit():
|
||||
mark = fmt.Sprintf("hit@%d", o.Rank)
|
||||
}
|
||||
fmt.Fprintf(&b, " %-6s %-20s q=%q\n", mark, o.Case.ID, o.Case.Query)
|
||||
if o.Err != nil {
|
||||
fmt.Fprintf(&b, " error: %v\n", o.Err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " got: %s\n", strings.Join(o.Titles, " | "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package kiwix
|
||||
|
||||
// Turning a Russian question into an English Kiwix search.
|
||||
//
|
||||
// Kiwix ranks by keyword, not by meaning. "why is the sky blue" returns a TV
|
||||
// episode; "Rayleigh scattering sky blue" returns the right article. So the
|
||||
// model's job here is NOT translation — it is naming the English article the
|
||||
// answer lives in.
|
||||
//
|
||||
// The output space is a handful of words, so it is worth locking down hard: a
|
||||
// GBNF grammar for the shape, a tiny token cap, and a cleanup pass that throws
|
||||
// away anything odd rather than handing junk to Kiwix.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// Completer — the LLM seam, so tests can fake it. *llm.Client satisfies it.
|
||||
type Completer interface {
|
||||
Complete(ctx context.Context, r llm.Req) (string, error)
|
||||
}
|
||||
|
||||
// queryGrammar — one JSON object holding 1..6 keyword words. Latin letters,
|
||||
// digits and hyphens only, so the model physically cannot answer the question
|
||||
// or reply in Russian.
|
||||
//
|
||||
// Why the JSON wrapper: this model always thinks out loud and this llama-server
|
||||
// build ignores the thinking switch (see ROUTING-EVAL-31-07-2026.md). A bare
|
||||
// word-list grammar just captured the reasoning — every case came back as
|
||||
// "Let me analyze this request carefully". Demanding JSON, like routeGrammar and
|
||||
// responseGrammar already do, gives the reasoning nowhere to go.
|
||||
const queryGrammar = `
|
||||
root ::= "{" ws "\"query\"" ws ":" ws "\"" word (" " word){0,5} "\"" ws "}"
|
||||
word ::= [A-Za-z0-9] [A-Za-z0-9-]{0,23}
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
// rewriteSystem — asks for search keywords, not an answer and not a translation.
|
||||
const rewriteSystem = `You turn a question into a search query for English Wikipedia.
|
||||
|
||||
Rules:
|
||||
- Output ONLY English search keywords. Never an answer, never an explanation.
|
||||
- Do NOT translate the sentence. Name the thing the answer is about.
|
||||
- The output must be a noun phrase, like a Wikipedia article title.
|
||||
- Never use question words: no why, how, what, when, which, "how much",
|
||||
"how long", "how to", "vs", "reason", "difference".
|
||||
- 2 to 4 words.
|
||||
|
||||
Reply with JSON: {"query":"<keywords>"}
|
||||
|
||||
Good:
|
||||
"почему листья желтеют осенью?" -> {"query":"leaf senescence autumn"}
|
||||
"как работает микроволновка?" -> {"query":"microwave oven"}
|
||||
"не могли бы вы объяснить, что такое блокчейн?" -> {"query":"blockchain"}
|
||||
"сколько живут собаки?" -> {"query":"dog lifespan"}
|
||||
"как избавиться от комаров в квартире?" -> {"query":"mosquito control"}
|
||||
"чем чай отличается от кофе?" -> {"query":"tea"}
|
||||
|
||||
Only JSON, no explanation.`
|
||||
|
||||
// maxQueryTokens — the output is a few words plus the JSON wrapper. A tight cap
|
||||
// is the cheapest guard against the model rambling into an answer.
|
||||
const maxQueryTokens = 32
|
||||
|
||||
// Rewriter asks the resident model for English search keywords.
|
||||
type Rewriter struct{ c Completer }
|
||||
|
||||
func NewRewriter(c Completer) *Rewriter { return &Rewriter{c: c} }
|
||||
|
||||
// Rewrite returns English keywords for a question in any language.
|
||||
// It errors rather than returning something Kiwix should not see.
|
||||
func (r *Rewriter) Rewrite(ctx context.Context, question string) (string, error) {
|
||||
raw, err := r.c.Complete(ctx, llm.Req{
|
||||
System: rewriteSystem,
|
||||
User: strings.TrimSpace(question),
|
||||
Grammar: queryGrammar,
|
||||
MaxTokens: maxQueryTokens,
|
||||
RepeatPenalty: 1.15,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return CleanQuery(unwrapJSON(raw))
|
||||
}
|
||||
|
||||
// unwrapJSON pulls the query out of {"query":"..."}. If the reply is not that
|
||||
// shape it is returned as-is, and CleanQuery decides whether it is usable.
|
||||
func unwrapJSON(raw string) string {
|
||||
s := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(s, "{") {
|
||||
return s
|
||||
}
|
||||
var got struct{ Query string }
|
||||
if err := json.Unmarshal([]byte(s), &got); err != nil {
|
||||
return s
|
||||
}
|
||||
return got.Query
|
||||
}
|
||||
|
||||
// maxQueryWords matches the grammar's bound. Anything longer is prose.
|
||||
const maxQueryWords = 6
|
||||
|
||||
// CleanQuery checks and tidies whatever the model produced. The grammar makes
|
||||
// bad output unlikely, not impossible (a server without grammar support, a
|
||||
// different model), so this is the real gate in front of Kiwix.
|
||||
//
|
||||
// Exported so it can be tested without a model.
|
||||
func CleanQuery(raw string) (string, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
// Models like to wrap answers in quotes. Drop surrounding ones.
|
||||
s = strings.Trim(s, "\"'`")
|
||||
// Keep the first line only: everything after it is prose.
|
||||
if i := strings.IndexAny(s, "\r\n"); i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
// Keep letters, digits, spaces and hyphens; anything else becomes a space.
|
||||
var b strings.Builder
|
||||
for _, ru := range s {
|
||||
switch {
|
||||
case unicode.IsLetter(ru) || unicode.IsDigit(ru) || ru == '-':
|
||||
b.WriteRune(ru)
|
||||
default:
|
||||
b.WriteRune(' ')
|
||||
}
|
||||
}
|
||||
words := strings.Fields(b.String())
|
||||
if len(words) == 0 {
|
||||
return "", fmt.Errorf("kiwix rewrite: empty query")
|
||||
}
|
||||
if len(words) > maxQueryWords {
|
||||
return "", fmt.Errorf("kiwix rewrite: %d words, want at most %d (looks like prose)", len(words), maxQueryWords)
|
||||
}
|
||||
words = dropStopWords(words)
|
||||
out := strings.Join(words, " ")
|
||||
// The ZIMs are English. Non-Latin letters mean the model ignored the ask.
|
||||
for _, ru := range out {
|
||||
if unicode.IsLetter(ru) && !isLatin(ru) {
|
||||
return "", fmt.Errorf("kiwix rewrite: query is not English: %q", out)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// stopWords — question words and filler. The model keeps writing question-shaped
|
||||
// queries ("why is the sky blue", "how much water to drink daily") no matter how
|
||||
// the prompt is worded, and Kiwix ranks on every word, so those words drag in
|
||||
// song and episode titles. Dropping them in code is not a style preference: a
|
||||
// keyword ranker gets nothing from them.
|
||||
var stopWords = map[string]bool{
|
||||
"a": true, "an": true, "the": true, "is": true, "are": true, "was": true,
|
||||
"do": true, "does": true, "did": true, "to": true, "of": true, "in": true,
|
||||
"on": true, "for": true, "and": true, "or": true, "my": true, "me": true,
|
||||
"i": true, "it": true, "its": true, "be": true, "been": true, "get": true,
|
||||
"how": true, "why": true, "what": true, "when": true, "which": true,
|
||||
"who": true, "where": true, "much": true, "many": true, "long": true,
|
||||
"vs": true, "than": true, "rid": true, "from": true, "about": true,
|
||||
}
|
||||
|
||||
// dropStopWords removes filler, but never everything: if the query was nothing
|
||||
// but stop words there is nothing better to search, so the original is kept and
|
||||
// the caller sees whatever Kiwix makes of it.
|
||||
func dropStopWords(words []string) []string {
|
||||
kept := make([]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
if !stopWords[strings.ToLower(w)] {
|
||||
kept = append(kept, w)
|
||||
}
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
return words
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
func isLatin(ru rune) bool {
|
||||
return (ru >= 'a' && ru <= 'z') || (ru >= 'A' && ru <= 'Z')
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package kiwix
|
||||
|
||||
// End-to-end score: Russian question -> model rewrite -> Kiwix search -> did a
|
||||
// wanted article come back. Same 9 cases as the retrieval eval, so the two
|
||||
// numbers are directly comparable: retrieval with hand-written keywords is the
|
||||
// ceiling, this is what the model actually reaches.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RewriteOutcome — one case, end to end.
|
||||
type RewriteOutcome struct {
|
||||
Outcome
|
||||
ModelQuery string // what the model asked for ("" if it failed)
|
||||
RewriteErr error
|
||||
}
|
||||
|
||||
// RunRewriteEval rewrites every question with the model, then searches.
|
||||
func RunRewriteEval(ctx context.Context, c *Client, rw *Rewriter, topN int) (RewriteReport, error) {
|
||||
var f fixture
|
||||
if err := json.Unmarshal(knowledgeFixtureJSON, &f); err != nil {
|
||||
return RewriteReport{}, err
|
||||
}
|
||||
rep := RewriteReport{Report: Report{Name: f.Name + "-rewrite", Book: f.Book, TopN: topN}}
|
||||
for _, cs := range f.Cases {
|
||||
out := RewriteOutcome{Outcome: Outcome{Case: cs}}
|
||||
q, err := rw.Rewrite(ctx, cs.Question)
|
||||
out.ModelQuery, out.RewriteErr = q, err
|
||||
if err == nil {
|
||||
res, serr := c.Search(ctx, q, f.Book, topN)
|
||||
out.Err = serr
|
||||
for i, hit := range res {
|
||||
out.Titles = append(out.Titles, hit.Title)
|
||||
if out.Rank == 0 && matches(cs.WantTitles, hit.Title) {
|
||||
out.Rank = i + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
if out.RewriteErr != nil || out.Err != nil {
|
||||
rep.Errors++
|
||||
}
|
||||
if !cs.ExpectMiss {
|
||||
rep.Scored++
|
||||
if out.Hit() {
|
||||
rep.Hits++
|
||||
}
|
||||
}
|
||||
rep.Cases = append(rep.Cases, out)
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// RewriteReport — the score plus per-case detail.
|
||||
type RewriteReport struct {
|
||||
Report
|
||||
Cases []RewriteOutcome
|
||||
}
|
||||
|
||||
// String — the headline number.
|
||||
func (r RewriteReport) String() string {
|
||||
return fmt.Sprintf("%s: %d/%d answerable questions retrieve a wanted article in top %d (%.1f%%), %d errors\n book: %s\n",
|
||||
r.Name, r.Hits, r.Scored, r.TopN, 100*r.Accuracy(), r.Errors, r.Book)
|
||||
}
|
||||
|
||||
// Detail — per case: hand-written query next to the model's, and what came back.
|
||||
// The point is seeing WHERE the model's phrasing differs, not just the score.
|
||||
func (r RewriteReport) Detail() string {
|
||||
var b strings.Builder
|
||||
for _, o := range r.Cases {
|
||||
mark := "MISS"
|
||||
switch {
|
||||
case o.Case.ExpectMiss:
|
||||
mark = "n/a "
|
||||
case o.Hit():
|
||||
mark = fmt.Sprintf("hit@%d", o.Rank)
|
||||
}
|
||||
fmt.Fprintf(&b, " %-6s %-20s\n", mark, o.Case.ID)
|
||||
fmt.Fprintf(&b, " asked: %s\n", o.Case.Question)
|
||||
fmt.Fprintf(&b, " hand: %q\n", o.Case.Query)
|
||||
fmt.Fprintf(&b, " model: %q\n", o.ModelQuery)
|
||||
if o.RewriteErr != nil {
|
||||
fmt.Fprintf(&b, " rewrite rejected: %v\n", o.RewriteErr)
|
||||
continue
|
||||
}
|
||||
if o.Err != nil {
|
||||
fmt.Fprintf(&b, " search error: %v\n", o.Err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, " got: %s\n", strings.Join(o.Titles, " | "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// Opt-in: needs a live Kiwix server AND a live llama-server.
|
||||
// MAVEN_KIWIX_URL=http://127.0.0.1:8034 MAVEN_LLM_URL=http://127.0.0.1:18099 \
|
||||
//
|
||||
// no_proxy=127.0.0.1,localhost go test -run RewriteEval -v ./internal/kiwix/
|
||||
func TestRewriteEval(t *testing.T) {
|
||||
kbase, lbase := os.Getenv("MAVEN_KIWIX_URL"), os.Getenv("MAVEN_LLM_URL")
|
||||
if kbase == "" || lbase == "" {
|
||||
t.Skip("set MAVEN_KIWIX_URL and MAVEN_LLM_URL to run the rewrite eval")
|
||||
}
|
||||
noProxyLoopback(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
rw := NewRewriter(llm.New(lbase, 3*time.Minute))
|
||||
rep, err := RunRewriteEval(ctx, New(kbase), rw, 5)
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
// No pass bar on purpose: the number is the finding.
|
||||
t.Log("\n" + rep.String() + rep.Detail())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package kiwix
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// Bad model output must never reach Kiwix. No model needed for this.
|
||||
func TestCleanQueryRejectsJunk(t *testing.T) {
|
||||
bad := []struct{ name, raw string }{
|
||||
{"empty", ""},
|
||||
{"blank", " \n "},
|
||||
{"russian came back", "почему небо синее"},
|
||||
{"mixed russian", "sky синее scattering"},
|
||||
{"full sentence", "The sky looks blue because of the scattering of sunlight by air molecules"},
|
||||
{"prose with quotes", `Sure! Here is a good search query: "Rayleigh scattering", which explains it.`},
|
||||
}
|
||||
for _, c := range bad {
|
||||
if got, err := CleanQuery(c.raw); err == nil {
|
||||
t.Errorf("%s: want rejection, got %q", c.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanQueryCleans(t *testing.T) {
|
||||
ok := []struct{ raw, want string }{
|
||||
{"Rayleigh scattering sky", "Rayleigh scattering sky"},
|
||||
{" boiled egg cooking \n", "boiled egg cooking"},
|
||||
{`"virtual private network"`, "virtual private network"},
|
||||
{"solid-state drive", "solid-state drive"},
|
||||
{"cat purr.", "cat purr"},
|
||||
{"hiccup\nAlso: hiccough", "hiccup"},
|
||||
// Question words are filler to a keyword ranker, so they go.
|
||||
{"why is the sky blue", "sky blue"},
|
||||
{"how much water to drink daily", "water drink daily"},
|
||||
{"SSD vs HDD comparison", "SSD HDD comparison"},
|
||||
// Nothing but filler: keep it rather than return nothing.
|
||||
{"what is it", "what is it"},
|
||||
}
|
||||
for _, c := range ok {
|
||||
got, err := CleanQuery(c.raw)
|
||||
if err != nil {
|
||||
t.Errorf("%q: %v", c.raw, err)
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%q -> %q, want %q", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCompleter struct {
|
||||
out string
|
||||
req llm.Req
|
||||
}
|
||||
|
||||
func (f *fakeCompleter) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||
f.req = r
|
||||
return f.out, nil
|
||||
}
|
||||
|
||||
func TestRewriteConstrainsTheCall(t *testing.T) {
|
||||
f := &fakeCompleter{out: `{"query":"Rayleigh scattering sky"}`}
|
||||
got, err := NewRewriter(f).Rewrite(context.Background(), "почему небо синее?")
|
||||
if err != nil {
|
||||
t.Fatalf("rewrite: %v", err)
|
||||
}
|
||||
if got != "Rayleigh scattering sky" {
|
||||
t.Errorf("query = %q", got)
|
||||
}
|
||||
if f.req.Grammar == "" {
|
||||
t.Error("no grammar sent")
|
||||
}
|
||||
if f.req.MaxTokens == 0 || f.req.MaxTokens > 32 {
|
||||
t.Errorf("max_tokens = %d, want a small cap", f.req.MaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewriteRejectsBadModelOutput(t *testing.T) {
|
||||
bad := []string{
|
||||
`{"query":"почему небо синее"}`, // never translated
|
||||
`{"query":""}`, // empty
|
||||
`{"query":"the sky is blue because sunlight is scattered by air"}`, // an answer
|
||||
// Note: a SHORT English prose fragment ("Let me analyze this request")
|
||||
// is under the word cap and cannot be caught here. The grammar is what
|
||||
// stops that one.
|
||||
}
|
||||
for _, out := range bad {
|
||||
f := &fakeCompleter{out: out}
|
||||
if got, err := NewRewriter(f).Rewrite(context.Background(), "почему небо синее?"); err == nil {
|
||||
t.Errorf("%s: want rejection, got %q", out, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A reply that is not the JSON shape but is still usable keywords should pass.
|
||||
func TestRewriteFallsBackToPlainText(t *testing.T) {
|
||||
f := &fakeCompleter{out: "Rayleigh scattering sky"}
|
||||
got, err := NewRewriter(f).Rewrite(context.Background(), "почему небо синее?")
|
||||
if err != nil || got != "Rayleigh scattering sky" {
|
||||
t.Errorf("got %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -286,6 +286,54 @@ func TestRemindersStillHonourSnooze(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------- digest eligibility ------------------------------
|
||||
|
||||
// A Sev2 care candidate (break) suppressed for a genuine restraint reason is
|
||||
// worth resurfacing later.
|
||||
func TestDigestEligibleSev2SuppressedByRestraint(t *testing.T) {
|
||||
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
|
||||
if !DigestEligible(Sev2, reason) {
|
||||
t.Errorf("sev2 blocked by %q: want digest-eligible", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Sev1 care candidate (water/meal) never digests — a biological timer
|
||||
// nudge is stale by the time anyone could resurface it, so it just drops.
|
||||
func TestDigestEligibleSev1NeverDigests(t *testing.T) {
|
||||
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
|
||||
if DigestEligible(Sev1, reason) {
|
||||
t.Errorf("sev1 blocked by %q: want drop, got digest-eligible", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ops severities are never blocked by these reasons in practice (Gate only
|
||||
// applies quiet_hours/calendar_busy/presence to care severities), but the
|
||||
// boundary itself must refuse to digest a high severity even if asked —
|
||||
// alarms bypass the gate and deliver now, unchanged, never delayed.
|
||||
func TestDigestEligibleNeverDigestsHighSeverity(t *testing.T) {
|
||||
for _, sev := range []Severity{Sev3, Sev4} {
|
||||
for _, reason := range []string{"quiet_hours", "calendar_busy", "presence"} {
|
||||
if DigestEligible(sev, reason) {
|
||||
t.Errorf("sev%d blocked by %q: high severity must never digest", sev, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cooldown and snooze are not "suppression" in the digest sense — cooldown
|
||||
// means it was already said recently, snooze means the user asked to not
|
||||
// hear about it. Neither should resurface later just because the severity
|
||||
// matches.
|
||||
func TestDigestEligibleExcludesCooldownAndSnooze(t *testing.T) {
|
||||
for _, reason := range []string{"cooldown", "snooze", "inert_no_data", "predicate", ""} {
|
||||
if DigestEligible(Sev2, reason) {
|
||||
t.Errorf("sev2 blocked by %q: should not be digest-eligible", reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GAP — the gate reads State.SnoozeUntil, but the Gatherer hard-codes it to nil
|
||||
// (internal/loop/gather.go:153), so snooze is dead in the running daemon: the
|
||||
// unit tests above pass while nothing can ever populate the map. This asserts
|
||||
|
||||
@@ -105,6 +105,36 @@ func Tick(s State, rules []Rule) *Candidate {
|
||||
return fire
|
||||
}
|
||||
|
||||
// DigestEligible decides digest-vs-drop for a care candidate the gate
|
||||
// suppressed this tick (see ExplainGate's blockedBy). Pure — no I/O, no
|
||||
// state, just the two facts that matter: why it was suppressed, and how
|
||||
// insistent it was.
|
||||
//
|
||||
// Only genuine RESTRAINT blocks are eligible at all — quiet_hours,
|
||||
// calendar_busy, presence(away). cooldown and snooze are not suppression in
|
||||
// this sense: cooldown means "you already heard this recently" (resurfacing
|
||||
// it later would be an actual repeat, not a rescue) and snooze is the user
|
||||
// explicitly saying "not this" (digesting it anyway would defeat the ask).
|
||||
// Ops severities (Sev3/4) never reach here — the gate never blocks them for
|
||||
// these reasons in the first place (see Gate), and even if a future rule
|
||||
// dropped Sev3+ into "care", digest still refuses them: alarms bypass the
|
||||
// gate on purpose and must never be silently delayed into a bundle.
|
||||
//
|
||||
// Within care (Sev1–2), the boundary is severity itself: Sev1 (water, meal —
|
||||
// biological timers with no "still relevant later" property; a water nudge
|
||||
// from 3 hours into quiet hours is just wrong by morning) drops. Sev2
|
||||
// (break — "you worked through a long stretch without a break while I
|
||||
// couldn't reach you") is information that stays true and useful after the
|
||||
// fact, so it digests.
|
||||
func DigestEligible(sev Severity, blockedBy string) bool {
|
||||
switch blockedBy {
|
||||
case "quiet_hours", "calendar_busy", "presence":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return sev == Sev2
|
||||
}
|
||||
|
||||
// ReminderDecision — a due reminder the daemon should deliver now.
|
||||
// NOT gated by the universal Gate (per spec: "wake me 7" fires in quiet hours;
|
||||
// that's the point). Snooze is the one part of restraint that still applies.
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
// Package memeval is background memory evaluation (Vikunja #248,
|
||||
// docs/plans/03-memory-evaluation.md).
|
||||
//
|
||||
// It lives beside internal/memory rather than inside it because
|
||||
// internal/store imports internal/memory for the vector-store backend, and an
|
||||
// evaluator has to read store.Fact / store.Note / store.Nudge — putting it in
|
||||
// internal/memory would close that import cycle.
|
||||
//
|
||||
// Every so often Maven reads back her own recent memory — facts, notes, the
|
||||
// nudges she sent — and asks the resident model what it notices: a habit that
|
||||
// stopped, a gap, something worth saying later. What comes back is written as
|
||||
// notes with source EvalNoteSource and nothing else happens. That restraint is
|
||||
// the design, not an unfinished edge:
|
||||
//
|
||||
// - She does not speak here. There is no dispatcher, no channel, no nudge.
|
||||
// An observation is a thought she wrote down; he reads it on /dash when he
|
||||
// wants to. "Not a nag, not autonomous" (CLAUDE.md) is easy to violate with
|
||||
// exactly this feature — an hourly loop with an LLM in it and permission to
|
||||
// talk is a machine for generating interruptions — so the loop has no way
|
||||
// to reach him at all. Turning observations into nudges is a separate
|
||||
// decision with a separate opt-in, and it is deliberately NOT in this file.
|
||||
// - She does not act. No reminder is created, no routine proposed, no fact
|
||||
// written. The model's suggested_action is recorded as text inside the note
|
||||
// and interpreted by nobody.
|
||||
// - She says nothing about an empty store. No memory ⇒ no LLM call ⇒ no
|
||||
// "observations" invented out of two facts. A 1.7B asked to find a pattern
|
||||
// will always find one; the defence is not asking.
|
||||
//
|
||||
// Everything the evaluator writes is attributable: source is EvalNoteSource, so
|
||||
// an inferred observation can never be mistaken for something he said, and the
|
||||
// whole batch is one SQL delete away if the output turns out to be noise.
|
||||
package memeval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/persona"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// EvalNoteSource — the source stamped on every note the evaluator writes.
|
||||
// Same infer:* convention as the rest of the derived facts.
|
||||
const EvalNoteSource = "infer:memory-eval"
|
||||
|
||||
// DefaultMinConfidence — an observation below this is dropped. The model is
|
||||
// asked for its own confidence and small models are badly calibrated, so this
|
||||
// is a coarse filter, not a probability: it exists to throw away the guesses
|
||||
// the model itself hedged on.
|
||||
const DefaultMinConfidence = 0.7
|
||||
|
||||
// DefaultMaxItems — how much recent memory goes into one evaluation, per
|
||||
// store. 30 facts + 30 notes + 30 nudges is a few thousand tokens of the 4096
|
||||
// context the resident Thinking model runs with, which leaves room for its
|
||||
// reasoning tokens. Raising this trades reasoning room for history.
|
||||
const DefaultMaxItems = 30
|
||||
|
||||
// MaxObservations — the model may return at most this many observations per
|
||||
// evaluation, enforced by the grammar. A cap here is also a noise cap: an
|
||||
// evaluation that "notices" ten things has noticed nothing.
|
||||
const MaxObservations = 3
|
||||
|
||||
// Observation — one thing the evaluator noticed.
|
||||
type Observation struct {
|
||||
Text string `json:"observation"`
|
||||
Conf float64 `json:"confidence"`
|
||||
// Action — what the model thinks should happen with this. Recorded, never
|
||||
// executed: see the file comment. One of "note", "propose", "notify".
|
||||
Action string `json:"suggested_action"`
|
||||
}
|
||||
|
||||
// Completer — the llama-server seam, same shape router.Completer uses so the
|
||||
// one resident model serves this caller too.
|
||||
type Completer interface {
|
||||
Complete(ctx context.Context, r llm.Req) (string, error)
|
||||
}
|
||||
|
||||
// Reader — the slice of the store an evaluation reads. Narrow on purpose: the
|
||||
// evaluator gets recent memory and nothing else. No entity graph, no presence,
|
||||
// no config facts.
|
||||
type Reader interface {
|
||||
RecentFacts(ctx context.Context, n int) ([]store.Fact, error)
|
||||
RecentNotes(ctx context.Context, n int) ([]store.Note, error)
|
||||
RecentNudges(ctx context.Context, n int) ([]store.Nudge, error)
|
||||
}
|
||||
|
||||
// NoteWriter — where observations land. Embeddings are passed nil: an
|
||||
// observation is written for a human to read on /dash, not to be recalled by
|
||||
// similarity. Feeding LLM-generated text back into the RAG pool it was
|
||||
// generated from is how a small model starts citing its own guesses as
|
||||
// evidence.
|
||||
type NoteWriter interface {
|
||||
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
|
||||
}
|
||||
|
||||
// Config — evaluator tuning. Zero values are replaced by the Default*
|
||||
// constants, so the zero Config is the sane one.
|
||||
type Config struct {
|
||||
MaxItems int
|
||||
MinConfidence float64
|
||||
|
||||
// ContextBlock — the shared persona block (internal/persona), re-evaluated
|
||||
// per call so the clock in it is current. Prepended to the system prompt so
|
||||
// observations come out in Maven's voice: feminine self-reference, informal
|
||||
// "ты". nil is allowed; the base prompt still carries the address rules.
|
||||
ContextBlock func() string
|
||||
}
|
||||
|
||||
// Evaluator reads recent memory and records what the model notices.
|
||||
type Evaluator struct {
|
||||
read Reader
|
||||
write NoteWriter
|
||||
llm Completer
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func NewEvaluator(r Reader, w NoteWriter, c Completer, cfg Config) *Evaluator {
|
||||
if cfg.MaxItems <= 0 {
|
||||
cfg.MaxItems = DefaultMaxItems
|
||||
}
|
||||
if cfg.MinConfidence <= 0 {
|
||||
cfg.MinConfidence = DefaultMinConfidence
|
||||
}
|
||||
return &Evaluator{read: r, write: w, llm: c, cfg: cfg}
|
||||
}
|
||||
|
||||
// evalGrammar — GBNF pinning the reply to a bounded JSON array of fixed-shape
|
||||
// observations. Same reasoning as the router's routeGrammar: the enum and the
|
||||
// length bound are what stop a small model from drifting into free text or
|
||||
// filling the token budget with one repeated field.
|
||||
const evalGrammar = `
|
||||
root ::= "[" ws (obs ("," ws obs){0,2})? ws "]"
|
||||
obs ::= "{" ws "\"observation\"" ws ":" ws text "," ws "\"confidence\"" ws ":" ws conf "," ws "\"suggested_action\"" ws ":" ws act ws "}"
|
||||
text ::= "\"" ([^"\\] | "\\" .){1,200} "\""
|
||||
conf ::= "0" "." [0-9]{1,2} | "1" ("." "0")?
|
||||
act ::= "\"note\"" | "\"propose\"" | "\"notify\""
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
// evalSystem — the evaluation prompt. Two things it insists on, both learned
|
||||
// from the phraser: state the observation as something she noticed rather than
|
||||
// an instruction, and say nothing when there is nothing (the model is given an
|
||||
// explicit way to return an empty array, because a model with no exit returns
|
||||
// filler).
|
||||
const evalSystem = `Ты просматриваешь свою собственную память: недавние факты, заметки и напоминания, которые ты отправляла.
|
||||
Найди то, что действительно заметно: привычка, которая прервалась; пробел в записях; повторяющаяся закономерность.
|
||||
|
||||
Правила:
|
||||
- Отвечай ТОЛЬКО массивом JSON. Каждый элемент: {"observation": "...", "confidence": 0.0-1.0, "suggested_action": "note"|"propose"|"notify"}.
|
||||
- observation — короткая фраза по-русски о том, что ты заметила. О себе — в женском роде ("я заметила"). К нему — на "ты".
|
||||
- Не выдумывай. Если в памяти нет ничего заметного, верни пустой массив [].
|
||||
- Не давай советов и не приказывай. Ты замечаешь, а не требуешь.
|
||||
- confidence — насколько ты уверена, что это настоящая закономерность, а не совпадение.
|
||||
- Максимум три наблюдения. Лучше одно точное, чем три общих.`
|
||||
|
||||
// Evaluate runs one evaluation and returns the observations it recorded.
|
||||
//
|
||||
// Returns (nil, nil) — not an error — for every ordinary "nothing to say"
|
||||
// outcome: an empty store, an empty array from the model, everything below the
|
||||
// confidence floor, or every observation already recorded earlier. Only a real
|
||||
// read/LLM/write failure is an error, and the caller (a background ticker) logs
|
||||
// it and waits for the next interval.
|
||||
func (e *Evaluator) Evaluate(ctx context.Context, now time.Time) ([]Observation, error) {
|
||||
snap, err := e.snapshot(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snap == "" {
|
||||
return nil, nil // nothing recorded ⇒ nothing to notice, and no LLM call
|
||||
}
|
||||
|
||||
raw, err := e.llm.Complete(ctx, llm.Req{
|
||||
System: persona.Prepend(e.cfg.ContextBlock, evalSystem),
|
||||
User: snap,
|
||||
Grammar: evalGrammar,
|
||||
MaxTokens: 512,
|
||||
RepeatPenalty: 1.1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory eval: complete: %w", err)
|
||||
}
|
||||
obs, err := parseObservations(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory eval: parse %q: %w", truncate(raw, 120), err)
|
||||
}
|
||||
|
||||
// Dedupe against what earlier evaluations already wrote. Without this an
|
||||
// hourly loop over a slowly-changing store writes the same sentence every
|
||||
// hour until /dash is nothing but the evaluator talking to itself.
|
||||
seen, err := e.recordedTexts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var kept []Observation
|
||||
for _, o := range obs {
|
||||
o.Text = strings.TrimSpace(o.Text)
|
||||
if o.Text == "" || o.Conf < e.cfg.MinConfidence {
|
||||
continue
|
||||
}
|
||||
norm := normalizeObservation(o.Text)
|
||||
if seen[norm] {
|
||||
continue
|
||||
}
|
||||
seen[norm] = true
|
||||
if _, err := e.write.WriteNote(ctx, now, formatNote(o), nil, EvalNoteSource); err != nil {
|
||||
return kept, fmt.Errorf("memory eval: write note: %w", err)
|
||||
}
|
||||
kept = append(kept, o)
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// formatNote — the stored text. The suggested action is kept as a visible
|
||||
// suffix rather than a column: it is the model's opinion about what to do next,
|
||||
// and the only consumer is a human reading /dash.
|
||||
func formatNote(o Observation) string {
|
||||
if o.Action == "" {
|
||||
return o.Text
|
||||
}
|
||||
return fmt.Sprintf("%s [%s]", o.Text, o.Action)
|
||||
}
|
||||
|
||||
// recordedTexts — the normalized text of every observation earlier evaluations
|
||||
// wrote, for dedupe. Reads a wider window than MaxItems because the point is to
|
||||
// remember saying it, not to summarize it.
|
||||
func (e *Evaluator) recordedTexts(ctx context.Context) (map[string]bool, error) {
|
||||
notes, err := e.read.RecentNotes(ctx, 200)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory eval: recent notes: %w", err)
|
||||
}
|
||||
seen := make(map[string]bool, len(notes))
|
||||
for _, n := range notes {
|
||||
if n.Source != EvalNoteSource {
|
||||
continue
|
||||
}
|
||||
text := n.Text
|
||||
// Strip the "[action]" suffix formatNote appended.
|
||||
if i := strings.LastIndex(text, " ["); i > 0 && strings.HasSuffix(text, "]") {
|
||||
text = text[:i]
|
||||
}
|
||||
seen[normalizeObservation(text)] = true
|
||||
}
|
||||
return seen, nil
|
||||
}
|
||||
|
||||
// normalizeObservation — dedupe key. Case- and whitespace-insensitive, which
|
||||
// catches the realistic repeat (the model re-emitting the same sentence with a
|
||||
// different comma) without pretending to do semantic dedupe.
|
||||
func normalizeObservation(s string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
|
||||
}
|
||||
|
||||
// snapshot renders recent memory as the user turn. Returns "" when there is
|
||||
// nothing in any store — the caller treats that as "do not ask the model".
|
||||
//
|
||||
// Notes written by earlier evaluations are excluded. Feeding her own
|
||||
// observations back in is how "я заметила, что ты не записывал еду" becomes
|
||||
// evidence for noticing it again, three evaluations deep.
|
||||
func (e *Evaluator) snapshot(ctx context.Context) (string, error) {
|
||||
n := e.cfg.MaxItems
|
||||
facts, err := e.read.RecentFacts(ctx, n)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("memory eval: recent facts: %w", err)
|
||||
}
|
||||
notes, err := e.read.RecentNotes(ctx, n)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("memory eval: recent notes: %w", err)
|
||||
}
|
||||
nudges, err := e.read.RecentNudges(ctx, n)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("memory eval: recent nudges: %w", err)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
wrote := false
|
||||
if len(facts) > 0 {
|
||||
b.WriteString("Факты:\n")
|
||||
for _, f := range facts {
|
||||
fmt.Fprintf(&b, "- %s %s=%s (%s)\n", f.Ts.Format("2006-01-02 15:04"), f.Key, truncate(f.Value, 80), f.Source)
|
||||
wrote = true
|
||||
}
|
||||
}
|
||||
own := 0
|
||||
var noteLines []string
|
||||
for _, nt := range notes {
|
||||
if nt.Source == EvalNoteSource {
|
||||
own++
|
||||
continue
|
||||
}
|
||||
noteLines = append(noteLines, fmt.Sprintf("- %s %s\n", nt.Ts.Format("2006-01-02 15:04"), truncate(nt.Text, 160)))
|
||||
}
|
||||
if len(noteLines) > 0 {
|
||||
b.WriteString("\nЗаметки:\n")
|
||||
for _, l := range noteLines {
|
||||
b.WriteString(l)
|
||||
wrote = true
|
||||
}
|
||||
}
|
||||
if len(nudges) > 0 {
|
||||
b.WriteString("\nНапоминания, которые ты отправляла:\n")
|
||||
for _, nd := range nudges {
|
||||
outcome := nd.Outcome
|
||||
if outcome == "" {
|
||||
outcome = "?"
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s %s → %s (%s)\n", nd.Ts.Format("2006-01-02 15:04"), nd.Rule, outcome, nd.Channel)
|
||||
wrote = true
|
||||
}
|
||||
}
|
||||
if !wrote {
|
||||
// Only her own past observations, or nothing at all. Either way there is
|
||||
// no new memory to evaluate.
|
||||
return "", nil
|
||||
}
|
||||
b.WriteString("\nЧто ты замечаешь?")
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// parseObservations reads the model's array. Tolerates the leading/trailing
|
||||
// prose a Thinking model sometimes emits around JSON by taking the outermost
|
||||
// bracketed span, the same tolerance the router's parser has.
|
||||
func parseObservations(raw string) ([]Observation, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if i := strings.Index(s, "["); i >= 0 {
|
||||
if j := strings.LastIndex(s, "]"); j > i {
|
||||
s = s[i : j+1]
|
||||
}
|
||||
}
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var obs []Observation
|
||||
if err := json.Unmarshal([]byte(s), &obs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(obs) > MaxObservations {
|
||||
// The grammar bounds this; a grammar-less server or a future prompt
|
||||
// change must not be able to flood /dash.
|
||||
sort.SliceStable(obs, func(i, j int) bool { return obs[i].Conf > obs[j].Conf })
|
||||
obs = obs[:MaxObservations]
|
||||
}
|
||||
return obs, nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package memeval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// fakeLLM — canned replies, one per call, and a record of what it was asked.
|
||||
type fakeLLM struct {
|
||||
replies []string
|
||||
calls []llm.Req
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||
f.calls = append(f.calls, r)
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
if len(f.replies) == 0 {
|
||||
return "[]", nil
|
||||
}
|
||||
out := f.replies[0]
|
||||
f.replies = f.replies[1:]
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func newTestStore(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
st, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "memeval_test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func refNow() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) }
|
||||
|
||||
// seedMemory writes a little of everything the evaluator reads.
|
||||
func seedMemory(t *testing.T, st *store.Store, ctx context.Context, now time.Time) {
|
||||
t.Helper()
|
||||
for i := 0; i < 3; i++ {
|
||||
ts := now.Add(-time.Duration(i+1) * 24 * time.Hour)
|
||||
if _, err := st.WriteFact(ctx, ts, store.KindSelf, "water_ml", "500", "tap:desk", 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("write fact: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := st.WriteNote(ctx, now.Add(-2*time.Hour), "купить корм для кота", nil, "tap:voice"); err != nil {
|
||||
t.Fatalf("write note: %v", err)
|
||||
}
|
||||
if _, err := st.RecordNudge(ctx, "water", "voice", "пора выпить воды", now.Add(-time.Hour)); err != nil {
|
||||
t.Fatalf("record nudge: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateEmptyStoreAsksNothing — the "shuts up when uncertain" floor. An
|
||||
// empty store must not even reach the model: a small model asked to find a
|
||||
// pattern in nothing will invent one.
|
||||
func TestEvaluateEmptyStoreAsksNothing(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
f := &fakeLLM{}
|
||||
ev := NewEvaluator(st, st, f, Config{})
|
||||
|
||||
obs, err := ev.Evaluate(ctx, refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
if len(obs) != 0 {
|
||||
t.Fatalf("observations on an empty store = %d, want 0", len(obs))
|
||||
}
|
||||
if len(f.calls) != 0 {
|
||||
t.Fatalf("LLM called %d times on an empty store, want 0", len(f.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateWritesHighConfidenceObservations — the happy path. Confident
|
||||
// observations are written as notes stamped infer:memory-eval, and the low
|
||||
// ones are dropped.
|
||||
func TestEvaluateWritesHighConfidenceObservations(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedMemory(t, st, ctx, now)
|
||||
|
||||
f := &fakeLLM{replies: []string{`[
|
||||
{"observation":"ты три дня не записывал еду","confidence":0.9,"suggested_action":"notify"},
|
||||
{"observation":"может быть, ты стал меньше пить воды","confidence":0.3,"suggested_action":"note"}
|
||||
]`}}
|
||||
ev := NewEvaluator(st, st, f, Config{})
|
||||
|
||||
obs, err := ev.Evaluate(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
if len(obs) != 1 {
|
||||
t.Fatalf("kept %d observations, want 1 (the 0.3 one is below the floor): %+v", len(obs), obs)
|
||||
}
|
||||
if obs[0].Text != "ты три дня не записывал еду" {
|
||||
t.Errorf("kept the wrong observation: %q", obs[0].Text)
|
||||
}
|
||||
|
||||
notes, err := st.RecentNotes(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
var written []store.Note
|
||||
for _, n := range notes {
|
||||
if n.Source == EvalNoteSource {
|
||||
written = append(written, n)
|
||||
}
|
||||
}
|
||||
if len(written) != 1 {
|
||||
t.Fatalf("notes with source %s = %d, want 1", EvalNoteSource, len(written))
|
||||
}
|
||||
if !strings.Contains(written[0].Text, "ты три дня не записывал еду") {
|
||||
t.Errorf("note text = %q", written[0].Text)
|
||||
}
|
||||
if !strings.Contains(written[0].Text, "[notify]") {
|
||||
t.Errorf("note text = %q, want the suggested action recorded", written[0].Text)
|
||||
}
|
||||
|
||||
// The prompt must carry the memory it is evaluating, and must not carry a
|
||||
// grammar-free request.
|
||||
if len(f.calls) != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", len(f.calls))
|
||||
}
|
||||
if !strings.Contains(f.calls[0].User, "water_ml") {
|
||||
t.Errorf("prompt does not mention the seeded facts:\n%s", f.calls[0].User)
|
||||
}
|
||||
if f.calls[0].Grammar == "" {
|
||||
t.Error("evaluation ran without a grammar")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateDeduplicatesAcrossRuns — the failure mode that would make this
|
||||
// feature unusable: an hourly loop over a store that barely changes writing the
|
||||
// same sentence every hour until /dash is nothing but the evaluator.
|
||||
func TestEvaluateDeduplicatesAcrossRuns(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedMemory(t, st, ctx, now)
|
||||
|
||||
same := `[{"observation":"ты три дня не записывал еду","confidence":0.9,"suggested_action":"note"}]`
|
||||
spaced := `[{"observation":"Ты три дня не записывал еду","confidence":0.95,"suggested_action":"note"}]`
|
||||
f := &fakeLLM{replies: []string{same, same, spaced}}
|
||||
ev := NewEvaluator(st, st, f, Config{})
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := ev.Evaluate(ctx, now.Add(time.Duration(i)*time.Hour)); err != nil {
|
||||
t.Fatalf("Evaluate %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
notes, err := st.RecentNotes(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
n := 0
|
||||
for _, nt := range notes {
|
||||
if nt.Source == EvalNoteSource {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("eval notes after three identical evaluations = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateIgnoresOwnNotes — her own observations must not become input.
|
||||
// Otherwise "я заметила X" is evidence for noticing X again, three evaluations
|
||||
// deep. With nothing but eval notes in the store there is no new memory, so the
|
||||
// model is not asked at all.
|
||||
func TestEvaluateIgnoresOwnNotes(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
if _, err := st.WriteNote(ctx, now.Add(-time.Hour), "я заметила, что ты мало пьёшь [note]", nil, EvalNoteSource); err != nil {
|
||||
t.Fatalf("write note: %v", err)
|
||||
}
|
||||
|
||||
f := &fakeLLM{}
|
||||
ev := NewEvaluator(st, st, f, Config{})
|
||||
obs, err := ev.Evaluate(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
if len(obs) != 0 || len(f.calls) != 0 {
|
||||
t.Fatalf("observations=%d llm calls=%d, want 0/0 — own notes are not memory to evaluate", len(obs), len(f.calls))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateEmptyArrayIsNotAnError — "nothing to say" is the expected outcome
|
||||
// most of the time and must not be logged as a failure.
|
||||
func TestEvaluateEmptyArrayIsNotAnError(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedMemory(t, st, ctx, now)
|
||||
|
||||
ev := NewEvaluator(st, st, &fakeLLM{replies: []string{"[]"}}, Config{})
|
||||
obs, err := ev.Evaluate(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("Evaluate: %v", err)
|
||||
}
|
||||
if len(obs) != 0 {
|
||||
t.Fatalf("observations = %d, want 0", len(obs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEvaluateLLMErrorIsReported — a broken llama-server is an error the caller
|
||||
// logs; it must not silently write anything.
|
||||
func TestEvaluateLLMErrorIsReported(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedMemory(t, st, ctx, now)
|
||||
|
||||
ev := NewEvaluator(st, st, &fakeLLM{err: errors.New("connection refused")}, Config{})
|
||||
if _, err := ev.Evaluate(ctx, now); err == nil {
|
||||
t.Fatal("want an error when the model is unreachable")
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
for _, n := range notes {
|
||||
if n.Source == EvalNoteSource {
|
||||
t.Fatalf("wrote a note despite an LLM failure: %q", n.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseObservationsTolerantAndBounded — Thinking models wrap JSON in prose,
|
||||
// and no reply may exceed MaxObservations even if the grammar is bypassed.
|
||||
func TestParseObservationsTolerantAndBounded(t *testing.T) {
|
||||
obs, err := parseObservations(`<think>hmm</think> вот: [{"observation":"a","confidence":0.9,"suggested_action":"note"}] всё`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(obs) != 1 || obs[0].Text != "a" {
|
||||
t.Fatalf("got %+v, want one observation 'a'", obs)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("[")
|
||||
for i := 0; i < MaxObservations+3; i++ {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
b.WriteString(`{"observation":"x","confidence":0.5,"suggested_action":"note"}`)
|
||||
}
|
||||
b.WriteString("]")
|
||||
obs, err = parseObservations(b.String())
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(obs) != MaxObservations {
|
||||
t.Fatalf("parsed %d observations, want the %d cap", len(obs), MaxObservations)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package morning
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The day plan (Vikunja #128).
|
||||
//
|
||||
// It lives here, with the morning routine engine, because it is the same
|
||||
// question asked at a different scale: the routine knows what is still missing
|
||||
// from a window, the plan knows what the whole day holds. A parallel system
|
||||
// would have to re-read the same facts and re-decide what "today" means.
|
||||
//
|
||||
// It is pure, like the rest of this package: the daemon reads the calendar,
|
||||
// the reminders and the checklist facts, and BuildPlan puts them in order.
|
||||
//
|
||||
// It is also NOT a nag. A plan she can recite when asked is the whole feature;
|
||||
// nothing here fires, schedules or announces. Unprompted delivery stays with
|
||||
// the existing morning nudge and the dispatcher's policy.
|
||||
|
||||
// PlanKind — where a plan line came from. It survives into the reply and the
|
||||
// web view because the three read differently: an event is something happening
|
||||
// to the owner, a reminder is something he asked for, a checklist item is
|
||||
// something he has not done yet.
|
||||
type PlanKind string
|
||||
|
||||
const (
|
||||
PlanEvent PlanKind = "event"
|
||||
PlanReminder PlanKind = "reminder"
|
||||
PlanChecklist PlanKind = "checklist"
|
||||
)
|
||||
|
||||
// PlanEntry — one timed thing on the day, as the daemon read it out of the
|
||||
// store. Text is rendered verbatim; the plan does not rephrase.
|
||||
//
|
||||
// Uncertain marks provenance below a full-confidence read — a work meeting
|
||||
// relayed off a phone notification (#126). It travels through to the reply so
|
||||
// she hedges instead of reciting a guess as fact.
|
||||
type PlanEntry struct {
|
||||
At time.Time
|
||||
Text string
|
||||
Kind PlanKind
|
||||
Uncertain bool
|
||||
}
|
||||
|
||||
// Plan — the ordered day. Date is the calendar day it describes.
|
||||
type Plan struct {
|
||||
Date time.Time
|
||||
Items []PlanEntry
|
||||
}
|
||||
|
||||
// BuildPlan orders everything known about the day Now falls on: calendar
|
||||
// events, pending reminders, and one line per morning routine that still has
|
||||
// unfinished items.
|
||||
//
|
||||
// Entries outside that calendar day are dropped — a plan for today that
|
||||
// includes tomorrow's meeting is wrong in a way that is worse than terse.
|
||||
// Ordering is by time, then by kind, then by text, so the same day always reads
|
||||
// the same way.
|
||||
func BuildPlan(routines []Routine, facts map[string]store.Fact, events, reminders []PlanEntry, now time.Time) Plan {
|
||||
y, m, d := now.Date()
|
||||
dayStart := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
dayEnd := dayStart.AddDate(0, 0, 1)
|
||||
|
||||
p := Plan{Date: dayStart}
|
||||
for _, group := range [][]PlanEntry{events, reminders} {
|
||||
for _, e := range group {
|
||||
at := e.At.In(now.Location())
|
||||
if at.Before(dayStart) || !at.Before(dayEnd) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(e.Text) == "" {
|
||||
continue
|
||||
}
|
||||
e.At = at
|
||||
p.Items = append(p.Items, e)
|
||||
}
|
||||
}
|
||||
p.Items = append(p.Items, checklistEntries(routines, facts, now)...)
|
||||
|
||||
sort.SliceStable(p.Items, func(i, j int) bool {
|
||||
a, b := p.Items[i], p.Items[j]
|
||||
if !a.At.Equal(b.At) {
|
||||
return a.At.Before(b.At)
|
||||
}
|
||||
if a.Kind != b.Kind {
|
||||
return a.Kind < b.Kind
|
||||
}
|
||||
return a.Text < b.Text
|
||||
})
|
||||
return p
|
||||
}
|
||||
|
||||
// checklistEntries renders one line per routine with work left in it, placed at
|
||||
// the routine's nudge time — where the checklist actually matters in the day.
|
||||
// A routine that does not apply today, is not in its window, or is already
|
||||
// complete contributes nothing: the plan says what is left, not what was done.
|
||||
func checklistEntries(routines []Routine, facts map[string]store.Fact, now time.Time) []PlanEntry {
|
||||
var out []PlanEntry
|
||||
for _, r := range routines {
|
||||
st := Evaluate(r, facts, now)
|
||||
if !st.Active || len(st.Missing) == 0 {
|
||||
continue
|
||||
}
|
||||
labels := make([]string, 0, len(st.Missing))
|
||||
for _, it := range st.Missing {
|
||||
label := it.Label
|
||||
if label == "" {
|
||||
label = it.Key
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
at := r.NudgeAt
|
||||
if at == "" {
|
||||
at = r.WindowEnd
|
||||
}
|
||||
when, ok := todayAt(at, now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, PlanEntry{
|
||||
At: when,
|
||||
Text: fmt.Sprintf("%s — осталось: %s", r.Name, strings.Join(labels, ", ")),
|
||||
Kind: PlanChecklist,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// After returns the part of the plan that has not happened yet — the answer to
|
||||
// "что дальше?" as opposed to "какие планы на сегодня?". The Date is kept, so an
|
||||
// empty result still knows which day it is empty for.
|
||||
func (p Plan) After(now time.Time) Plan {
|
||||
out := Plan{Date: p.Date}
|
||||
for _, it := range p.Items {
|
||||
if it.At.Before(now) {
|
||||
continue
|
||||
}
|
||||
out.Items = append(out.Items, it)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FormatRU renders the plan as maven says it. Feminine self-reference,
|
||||
// informal address, no pet names — and no exhortation: she reads the day back,
|
||||
// she does not tell him to get on with it.
|
||||
func (p Plan) FormatRU() string {
|
||||
if len(p.Items) == 0 {
|
||||
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
|
||||
}
|
||||
parts := make([]string, len(p.Items))
|
||||
for i, it := range p.Items {
|
||||
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
|
||||
if it.Uncertain {
|
||||
line = "похоже, " + line
|
||||
}
|
||||
parts[i] = line
|
||||
}
|
||||
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package morning
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// planAt is at() for the plan tests' day (2026-08-03, a Monday); the existing
|
||||
// at() in morning_test.go is pinned to a different date.
|
||||
func planAt(now time.Time, hh, mm int) time.Time {
|
||||
y, m, d := now.Date()
|
||||
return time.Date(y, m, d, hh, mm, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
func planFixture(t *testing.T) (Plan, time.Time) {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||
routines := []Routine{{
|
||||
Name: "утро",
|
||||
WindowStart: "07:00",
|
||||
WindowEnd: "11:00",
|
||||
NudgeAt: "10:30",
|
||||
Items: []Item{
|
||||
{Key: "water", FactKey: "drank_water", Label: "выпить воды"},
|
||||
{Key: "pills", FactKey: "took_pills", Label: "витамины"},
|
||||
},
|
||||
}}
|
||||
facts := map[string]store.Fact{
|
||||
"drank_water": {Ts: planAt(now, 8, 0)},
|
||||
}
|
||||
events := []PlanEntry{
|
||||
{At: planAt(now, 14, 0), Text: "Планёрка @ 14:00-14:30", Kind: PlanEvent, Uncertain: true},
|
||||
{At: planAt(now, 10, 0), Text: "Standup @ 10:00-10:30", Kind: PlanEvent},
|
||||
}
|
||||
reminders := []PlanEntry{
|
||||
{At: planAt(now, 18, 30), Text: "позвонить маме", Kind: PlanReminder},
|
||||
}
|
||||
return BuildPlan(routines, facts, events, reminders, now), now
|
||||
}
|
||||
|
||||
func TestBuildPlanOrdersTheDay(t *testing.T) {
|
||||
p, now := planFixture(t)
|
||||
|
||||
if !p.Date.Equal(planAt(now, 0, 0)) {
|
||||
t.Errorf("Date = %v, want midnight of now's day", p.Date)
|
||||
}
|
||||
want := []struct {
|
||||
hhmm string
|
||||
kind PlanKind
|
||||
}{
|
||||
{"10:00", PlanEvent},
|
||||
{"10:30", PlanChecklist},
|
||||
{"14:00", PlanEvent},
|
||||
{"18:30", PlanReminder},
|
||||
}
|
||||
if len(p.Items) != len(want) {
|
||||
t.Fatalf("got %d items, want %d: %+v", len(p.Items), len(want), p.Items)
|
||||
}
|
||||
for i, w := range want {
|
||||
if got := p.Items[i].At.Format("15:04"); got != w.hhmm {
|
||||
t.Errorf("item %d at %s, want %s", i, got, w.hhmm)
|
||||
}
|
||||
if p.Items[i].Kind != w.kind {
|
||||
t.Errorf("item %d kind %q, want %q", i, p.Items[i].Kind, w.kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The checklist line says what is LEFT. An item already evidenced today must
|
||||
// not be read back as outstanding.
|
||||
func TestBuildPlanChecklistListsOnlyMissing(t *testing.T) {
|
||||
p, _ := planFixture(t)
|
||||
var line string
|
||||
for _, it := range p.Items {
|
||||
if it.Kind == PlanChecklist {
|
||||
line = it.Text
|
||||
}
|
||||
}
|
||||
if line == "" {
|
||||
t.Fatal("no checklist line in the plan")
|
||||
}
|
||||
if !strings.Contains(line, "витамины") {
|
||||
t.Errorf("missing item not listed: %q", line)
|
||||
}
|
||||
if strings.Contains(line, "выпить воды") {
|
||||
t.Errorf("a completed item must not be read back as outstanding: %q", line)
|
||||
}
|
||||
if !strings.HasPrefix(line, "утро — осталось:") {
|
||||
t.Errorf("line = %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlanSkipsCompleteAndInactiveRoutines(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||
routines := []Routine{
|
||||
{
|
||||
Name: "утро", WindowStart: "07:00", WindowEnd: "11:00",
|
||||
Items: []Item{{Key: "water", FactKey: "drank_water", Label: "выпить воды"}},
|
||||
},
|
||||
{
|
||||
// Not in its window at 09:00.
|
||||
Name: "вечер", WindowStart: "20:00", WindowEnd: "23:00",
|
||||
Items: []Item{{Key: "walk", FactKey: "walked", Label: "прогулка"}},
|
||||
},
|
||||
}
|
||||
facts := map[string]store.Fact{"drank_water": {Ts: planAt(now, 8, 0)}}
|
||||
p := BuildPlan(routines, facts, nil, nil, now)
|
||||
if len(p.Items) != 0 {
|
||||
t.Fatalf("a complete routine and an out-of-window one must contribute nothing: %+v", p.Items)
|
||||
}
|
||||
if got, want := p.FormatRU(), "на 03.08.2026 ничего не запланировано."; got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A plan for today that includes tomorrow's meeting is worse than terse.
|
||||
func TestBuildPlanDropsOtherDays(t *testing.T) {
|
||||
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
|
||||
events := []PlanEntry{
|
||||
{At: planAt(now, 10, 0), Text: "today", Kind: PlanEvent},
|
||||
{At: planAt(now, 10, 0).AddDate(0, 0, 1), Text: "tomorrow", Kind: PlanEvent},
|
||||
{At: planAt(now, 10, 0).AddDate(0, 0, -1), Text: "yesterday", Kind: PlanEvent},
|
||||
{At: planAt(now, 12, 0), Text: " ", Kind: PlanEvent},
|
||||
}
|
||||
p := BuildPlan(nil, nil, events, nil, now)
|
||||
if len(p.Items) != 1 || p.Items[0].Text != "today" {
|
||||
t.Fatalf("got %+v", p.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanFormatRU(t *testing.T) {
|
||||
p, _ := planFixture(t)
|
||||
got := p.FormatRU()
|
||||
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
|
||||
"10:30 — утро — осталось: витамины; " +
|
||||
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
|
||||
"18:30 — позвонить маме."
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
// Persona: she recites, she does not exhort, and she never speaks of
|
||||
// herself in the masculine or addresses him formally.
|
||||
for _, bad := range []string{"рад ", "понял", "вы ", "ваш", "милый", "дорогой", "давай же", "не забудь"} {
|
||||
if strings.Contains(strings.ToLower(got), bad) {
|
||||
t.Errorf("plan text contains %q: %q", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanAfter(t *testing.T) {
|
||||
p, now := planFixture(t)
|
||||
rest := p.After(planAt(now, 11, 0))
|
||||
if len(rest.Items) != 2 {
|
||||
t.Fatalf("got %d items, want the 14:00 and 18:30 ones: %+v", len(rest.Items), rest.Items)
|
||||
}
|
||||
if !rest.Date.Equal(p.Date) {
|
||||
t.Error("After must keep the date, so an empty rest-of-day still knows which day")
|
||||
}
|
||||
empty := p.After(planAt(now, 23, 0))
|
||||
if len(empty.Items) != 0 {
|
||||
t.Errorf("got %+v", empty.Items)
|
||||
}
|
||||
if !strings.Contains(empty.FormatRU(), "ничего не запланировано") {
|
||||
t.Errorf("empty plan reads %q", empty.FormatRU())
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,19 @@ type ProposedRoutine struct {
|
||||
const MaxIntervalRatio = 1.5
|
||||
|
||||
// MinEvents is the minimum number of events needed to detect a pattern.
|
||||
// With N events, there are N-1 intervals; we need at least 2 intervals
|
||||
// before proposing anything.
|
||||
const MinEvents = 3
|
||||
// With N events there are N-1 intervals, so 4 events means 3 intervals.
|
||||
//
|
||||
// This used to be 3 (two intervals), which is not a pattern — it is a
|
||||
// coincidence with a mean. Two gaps of similar length happen constantly:
|
||||
// water the plants on a Sunday, again the next Sunday, once more the Sunday
|
||||
// after, and a detector with a ±50% band calls that a weekly routine. The
|
||||
// cost of being wrong is asymmetric now that the digestion tick scans all of
|
||||
// history on its own schedule and can announce what it finds: a false
|
||||
// positive is something the owner has to read and dismiss, and a dismissal
|
||||
// is permanent, so one bad guess burns that action+object pair forever.
|
||||
// Three intervals is the cheapest bar that makes a run distinguishable from
|
||||
// a repeat. False negatives cost one more observation and nothing else.
|
||||
const MinEvents = 4
|
||||
|
||||
// Detect checks whether a sequence of events for the same action+object
|
||||
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
||||
|
||||
@@ -6,12 +6,13 @@ import (
|
||||
)
|
||||
|
||||
func TestDetectEnoughEvents(t *testing.T) {
|
||||
// 3 events with 7-day intervals → stable pattern
|
||||
// MinEvents events with 7-day intervals → stable pattern
|
||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
events := []Event{
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(21 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
r, err := Detect(events)
|
||||
@@ -24,8 +25,8 @@ func TestDetectEnoughEvents(t *testing.T) {
|
||||
if r.Action != "refill" || r.Object != "cat_water" {
|
||||
t.Fatalf("action/object: want refill/cat_water, got %s/%s", r.Action, r.Object)
|
||||
}
|
||||
if r.N != 3 {
|
||||
t.Fatalf("want N=3, got %d", r.N)
|
||||
if r.N != 4 {
|
||||
t.Fatalf("want N=4, got %d", r.N)
|
||||
}
|
||||
// ~7 days
|
||||
if r.IntervalDays < 6.9 || r.IntervalDays > 7.1 {
|
||||
@@ -33,19 +34,28 @@ func TestDetectEnoughEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDetectNotEnoughEvents — two intervals are a coincidence, not a routine
|
||||
// (Vikunja #43). Three same-day-of-week events used to be enough to propose a
|
||||
// weekly reminder; MinEvents is 4 now so a repeat has to happen a third time
|
||||
// before Maven calls it a pattern.
|
||||
func TestDetectNotEnoughEvents(t *testing.T) {
|
||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
events := []Event{
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
r, err := Detect(events)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect: %v", err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Fatal("want nil for <3 events")
|
||||
for _, n := range []int{1, 2, MinEvents - 1} {
|
||||
events := make([]Event, n)
|
||||
for i := range events {
|
||||
events[i] = Event{
|
||||
Action: "refill",
|
||||
Object: "cat_water",
|
||||
Ts: base.Add(time.Duration(i) * 7 * 24 * time.Hour),
|
||||
}
|
||||
}
|
||||
r, err := Detect(events)
|
||||
if err != nil {
|
||||
t.Fatalf("Detect(%d events): %v", n, err)
|
||||
}
|
||||
if r != nil {
|
||||
t.Fatalf("Detect(%d events) proposed %+v, want nil below MinEvents=%d", n, r, MinEvents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,12 +78,13 @@ func TestDetectEmpty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDetectIrregularRejects(t *testing.T) {
|
||||
// 3 events but wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
|
||||
// wildly irregular: 1 day, then 14 days → ratio 14 > 1.5
|
||||
base := time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)
|
||||
events := []Event{
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(1 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(15 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(16 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
r, err := Detect(events)
|
||||
@@ -117,6 +128,7 @@ func TestDetectSameTimestamp(t *testing.T) {
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(7 * 24 * time.Hour)},
|
||||
{Action: "refill", Object: "cat_water", Ts: base.Add(14 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
r, err := Detect(events)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A reply that starts a JSON object and never finishes it is a failed
|
||||
// generation, not a reply. Before this, the parser returned ("", "") for these
|
||||
// and every caller then shipped the raw fragment as the thing Maven said. A
|
||||
// real run produced replies of literally "{" and "{\n \"".
|
||||
func TestParseResponseMoodRejectsUnfinishedJSON(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{`,
|
||||
"{\n \"",
|
||||
`{"response": "неполн`,
|
||||
`{"response": "текст", "mood":`,
|
||||
} {
|
||||
text, mood, err := parseResponseMood(raw)
|
||||
if !errors.Is(err, errBrokenJSON) {
|
||||
t.Errorf("parseResponseMood(%q) err = %v, want errBrokenJSON", raw, err)
|
||||
}
|
||||
if text != "" || mood != "" {
|
||||
t.Errorf("parseResponseMood(%q) leaked %q/%q — a fragment must never come back as a reply", raw, text, mood)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bare prose is still fine. Small models sometimes answer without any JSON at
|
||||
// all, and that reply is usable — so the new error must not swallow it.
|
||||
func TestParseResponseMoodAllowsBareProse(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"норм, а ты как?",
|
||||
"вот что я нашла: ключ у соседа",
|
||||
} {
|
||||
text, mood, err := parseResponseMood(raw)
|
||||
if err != nil {
|
||||
t.Errorf("parseResponseMood(%q) err = %v, want nil", raw, err)
|
||||
}
|
||||
// No JSON means no fields; the caller ships raw as-is.
|
||||
if text != "" || mood != "" {
|
||||
t.Errorf("parseResponseMood(%q) = %q/%q, want empty", raw, text, mood)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The measured failure: the model wants more than 400 characters and the old
|
||||
// grammar cut it off mid-word. Guards the bound against being tightened back.
|
||||
func TestGrammarStringBoundHasRoomForARealAnswer(t *testing.T) {
|
||||
if !strings.Contains(responseGrammar, "{0,1000}") {
|
||||
t.Error("grammar string bound is not 1000; 400 truncated real replies mid-word (see the comment on responseGrammar)")
|
||||
}
|
||||
}
|
||||
@@ -31,3 +31,35 @@ func TestAddressDeduplicates(t *testing.T) {
|
||||
t.Errorf("detail repeats the same break %d times: %q", n, res.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
// The fragments a real run produced. All of them scored as non-empty replies
|
||||
// before checkNonEmpty looked for letters.
|
||||
func TestNonEmptyNeedsLetters(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
"{",
|
||||
"{\n \"",
|
||||
"15-16",
|
||||
`{"`,
|
||||
" ",
|
||||
"...",
|
||||
} {
|
||||
if got := checkNonEmpty(body); got.Pass {
|
||||
t.Errorf("checkNonEmpty(%q) passed — that is not a reply", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And it must not start failing real replies. Latin counts as well as Cyrillic:
|
||||
// answers about ssd or vpn are legitimately part English.
|
||||
func TestNonEmptyAcceptsRealReplies(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
"норм, а ты как?",
|
||||
"вот что я нашла: ключ у соседа",
|
||||
"ssd быстрее hdd.",
|
||||
"9 минут.",
|
||||
} {
|
||||
if got := checkNonEmpty(body); !got.Pass {
|
||||
t.Errorf("checkNonEmpty(%q) failed: %s", body, got.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -623,11 +623,24 @@ const (
|
||||
CheckEllipsis = "ellipsis" // she finished the sentence
|
||||
)
|
||||
|
||||
// A reply needs words in it, not just characters. This check used to test for a
|
||||
// non-empty string, which scored 27/27 on a run where two replies were "{" and
|
||||
// "{\n \"" — punctuation passed as content. Braces, quotes, digits and spaces
|
||||
// are all empty in the only sense that matters.
|
||||
//
|
||||
// Digits alone fail too, and that is deliberate: the same run answered "сколько
|
||||
// варить яйцо вкрутую?" with "15-16". No unit, no words, and it is also the
|
||||
// wrong number. Whatever that is, it is not something she said.
|
||||
func checkNonEmpty(body string) Result {
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return Result{CheckNonEmpty, false, "empty reply"}
|
||||
}
|
||||
return Result{CheckNonEmpty, true, ""}
|
||||
for _, r := range body {
|
||||
if unicode.IsLetter(r) {
|
||||
return Result{CheckNonEmpty, true, ""}
|
||||
}
|
||||
}
|
||||
return Result{CheckNonEmpty, false, fmt.Sprintf("no letters in the reply %q — punctuation or digits only", strings.TrimSpace(body))}
|
||||
}
|
||||
|
||||
// checkEllipsis — a reply ending in "…" or "..." is a generation that ran out of
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// TestTemplateNudges scores the hand-written Russian templates on the same
|
||||
// fixture the model is scored on. No model, no network — it runs in milliseconds.
|
||||
//
|
||||
// The bar is every case, not most of them: the templates are hand-written, so a
|
||||
// failure is a bug in one line of Russian, not model variance.
|
||||
func TestTemplateNudges(t *testing.T) {
|
||||
f, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
// Fixed seed: the score must not depend on which variant came up.
|
||||
nt, err := phraser.NewNudgeTemplates(rand.NewSource(20260731))
|
||||
if err != nil {
|
||||
t.Fatalf("NewNudgeTemplates: %v", err)
|
||||
}
|
||||
rep, err := Score(context.Background(), "ru templates", nt, f)
|
||||
if err != nil {
|
||||
t.Fatalf("Score: %v", err)
|
||||
}
|
||||
t.Log("\n" + rep.String())
|
||||
t.Log("\n" + rep.Messages())
|
||||
if rep.Passed != rep.Total {
|
||||
t.Errorf("templates scored %d/%d, want every case:\n%s",
|
||||
rep.Passed, rep.Total, rep.Failures())
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplateNudgesEverySeed — one seed passing could be luck. Every variant of
|
||||
// every rule has to pass every check, so sweep seeds until each has been used.
|
||||
func TestTemplateNudgesEverySeed(t *testing.T) {
|
||||
f, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
for seed := int64(0); seed < 60; seed++ {
|
||||
nt, err := phraser.NewNudgeTemplates(rand.NewSource(seed))
|
||||
if err != nil {
|
||||
t.Fatalf("NewNudgeTemplates: %v", err)
|
||||
}
|
||||
rep, err := Score(context.Background(), "ru templates", nt, f)
|
||||
if err != nil {
|
||||
t.Fatalf("Score: %v", err)
|
||||
}
|
||||
if rep.Passed != rep.Total {
|
||||
t.Errorf("seed %d: %d/%d\n%s", seed, rep.Passed, rep.Total, rep.Failures())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ func newGrammarSpy(t *testing.T) *grammarSpy {
|
||||
}
|
||||
|
||||
// callAllPhrasingPaths hits every path that expects the JSON contract.
|
||||
// LLMNudges must be set on the phraser under test: nudges come from templates
|
||||
// by default and never reach the model at all.
|
||||
func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
@@ -58,7 +60,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
||||
t.Fatal("responseGrammar is empty")
|
||||
}
|
||||
spy := newGrammarSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
||||
|
||||
callAllPhrasingPaths(t, p)
|
||||
|
||||
@@ -74,7 +76,7 @@ func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) {
|
||||
|
||||
func TestNoGrammarConfigDisablesIt(t *testing.T) {
|
||||
spy := newGrammarSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true})
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true})
|
||||
|
||||
callAllPhrasingPaths(t, p)
|
||||
|
||||
@@ -97,7 +99,10 @@ func TestGrammarStringRuleIsNotASCIIOnly(t *testing.T) {
|
||||
// Russian body with an escaped quote inside, hand-built to test the contract.
|
||||
func TestGrammarShapedJSONParses(t *testing.T) {
|
||||
raw := `{"response": "он сказал \"привет\" и ушёл.\nвот так.", "mood": "confused"}`
|
||||
text, mood := parseResponseMood(raw)
|
||||
text, mood, err := parseResponseMood(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("grammar-shaped JSON did not parse: %v", err)
|
||||
}
|
||||
if want := "он сказал \"привет\" и ушёл.\nвот так."; text != want {
|
||||
t.Errorf("response = %q, want %q", text, want)
|
||||
}
|
||||
|
||||
+123
-26
@@ -31,6 +31,10 @@ type LLMPhraser struct {
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
|
||||
// tmpl — the hand-written Russian nudges. Default path for nudges; see
|
||||
// Config.LLMNudges. nil only if the template file failed to load.
|
||||
tmpl *NudgeTemplates
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -46,6 +50,19 @@ type Config struct {
|
||||
// nil ⇒ no block, the prompts stand alone.
|
||||
ContextBlock func() string
|
||||
|
||||
// LLMNudges puts the model back in charge of nudge wording.
|
||||
//
|
||||
// Off by default, and that is a deliberate deprecation of LLM-phrased
|
||||
// nudges: hand-written templates (nudges_ru_v1.json) word every nudge now.
|
||||
// A nudge has nothing to be creative about, and measured over many runs the
|
||||
// 0.8B broke the persona (formal "вы", plural imperatives, masculine
|
||||
// self-reference) and invented facts and units. Templates score 15/15 on the
|
||||
// nudge fixture, the model 11-13/15.
|
||||
//
|
||||
// The LLM path is kept, not deleted: flip this on to get it back. Chat,
|
||||
// query and reminder phrasing are untouched and still go through the model.
|
||||
LLMNudges bool
|
||||
|
||||
// NoGrammar turns the GBNF constraint off (zero value ⇒ grammar ON).
|
||||
// The escape hatch exists because the target resident model — the
|
||||
// locally CPT'd Qwen3-1.7B — does not exist yet: if its chat template
|
||||
@@ -71,6 +88,7 @@ func NewLLMPhraser(ctx context.Context, cfg Config) (*LLMPhraser, error) {
|
||||
cfg: cfg,
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
cancel: cancel,
|
||||
tmpl: loadNudgeTemplates(),
|
||||
}
|
||||
if err := p.start(ctx); err != nil {
|
||||
cancel()
|
||||
@@ -92,9 +110,22 @@ func NewLLMPhraserAt(baseURL string, cfg Config) *LLMPhraser {
|
||||
client: &http.Client{Timeout: cfg.Timeout},
|
||||
port: strings.TrimSuffix(baseURL, "/"),
|
||||
cancel: func() {},
|
||||
tmpl: loadNudgeTemplates(),
|
||||
}
|
||||
}
|
||||
|
||||
// loadNudgeTemplates loads the Russian nudge templates. A broken template file
|
||||
// must not stop the daemon booting, so a failure logs and leaves the LLM path
|
||||
// in charge of nudges.
|
||||
func loadNudgeTemplates() *NudgeTemplates {
|
||||
nt, err := NewNudgeTemplates(nil)
|
||||
if err != nil {
|
||||
log.Printf("phraser: nudge templates unavailable, using the model: %v", err)
|
||||
return nil
|
||||
}
|
||||
return nt
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) start(ctx context.Context) error {
|
||||
args := []string{
|
||||
"-m", p.cfg.ModelPath,
|
||||
@@ -185,12 +216,21 @@ func (p *LLMPhraser) Close() error {
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
||||
// Templates first — see Config.LLMNudges for why this is the default.
|
||||
if !p.cfg.LLMNudges && p.tmpl != nil {
|
||||
return p.tmpl.PhraseNudge(ctx, c)
|
||||
}
|
||||
prompt := buildNudgePrompt(c)
|
||||
resp, err := p.chat(ctx, prompt)
|
||||
if err != nil {
|
||||
return delivery.PhrasedNudge{}, err
|
||||
}
|
||||
body, mood := parseResponseMood(resp)
|
||||
body, mood, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
// Truncated JSON. Not a nudge — use the plain Russian fallback.
|
||||
log.Printf("phraser: PhraseNudge: %v", perr)
|
||||
body, mood = "", ""
|
||||
}
|
||||
if body == "" {
|
||||
// fallback: try old body/summary format
|
||||
body, _ = parsePhrase(resp)
|
||||
@@ -216,11 +256,16 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
// prompt is the single tested source in router.KnowledgePrompt.
|
||||
sys := persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt())
|
||||
prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||
if err != nil || resp == "" {
|
||||
return "не знаю.", nil
|
||||
}
|
||||
if text, _ := parseResponseMood(resp); text != "" {
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
return "не знаю.", nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
return resp, nil
|
||||
@@ -230,17 +275,22 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
}
|
||||
sys := p.querySystemPrompt()
|
||||
prompt := fmt.Sprintf(
|
||||
`The user asks: "%s". Your notes matching the query contain: "%s". Answer them naturally and briefly. If the notes don't answer the question, say so.`,
|
||||
`Он спрашивает: "%s". В твоих заметках по этому вопросу написано: "%s". Ответь ему коротко и своими словами. Если в заметках ответа нет — так и скажи.`,
|
||||
utterance, strings.Join(notes, `"; "`),
|
||||
)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 256)
|
||||
if err != nil {
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if err != nil || perr != nil {
|
||||
// Read the notes out rather than ship a broken fragment.
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
}
|
||||
if len(notes) == 1 {
|
||||
return "вот что я нашла: " + notes[0], nil
|
||||
}
|
||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
||||
}
|
||||
if text, _ := parseResponseMood(resp); text != "" {
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
return resp, nil
|
||||
@@ -263,12 +313,17 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
combined += utterance
|
||||
msgs = append(msgs, chatMsg{Role: "user", Content: strings.TrimSpace(combined)})
|
||||
|
||||
resp, err := p.chatWithMessages(ctx, msgs, 512)
|
||||
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
||||
if err != nil {
|
||||
log.Printf("phraser: PhraseChat: %v", err)
|
||||
return "поговорили.", nil
|
||||
}
|
||||
if text, _ := parseResponseMood(resp); text != "" {
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseChat: %v", perr)
|
||||
return "поговорили.", nil
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
// fallback: plain text without JSON
|
||||
@@ -281,11 +336,13 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
// chatSystemPrompt returns the system prompt for conversational chat.
|
||||
// Prepends the shared context block when the phraser has one.
|
||||
func chatSystemPrompt(block func() string) string {
|
||||
base := `You are maven, a self-hosted personal assistant. You're talking with your owner.
|
||||
Keep replies brief (1-3 sentences) and natural. You're helpful, curious, and a little warm.
|
||||
Respond in the user's language (Russian or English, matching their last message).
|
||||
Never roleplay emotions you don't have, but stay friendly.
|
||||
Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}. "response" is your reply text; "mood" reflects your tone (neutral/happy/thinking/tired/confused).`
|
||||
// No self-introduction here: the persona block prepended one line above
|
||||
// already says who she is, same as router.KnowledgePrompt.
|
||||
base := `Ты разговариваешь с хозяином. О себе говоришь в женском роде ("я подумала", "я рада"). Он мужчина: обращайся к нему на "ты", в мужском роде ("ты сказал", "ты забыл"). Никогда не "вы"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||||
|
||||
Отвечай по-русски, коротко: одна-три фразы, живым языком. Ты доброжелательная, тебе интересно, но чувства не изображай.
|
||||
|
||||
Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}. В "response" — твой ответ. В "mood" — ровно одно из: neutral, happy, thinking, tired, confused.`
|
||||
return persona.Prepend(block, base)
|
||||
}
|
||||
|
||||
@@ -349,7 +406,12 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
|
||||
if err != nil {
|
||||
return delivery.PhrasedReminder{}, err
|
||||
}
|
||||
body, mood := parseResponseMood(resp)
|
||||
body, mood, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
// Truncated JSON. Fall through to the reminder's own text.
|
||||
log.Printf("phraser: PhraseReminder: %v", perr)
|
||||
body, mood = "", ""
|
||||
}
|
||||
if body == "" {
|
||||
// fallback: try old body/summary format
|
||||
body, _ = parsePhrase(resp)
|
||||
@@ -394,10 +456,16 @@ type chatReq struct {
|
||||
// Russian, so an ASCII-only rule would make every reply empty. The escape rule
|
||||
// is what lets the model close a string it opened with a quote inside. Length
|
||||
// is bounded so a repetition loop truncates the field, not the JSON object.
|
||||
//
|
||||
// That bound was 400 and 400 was too tight. Measured against Qwen3.5-0.8B: on
|
||||
// "почему гром слышно позже молнии?" the reply came back exactly 400 characters
|
||||
// long, cut mid-word ("Нужно записать и,"), at every token cap from 256 to 2048.
|
||||
// So the token cap was never what stopped it — this rule was. 1000 characters is
|
||||
// roughly six Russian sentences, still short enough to stop a repetition loop.
|
||||
const responseGrammar = `
|
||||
root ::= "{" ws "\"response\"" ws ":" ws string ws "," ws "\"mood\"" ws ":" ws mood ws "}"
|
||||
mood ::= "\"neutral\"" | "\"happy\"" | "\"thinking\"" | "\"tired\"" | "\"confused\""
|
||||
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,400} "\""
|
||||
string ::= "\"" ([^"\\] | "\\" ["\\/bfnrt]){0,1000} "\""
|
||||
ws ::= [ \t\n]*
|
||||
`
|
||||
|
||||
@@ -483,12 +551,21 @@ func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, ma
|
||||
// Russian only, feminine self-reference, second person masculine (the owner is
|
||||
// a man). She talks TO him, informally, singular — never "вы", never "он".
|
||||
// One short sentence — the nudge is spoken aloud.
|
||||
//
|
||||
// What the ban on обращения forbids is pet names ("дорогой", "милый"), not his
|
||||
// name: "Ками, ноутбук на трёх процентах" is exactly how she talks, and the
|
||||
// unqualified word read as forbidding that too. Hence "ласковые обращения".
|
||||
//
|
||||
// The examples also never claim a physical act. She has no hands and no smart
|
||||
// plug — she can tell him the battery is at three percent, she cannot put the
|
||||
// laptop on charge. An example that says she did teaches the model to invent
|
||||
// actions Maven never took, which is worse than a missing nudge.
|
||||
const nudgeSystem = `Ты — Maven, домашняя ассистентка. О себе говоришь в женском роде ("я проверила", "я записала"). Владелец — мужчина, обращайся к нему в мужском роде ("ты пил", "ты забыл").
|
||||
Говоришь с ним на "ты", в единственном числе ("выпей", "встань"). Никогда не "вы"/"вас"/"ваш" и никогда "он"/"его" — ты говоришь ему, а не о нём.
|
||||
|
||||
Пиши ОДНО короткое напоминание по-русски: не больше 120 символов и не больше 16 слов. Только по делу.
|
||||
|
||||
Запрещено: обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов.
|
||||
Запрещено: ласковые обращения ("дорогой", "милый"), эмодзи, извинения ("прости", "извини"), вопросы о самочувствии, похвала, больше одного восклицательного знака, английские слова кроме имён сервисов.
|
||||
|
||||
Отвечай ТОЛЬКО одним объектом JSON с полями "response" и "mood".
|
||||
"response" — сам текст напоминания.
|
||||
@@ -496,7 +573,7 @@ const nudgeSystem = `Ты — Maven, домашняя ассистентка. О
|
||||
|
||||
Так выглядит правильный ответ по форме. Темы здесь посторонние — их в запросе не будет:
|
||||
{"response": "Стиральная машина закончила. Развесь бельё.", "mood": "neutral"}
|
||||
{"response": "Ноутбук на трёх процентах. Я поставила его на зарядку.", "mood": "confused"}
|
||||
{"response": "Ками, ноутбук на трёх процентах. Поставь его на зарядку.", "mood": "confused"}
|
||||
|
||||
Это примеры ФОРМЫ, а не темы. Пиши только про ту ситуацию, которую тебе дали в запросе. Не копируй примеры и никогда не пиши "..." в поле response.`
|
||||
|
||||
@@ -507,7 +584,9 @@ func (p *LLMPhraser) systemPrompt() string {
|
||||
// querySystemPrompt returns the system prompt for PhraseQuery (notes + general
|
||||
// knowledge). Prepends the configured persona when set.
|
||||
func (p *LLMPhraser) querySystemPrompt() string {
|
||||
base := "You are maven, a self-hosted personal assistant answering from your notes. Answer briefly and naturally in Russian starting with \"вот что я нашла: \". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
|
||||
// No self-introduction here: the persona block prepended one line above
|
||||
// already says who she is, same as router.KnowledgePrompt.
|
||||
base := "Ты отвечаешь ему по своим заметкам. Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде (\"нашла\", \"записала\"). Он мужчина, обращайся к нему на \"ты\". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
|
||||
return persona.Prepend(p.cfg.ContextBlock, base)
|
||||
}
|
||||
|
||||
@@ -629,21 +708,39 @@ type responseMood struct {
|
||||
Mood string `json:"mood"`
|
||||
}
|
||||
|
||||
// errBrokenJSON — the model started a JSON object and never finished it.
|
||||
// That is a failed generation, not a reply. Callers must use their fallback.
|
||||
var errBrokenJSON = fmt.Errorf("phraser: model output starts as JSON but does not parse")
|
||||
|
||||
// parseResponseMood extracts {"response","mood"} from LLM output, tolerant
|
||||
// of thinking tokens and extra text before/after the JSON block. Returns
|
||||
// ("", "") when no valid JSON is found.
|
||||
func parseResponseMood(raw string) (response, mood string) {
|
||||
// of thinking tokens and extra text before/after the JSON block.
|
||||
//
|
||||
// Three outcomes:
|
||||
// - parsed fine → the fields, nil error.
|
||||
// - output never looked like JSON → ("", "", nil). The caller may ship it
|
||||
// as-is; small models sometimes answer in bare prose and that is fine.
|
||||
// - output starts with "{" but does not parse → errBrokenJSON. The grammar
|
||||
// guarantees a valid *prefix*, so a generation that hits the token cap
|
||||
// mid-object comes back as a fragment like `{` or `{\n "`. Shipping that
|
||||
// as a reply is the bug this error exists to stop.
|
||||
func parseResponseMood(raw string) (response, mood string, err error) {
|
||||
cleaned := strings.TrimSpace(raw)
|
||||
start := strings.Index(cleaned, "{")
|
||||
end := strings.LastIndex(cleaned, "}")
|
||||
if start < 0 || end < 0 || end <= start {
|
||||
return "", ""
|
||||
if strings.HasPrefix(cleaned, "{") {
|
||||
return "", "", errBrokenJSON
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
var parsed responseMood
|
||||
if err := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); err != nil {
|
||||
return "", ""
|
||||
if e := json.Unmarshal([]byte(cleaned[start:end+1]), &parsed); e != nil {
|
||||
if strings.HasPrefix(cleaned, "{") {
|
||||
return "", "", errBrokenJSON
|
||||
}
|
||||
return "", "", nil
|
||||
}
|
||||
return parsed.Response, parsed.Mood
|
||||
return parsed.Response, parsed.Mood, nil
|
||||
}
|
||||
|
||||
func parsePhrase(raw string) (body, summary string) {
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package phraser
|
||||
|
||||
// Hand-written Russian nudges instead of generated ones.
|
||||
//
|
||||
// Why: on a nudge there is nothing to be creative about. Measured over many
|
||||
// runs, Qwen3.5-0.8B breaks the persona (formal "вы", plural imperatives,
|
||||
// masculine self-reference) and invents facts and units — it once told him to
|
||||
// boil an egg for "90-95 секунд". A nudge is five words of known content, so
|
||||
// wording it with a model buys nothing and risks the persona every time.
|
||||
//
|
||||
// The wording lives in nudges_ru_v1.json so it can be edited without touching
|
||||
// Go. This file only picks one and fills in the values.
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
)
|
||||
|
||||
//go:embed nudges_ru_v1.json
|
||||
var nudgeTemplateJSON []byte
|
||||
|
||||
// NudgeTemplateSchemaVersion — the version this code understands.
|
||||
const NudgeTemplateSchemaVersion = 1
|
||||
|
||||
type nudgeRuleSet struct {
|
||||
Mood string `json:"mood"`
|
||||
Variants []string `json:"variants"`
|
||||
}
|
||||
|
||||
type nudgeTemplateFile struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
Rules map[string]nudgeRuleSet `json:"rules"`
|
||||
}
|
||||
|
||||
// NudgeTemplates picks a hand-written Russian nudge for a candidate.
|
||||
//
|
||||
// Safe for concurrent use. Random, but never the same variant twice in a row
|
||||
// for the same rule — being nagged with identical words is what makes a nudge
|
||||
// easy to tune out.
|
||||
type NudgeTemplates struct {
|
||||
mu sync.Mutex
|
||||
rnd *rand.Rand
|
||||
last map[string]string // rule family -> the text used last time
|
||||
file nudgeTemplateFile
|
||||
}
|
||||
|
||||
// NewNudgeTemplates loads the embedded template file. Pass a source to make the
|
||||
// picking reproducible in tests; nil means seed from the clock.
|
||||
func NewNudgeTemplates(src rand.Source) (*NudgeTemplates, error) {
|
||||
var f nudgeTemplateFile
|
||||
if err := json.Unmarshal(nudgeTemplateJSON, &f); err != nil {
|
||||
return nil, fmt.Errorf("nudge templates: parse: %w", err)
|
||||
}
|
||||
if f.SchemaVersion != NudgeTemplateSchemaVersion {
|
||||
return nil, fmt.Errorf("nudge templates: schema_version %d, want %d",
|
||||
f.SchemaVersion, NudgeTemplateSchemaVersion)
|
||||
}
|
||||
if len(f.Rules) == 0 {
|
||||
return nil, fmt.Errorf("nudge templates: no rules")
|
||||
}
|
||||
if src == nil {
|
||||
src = rand.NewSource(time.Now().UnixNano())
|
||||
}
|
||||
return &NudgeTemplates{
|
||||
rnd: rand.New(src),
|
||||
last: map[string]string{},
|
||||
file: f,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PhraseNudge implements the nudge half of the Phraser interface, so the
|
||||
// templates can be scored by the same harness as the model.
|
||||
func (t *NudgeTemplates) PhraseNudge(_ context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
||||
body, mood := t.Nudge(c)
|
||||
return delivery.PhrasedNudge{Candidate: c, Body: body, Summary: body, Mood: mood}, nil
|
||||
}
|
||||
|
||||
// Nudge returns the text and the mood for one candidate. Never fails: if no
|
||||
// template fits it uses the plain per-rule fallback.
|
||||
func (t *NudgeTemplates) Nudge(c loop.Candidate) (body, mood string) {
|
||||
rule := c.Rule.Name
|
||||
family := t.family(rule)
|
||||
set, ok := t.file.Rules[family]
|
||||
if !ok {
|
||||
return fallbackNudge(c), "neutral"
|
||||
}
|
||||
vals := nudgeValues(c)
|
||||
|
||||
// Only variants whose placeholders all have a value.
|
||||
usable := make([]string, 0, len(set.Variants))
|
||||
for _, v := range set.Variants {
|
||||
if text, ok := fillTemplate(v, vals); ok {
|
||||
usable = append(usable, text)
|
||||
}
|
||||
}
|
||||
if len(usable) == 0 {
|
||||
return fallbackNudge(c), "neutral"
|
||||
}
|
||||
|
||||
mood = set.Mood
|
||||
if mood == "" {
|
||||
mood = "neutral"
|
||||
}
|
||||
return t.pick(family, usable), mood
|
||||
}
|
||||
|
||||
// pick chooses at random, skipping whatever this rule said last time.
|
||||
func (t *NudgeTemplates) pick(family string, usable []string) string {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
choices := usable
|
||||
if len(usable) > 1 {
|
||||
choices = make([]string, 0, len(usable))
|
||||
for _, v := range usable {
|
||||
if v != t.last[family] {
|
||||
choices = append(choices, v)
|
||||
}
|
||||
}
|
||||
if len(choices) == 0 { // every variant equals the last one
|
||||
choices = usable
|
||||
}
|
||||
}
|
||||
got := choices[t.rnd.Intn(len(choices))]
|
||||
t.last[family] = got
|
||||
return got
|
||||
}
|
||||
|
||||
// family maps a rule name to a block in the template file: an exact match
|
||||
// first, then the prefix of "routine:зарядка" / "morning:утро", then "default".
|
||||
func (t *NudgeTemplates) family(rule string) string {
|
||||
if _, ok := t.file.Rules[rule]; ok {
|
||||
return rule
|
||||
}
|
||||
if i := strings.IndexByte(rule, ':'); i > 0 {
|
||||
if _, ok := t.file.Rules[rule[:i]]; ok {
|
||||
return rule[:i]
|
||||
}
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
// placeholderRE — the {name} slots a template may use.
|
||||
var placeholderRE = regexp.MustCompile(`\{([a-z]+)\}`)
|
||||
|
||||
// nudgeValues collects what this candidate can fill in. A key missing here
|
||||
// means every template needing it is skipped, so nothing half-filled is ever
|
||||
// spoken.
|
||||
func nudgeValues(c loop.Candidate) map[string]string {
|
||||
vals := map[string]string{}
|
||||
rule := c.Rule.Name
|
||||
|
||||
// {since} — only at hour scale. Below an hour the phrase would be minutes,
|
||||
// and none of the templates read well with "сорок минут".
|
||||
if d, ok := c.State.Since(rule); ok && d >= time.Hour {
|
||||
if s := ruSinceWords(d); s != "" {
|
||||
vals["since"] = s
|
||||
}
|
||||
}
|
||||
// {service} — the aggregate fact's key carries the service name.
|
||||
if f, ok := c.State.Fact(rule); ok && f.Key != "" && f.Key != rule {
|
||||
vals["service"] = f.Key
|
||||
}
|
||||
// {what} — the Russian suffix of "routine:таблетки" / "morning:утро".
|
||||
if i := strings.IndexByte(rule, ':'); i > 0 && i+1 < len(rule) {
|
||||
vals["what"] = rule[i+1:]
|
||||
}
|
||||
return vals
|
||||
}
|
||||
|
||||
// fillTemplate substitutes the placeholders. Returns false when a value is
|
||||
// missing, so a raw "{since}" can never reach the text-to-speech voice.
|
||||
func fillTemplate(tmpl string, vals map[string]string) (string, bool) {
|
||||
missing := false
|
||||
out := placeholderRE.ReplaceAllStringFunc(tmpl, func(m string) string {
|
||||
name := m[1 : len(m)-1]
|
||||
v, ok := vals[name]
|
||||
if !ok || v == "" {
|
||||
missing = true
|
||||
return m
|
||||
}
|
||||
return v
|
||||
})
|
||||
if missing || strings.ContainsAny(out, "{}%") {
|
||||
return "", false
|
||||
}
|
||||
return capitalizeFirst(out), true
|
||||
}
|
||||
|
||||
// capitalizeFirst — a placeholder can start the sentence, and "полтора часа без
|
||||
// перерыва" should be spoken as a sentence, not a fragment.
|
||||
func capitalizeFirst(s string) string {
|
||||
for i, r := range s {
|
||||
return string(unicode.ToUpper(r)) + s[i+len(string(r)):]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// hourWords — hours spelled out. "3 ч" is fine on a screen and wrong in a
|
||||
// Russian voice, so the number goes out as words.
|
||||
var hourWords = []string{
|
||||
"ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь",
|
||||
"девять", "десять", "одиннадцать", "двенадцать", "тринадцать",
|
||||
"четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать",
|
||||
"девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три",
|
||||
}
|
||||
|
||||
// hourPlural — час / часа / часов by Russian counting rules.
|
||||
func hourPlural(h int) string {
|
||||
if h%100 >= 11 && h%100 <= 14 {
|
||||
return "часов"
|
||||
}
|
||||
switch h % 10 {
|
||||
case 1:
|
||||
return "час"
|
||||
case 2, 3, 4:
|
||||
return "часа"
|
||||
default:
|
||||
return "часов"
|
||||
}
|
||||
}
|
||||
|
||||
// ruSinceWords — "полтора часа", "два с половиной часа", "семь часов".
|
||||
// Empty string means "do not say it" (under an hour, or over a day).
|
||||
func ruSinceWords(d time.Duration) string {
|
||||
if d < time.Hour {
|
||||
return ""
|
||||
}
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
if m >= 45 {
|
||||
h++
|
||||
m = 0
|
||||
}
|
||||
if h >= len(hourWords) {
|
||||
return "больше суток"
|
||||
}
|
||||
if h == 1 {
|
||||
if m >= 15 {
|
||||
return "полтора часа"
|
||||
}
|
||||
return "час"
|
||||
}
|
||||
if m >= 15 {
|
||||
return hourWords[h] + " с половиной часа"
|
||||
}
|
||||
return hourWords[h] + " " + hourPlural(h)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// cand builds a candidate the way a tick would.
|
||||
func cand(rule string, sinceMin int, factKey string) loop.Candidate {
|
||||
now := time.Date(2026, 7, 31, 21, 40, 0, 0, time.UTC)
|
||||
st := loop.State{Now: now, Facts: map[string]store.Fact{}}
|
||||
if sinceMin > 0 || factKey != "" {
|
||||
key := rule
|
||||
if factKey != "" {
|
||||
key = factKey
|
||||
}
|
||||
st.Facts[rule] = store.Fact{Key: key, Ts: now.Add(-time.Duration(sinceMin) * time.Minute)}
|
||||
}
|
||||
return loop.Candidate{Rule: loop.Rule{Name: rule, Severity: loop.Sev1}, Severity: loop.Sev1, State: st}
|
||||
}
|
||||
|
||||
func newTestTemplates(t *testing.T, seed int64) *NudgeTemplates {
|
||||
t.Helper()
|
||||
nt, err := NewNudgeTemplates(rand.NewSource(seed))
|
||||
if err != nil {
|
||||
t.Fatalf("NewNudgeTemplates: %v", err)
|
||||
}
|
||||
return nt
|
||||
}
|
||||
|
||||
func TestNudgeTemplatesLoad(t *testing.T) {
|
||||
nt := newTestTemplates(t, 1)
|
||||
for _, rule := range []string{"water", "meal", "break", "service_down", "netdata_critical", "routine", "morning", "default"} {
|
||||
set, ok := nt.file.Rules[rule]
|
||||
if !ok {
|
||||
t.Errorf("no templates for %q", rule)
|
||||
continue
|
||||
}
|
||||
if len(set.Variants) < 5 {
|
||||
t.Errorf("%s: only %d variants", rule, len(set.Variants))
|
||||
}
|
||||
// Every rule needs one variant that needs no value, or a candidate
|
||||
// without context has nothing to say. routine and morning are exempt:
|
||||
// they always carry a name and must always say it.
|
||||
plain := 0
|
||||
seen := map[string]bool{}
|
||||
for _, v := range set.Variants {
|
||||
if !placeholderRE.MatchString(v) {
|
||||
plain++
|
||||
}
|
||||
if seen[v] {
|
||||
t.Errorf("%s: duplicate variant %q", rule, v)
|
||||
}
|
||||
seen[v] = true
|
||||
}
|
||||
if plain == 0 && rule != "routine" && rule != "morning" {
|
||||
t.Errorf("%s: every variant needs a placeholder value", rule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of the picker: never the same words twice in a row.
|
||||
func TestNudgeNoImmediateRepeat(t *testing.T) {
|
||||
nt := newTestTemplates(t, 7)
|
||||
prev := ""
|
||||
for i := 0; i < 200; i++ {
|
||||
body, _ := nt.Nudge(cand("water", 200, ""))
|
||||
if body == prev {
|
||||
t.Fatalf("repeat at %d: %q", i, body)
|
||||
}
|
||||
prev = body
|
||||
}
|
||||
}
|
||||
|
||||
// Same seed, same sequence — otherwise the fixture score would drift run to run.
|
||||
func TestNudgeDeterministicWithSeed(t *testing.T) {
|
||||
var runs [2][]string
|
||||
for r := range runs {
|
||||
nt := newTestTemplates(t, 42)
|
||||
for i := 0; i < 20; i++ {
|
||||
body, _ := nt.Nudge(cand("break", 100, ""))
|
||||
runs[r] = append(runs[r], body)
|
||||
}
|
||||
}
|
||||
for i := range runs[0] {
|
||||
if runs[0][i] != runs[1][i] {
|
||||
t.Fatalf("run %d differs: %q vs %q", i, runs[0][i], runs[1][i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A variant is only used when its value exists, and nothing half-filled ships.
|
||||
func TestNudgeNoLeftoverPlaceholders(t *testing.T) {
|
||||
nt := newTestTemplates(t, 3)
|
||||
cases := []loop.Candidate{
|
||||
cand("water", 0, ""), // no duration
|
||||
cand("water", 30, ""), // under an hour
|
||||
cand("water", 200, ""), // hours
|
||||
cand("service_down", 3, "vaultwarden"),
|
||||
cand("service_down", 3, ""), // no service name
|
||||
cand("routine:таблетки", 0, ""),
|
||||
cand("morning:утро", 0, ""),
|
||||
cand("unknown_rule", 0, ""),
|
||||
}
|
||||
for _, c := range cases {
|
||||
for i := 0; i < 40; i++ {
|
||||
body, mood := nt.Nudge(c)
|
||||
if body == "" {
|
||||
t.Fatalf("%s: empty body", c.Rule.Name)
|
||||
}
|
||||
if strings.ContainsAny(body, "{}%") {
|
||||
t.Fatalf("%s: unfilled template %q", c.Rule.Name, body)
|
||||
}
|
||||
if mood != "neutral" {
|
||||
t.Fatalf("%s: mood %q", c.Rule.Name, mood)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The routine name must actually land in the text.
|
||||
func TestNudgeSubstitutesWhat(t *testing.T) {
|
||||
nt := newTestTemplates(t, 11)
|
||||
for i := 0; i < 40; i++ {
|
||||
body, _ := nt.Nudge(cand("routine:таблетки", 0, ""))
|
||||
if !strings.Contains(strings.ToLower(body), "таблетки") {
|
||||
t.Fatalf("routine text lost the name: %q", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuSinceWords(t *testing.T) {
|
||||
cases := []struct {
|
||||
min int
|
||||
want string
|
||||
}{
|
||||
{30, ""},
|
||||
{60, "час"},
|
||||
{95, "полтора часа"},
|
||||
{150, "два с половиной часа"},
|
||||
{190, "три часа"},
|
||||
{240, "четыре часа"},
|
||||
{430, "семь часов"},
|
||||
{660, "одиннадцать часов"},
|
||||
{60 * 30, "больше суток"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := ruSinceWords(time.Duration(c.min) * time.Minute)
|
||||
if got != c.want {
|
||||
t.Errorf("%d min: got %q want %q", c.min, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Templates are the default: a nudge must not reach the model at all.
|
||||
func TestLLMPhraserUsesTemplatesByDefault(t *testing.T) {
|
||||
spy := newGrammarSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseNudge: %v", err)
|
||||
}
|
||||
if len(spy.grammars) != 0 {
|
||||
t.Errorf("nudge hit the model %d times, want 0", len(spy.grammars))
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(pn.Body), "вод") {
|
||||
t.Errorf("nudge is not the water template: %q", pn.Body)
|
||||
}
|
||||
}
|
||||
|
||||
// ...and the flag brings the model back.
|
||||
func TestLLMNudgesFlagRestoresTheModel(t *testing.T) {
|
||||
spy := newGrammarSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
||||
pn, err := p.PhraseNudge(context.Background(), cand("water", 200, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseNudge: %v", err)
|
||||
}
|
||||
if len(spy.grammars) != 1 {
|
||||
t.Fatalf("nudge hit the model %d times, want 1", len(spy.grammars))
|
||||
}
|
||||
if pn.Body != "ага" {
|
||||
t.Errorf("body = %q, want the model's reply", pn.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeTemplatesPhraseNudge(t *testing.T) {
|
||||
nt := newTestTemplates(t, 5)
|
||||
pn, err := nt.PhraseNudge(context.Background(), cand("water", 200, ""))
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseNudge: %v", err)
|
||||
}
|
||||
if pn.Body == "" || pn.Summary != pn.Body || pn.Mood != "neutral" {
|
||||
t.Fatalf("bad nudge: %+v", pn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "russian nudge templates v1",
|
||||
"notes": [
|
||||
"Hand-written Russian nudges. Edit the wording here, no Go changes needed.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives (выпейте), never он/его about him.",
|
||||
"One short sentence. No questions, no emoji, no pet names, no emotional support.",
|
||||
"Placeholders: {since} how long it has been (only used when it is at least an hour), {service} the service name, {what} the routine name. A variant whose placeholder has no value is skipped, so every rule needs at least one variant with no placeholder. The exception is routine and morning: those only exist for rules like routine:таблетки that always carry a name, and a routine nudge that drops the name is useless.",
|
||||
"mood must be one of: neutral, happy, thinking, tired, confused."
|
||||
],
|
||||
"rules": {
|
||||
"water": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
"Ты не пил воду {since} — выпей стакан.",
|
||||
"Пора выпить воды.",
|
||||
"Стакан воды не помешает.",
|
||||
"Воду ты не пил уже {since}.",
|
||||
"Напоминаю про воду.",
|
||||
"Сходи за водой, дела подождут.",
|
||||
"Сделай глоток воды, пока помнишь.",
|
||||
"Между делом выпей воды.",
|
||||
"Вода — простое дело: выпей стакан.",
|
||||
"Отвлекись на стакан воды."
|
||||
]
|
||||
},
|
||||
"meal": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
"Ты не ел {since} — поешь.",
|
||||
"Пора поесть, сделай перекус.",
|
||||
"Еда важнее ещё одного часа за столом.",
|
||||
"Без еды уже {since}, поешь.",
|
||||
"Напоминаю про еду — поешь.",
|
||||
"Возьми перерыв на обед.",
|
||||
"Сделай себе перекус, это пять минут.",
|
||||
"Поешь, потом вернёшься к работе.",
|
||||
"Поешь нормально, а не на ходу.",
|
||||
"Еды не было {since} — разогрей что-нибудь."
|
||||
]
|
||||
},
|
||||
"break": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
"Ты за столом {since} — встань и разомнись.",
|
||||
"Пора сделать перерыв.",
|
||||
"Встань на пять минут.",
|
||||
"{since} без перерыва — отойди от экрана.",
|
||||
"Напоминаю про перерыв.",
|
||||
"Разомни спину, потом продолжишь.",
|
||||
"Короткая пауза не сорвёт дела.",
|
||||
"Отойди от компьютера на минуту.",
|
||||
"Сидишь без перерыва {since}.",
|
||||
"Встань, пройдись, вернись."
|
||||
]
|
||||
},
|
||||
"service_down": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
"Сервис {service} не отвечает.",
|
||||
"{service} упал — сервис не отвечает.",
|
||||
"{service} не отвечает, сервис нужно поднимать.",
|
||||
"Сервис {service} недоступен.",
|
||||
"Проверь {service}: сервис не отвечает.",
|
||||
"Сервис перестал отвечать.",
|
||||
"Сервис {service} лежит, нужно смотреть.",
|
||||
"{service} не отвечает уже {since}.",
|
||||
"Мониторинг сообщает: {service} лежит.",
|
||||
"Сервис {service} не отвечает, посмотри логи."
|
||||
]
|
||||
},
|
||||
"netdata_critical": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
"Netdata: критический алярм, проверь диск.",
|
||||
"Критический алярм в netdata — посмотри диск.",
|
||||
"Netdata поднял тревогу по диску.",
|
||||
"Проверь диск: netdata ругается.",
|
||||
"Алярм от netdata, критический.",
|
||||
"Netdata: критический уровень, дело в диске.",
|
||||
"Диск требует внимания — критический алярм в netdata.",
|
||||
"Критический алярм: проверь место на диске.",
|
||||
"Netdata сообщает о критической проблеме с диском.",
|
||||
"Открой netdata: там критический алярм по диску."
|
||||
]
|
||||
},
|
||||
"routine": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
"По распорядку: {what}.",
|
||||
"Пора — {what}.",
|
||||
"Напоминаю: {what}.",
|
||||
"В списке на сейчас: {what}.",
|
||||
"{what} — сейчас самое время.",
|
||||
"Не пропусти: {what}.",
|
||||
"{what}: пора сделать.",
|
||||
"Сейчас по плану {what}.",
|
||||
"Твой распорядок: {what}.",
|
||||
"{what} — по распорядку сейчас."
|
||||
]
|
||||
},
|
||||
"morning": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
"{what} — пора начать день.",
|
||||
"{what}: пройди утренний список.",
|
||||
"Начни {what} со списка.",
|
||||
"{what}. Осталось пройти чеклист.",
|
||||
"Утренний список ещё не пройден: {what}.",
|
||||
"{what}: первый пункт списка за тобой.",
|
||||
"{what} идёт, а список стоит.",
|
||||
"{what}: не забудь про утренние дела.",
|
||||
"По утреннему чеклисту ещё есть дела: {what}.",
|
||||
"{what} — утренний список дел ещё ждёт."
|
||||
]
|
||||
},
|
||||
"default": {
|
||||
"mood": "neutral",
|
||||
"variants": [
|
||||
"Напоминаю: есть дело.",
|
||||
"Пора вернуться к отложенному делу.",
|
||||
"Одно дело ждёт тебя.",
|
||||
"Напоминаю про дело из списка.",
|
||||
"В списке осталось дело.",
|
||||
"Дело всё ещё не сделано."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
-4
@@ -4,16 +4,112 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// CalendarEventFormatter formats calendar events into a Russian reply string.
|
||||
type CalendarEventFormatter struct{}
|
||||
|
||||
// Format returns a Russian reply for the given calendar events on the given date.
|
||||
func (CalendarEventFormatter) Format(events []string, date time.Time) string {
|
||||
// CalendarEntry — one event to recite. Uncertain marks an event maven did not
|
||||
// read off a calendar server: the work calendar arrives as relayed phone
|
||||
// notifications (Vikunja #126), stored below full confidence, and she says so
|
||||
// rather than reciting a guess as fact.
|
||||
type CalendarEntry struct {
|
||||
Text string
|
||||
Uncertain bool
|
||||
}
|
||||
|
||||
// dayPlanWords — the tokens that ask for the day as a whole rather than for a
|
||||
// calendar listing. Whole words, not substrings: "планёрка" is a MEETING, and a
|
||||
// notification about one must not be mistaken for a request for the plan.
|
||||
var dayPlanWords = []string{
|
||||
"план", "плана", "плану", "плане", "планом",
|
||||
"планы", "планов", "планам", "планах",
|
||||
"расписание", "расписании", "распорядок", "распорядке",
|
||||
"plan", "plans", "schedule", "agenda",
|
||||
}
|
||||
|
||||
// otherDayWords — a day that is not today. The plan is built for the clock's
|
||||
// own day only, so an utterance naming another one belongs to the calendar
|
||||
// listing instead. Claiming it here would answer the wrong day, which is worse
|
||||
// than answering more tersely.
|
||||
var otherDayWords = []string{
|
||||
"завтра", "послезавтра", "вчера", "позавчера",
|
||||
"tomorrow", "yesterday",
|
||||
}
|
||||
|
||||
// IsDayPlanQuery reports whether an utterance asks for today's plan (Vikunja
|
||||
// #128) — "какие планы на сегодня?", "что у меня по плану?", "что дальше?".
|
||||
//
|
||||
// Deliberately narrow. The calendar listing already answers "что у меня
|
||||
// сегодня?" and a plan that hijacks every date-bearing question would bury the
|
||||
// events under checklist lines. Only a plan-shaped ask, and only about today.
|
||||
func IsDayPlanQuery(text string) bool {
|
||||
toks := planTokens(text)
|
||||
for _, t := range toks {
|
||||
for _, w := range otherDayWords {
|
||||
if t == w {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range toks {
|
||||
for _, w := range dayPlanWords {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// "что дальше?" / "what's next?" — the rest of the day, with no plan word
|
||||
// in it. Both tokens rather than adjacency, because "what's" splits into
|
||||
// "what" and "s" and because "и что потом дальше" is the same question.
|
||||
return (hasTok(toks, "что") && hasTok(toks, "дальше")) ||
|
||||
(hasTok(toks, "what") && hasTok(toks, "next"))
|
||||
}
|
||||
|
||||
func hasTok(toks []string, w string) bool {
|
||||
for _, t := range toks {
|
||||
if t == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// planTokens lowercases and splits on everything that is not a letter or a
|
||||
// digit, so "планы?" and "что-дальше" tokenize like the plain words do.
|
||||
func planTokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// Format returns a Russian reply for the given calendar events on the given
|
||||
// date. Every event is treated as certain — use FormatEntries when provenance
|
||||
// differs between them.
|
||||
func (f CalendarEventFormatter) Format(events []string, date time.Time) string {
|
||||
entries := make([]CalendarEntry, len(events))
|
||||
for i, e := range events {
|
||||
entries[i] = CalendarEntry{Text: e}
|
||||
}
|
||||
return f.FormatEntries(entries, date)
|
||||
}
|
||||
|
||||
// FormatEntries returns a Russian reply, hedging the entries maven is not sure
|
||||
// about. "похоже" and not "возможно": the notification did arrive, what is
|
||||
// uncertain is whether it describes the meeting correctly.
|
||||
func (CalendarEventFormatter) FormatEntries(entries []CalendarEntry, date time.Time) string {
|
||||
dateStr := date.Format("02.01.2006")
|
||||
if len(events) == 0 {
|
||||
if len(entries) == 0 {
|
||||
return fmt.Sprintf("на %s ничего нет.", dateStr)
|
||||
}
|
||||
return fmt.Sprintf("на %s: %s", dateStr, strings.Join(events, "; "))
|
||||
parts := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
if e.Uncertain {
|
||||
parts[i] = "похоже, " + e.Text
|
||||
continue
|
||||
}
|
||||
parts[i] = e.Text
|
||||
}
|
||||
return fmt.Sprintf("на %s: %s", dateStr, strings.Join(parts, "; "))
|
||||
}
|
||||
|
||||
@@ -24,3 +24,60 @@ func TestCalendarEventFormatter(t *testing.T) {
|
||||
t.Errorf("multiple: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventFormatterHedgesUncertainEntries(t *testing.T) {
|
||||
f := CalendarEventFormatter{}
|
||||
date := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// An event relayed off a phone notification is not a calendar read, and she
|
||||
// says so instead of reciting a guess as fact.
|
||||
got := f.FormatEntries([]CalendarEntry{
|
||||
{Text: "Standup @ 10:00-10:30"},
|
||||
{Text: "Планёрка @ 14:00-14:30", Uncertain: true},
|
||||
}, date)
|
||||
want := "на 06.07.2026: Standup @ 10:00-10:30; похоже, Планёрка @ 14:00-14:30"
|
||||
if got != want {
|
||||
t.Errorf("got %q\nwant %q", got, want)
|
||||
}
|
||||
|
||||
// Format is FormatEntries with everything certain.
|
||||
if got := f.FormatEntries(nil, date); got != "на 06.07.2026 ничего нет." {
|
||||
t.Errorf("empty: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDayPlanQuery(t *testing.T) {
|
||||
yes := []string{
|
||||
"какие планы на сегодня?",
|
||||
"что у меня по плану",
|
||||
"расскажи план",
|
||||
"мой распорядок на сегодня",
|
||||
"расписание?",
|
||||
"что дальше?",
|
||||
"what's next",
|
||||
"what is my plan today",
|
||||
}
|
||||
for _, s := range yes {
|
||||
if !IsDayPlanQuery(s) {
|
||||
t.Errorf("IsDayPlanQuery(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
|
||||
no := []string{
|
||||
// The calendar listing owns these.
|
||||
"что у меня сегодня?",
|
||||
"какие планы на завтра?",
|
||||
"план на послезавтра",
|
||||
"что было вчера",
|
||||
// "планёрка" is a meeting, not a request for the plan.
|
||||
"когда планёрка?",
|
||||
"запиши планёрку на 14:00",
|
||||
"какая погода?",
|
||||
"",
|
||||
}
|
||||
for _, s := range no {
|
||||
if IsDayPlanQuery(s) {
|
||||
t.Errorf("IsDayPlanQuery(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +118,39 @@ const routeRepeatPenalty = 1.15
|
||||
// Route return ok=false so the caller drops to the classifier cascade.
|
||||
const routeIntentUnknown = "unknown"
|
||||
|
||||
// llmFullConfidence / llmThinConfidence — Vikunja #359. Confidence used to be
|
||||
// hardcoded to 1.0 for every LLM decision, so the stage-3 gate in router.go
|
||||
// never had anything to bite on and the LLM path could never produce a
|
||||
// Clarify: on the 77-case RU fixture, 6/6 want_clarify cases were missed by
|
||||
// EVERY model in the 31-07-2026 bake-off (0.8B through 2B) — proof this was a
|
||||
// code bug, not a capability ceiling.
|
||||
//
|
||||
// The fix does not touch the prompt (routeSystem is under
|
||||
// llm/check_prompt_parity.py in the training workspace; changing its text
|
||||
// creates a parity break that has to be fixed there too — see Vikunja #362).
|
||||
// Instead it reads structural signal that is already free:
|
||||
// - a single-token utterance is thin evidence for anything a grammar
|
||||
// didn't already catch at stage 0 — "вода" and "бэкап" alone don't say
|
||||
// fact-vs-query or act-vs-report;
|
||||
// - a fact with no key, or an act that never resolves to an allowlisted fn
|
||||
// (checked in router.go, after slot-fill has had its say), is a decision
|
||||
// with a hole in the one slot that makes it actionable.
|
||||
//
|
||||
// A model self-reporting confidence in the JSON was considered and rejected:
|
||||
// a sub-2B is not calibrated (nothing stops it saying "confident" on exactly
|
||||
// the cases it gets wrong today), and true logprobs would need a response
|
||||
// field internal/llm.Client's Complete does not currently return — see
|
||||
// internal/llm/client.go.
|
||||
//
|
||||
// llmThinConfidence sits below config.DefaultRouterThreshold (0.55) so the
|
||||
// existing stage-3 gate in Router.Route treats it exactly like a low-scoring
|
||||
// classifier result — same lane, same daemon-side clarify machinery
|
||||
// (cmd/mavend/clarify.go), no new consumer to build.
|
||||
const (
|
||||
llmFullConfidence = 1.0
|
||||
llmThinConfidence = 0.3
|
||||
)
|
||||
|
||||
type routeAction struct {
|
||||
Intent string `json:"intent"`
|
||||
Key string `json:"key"`
|
||||
@@ -160,7 +193,15 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time)
|
||||
if a.Intent == routeIntentUnknown {
|
||||
return Decision{}, false, nil
|
||||
}
|
||||
d := Decision{Utterance: utterance, Stage: 1, Confidence: 1.0}
|
||||
d := Decision{Utterance: utterance, Stage: 1, Confidence: llmFullConfidence}
|
||||
// A single-token utterance is thin evidence: the model had nothing to
|
||||
// disambiguate on ("вода" is a fact-or-query coin flip, "бэкап" an
|
||||
// act-or-report one) and stage 0 would already have won on anything
|
||||
// that pattern-matches cleanly. Flag it now; router.go's stage-3 gate
|
||||
// (Router.Route) decides whether that trips Clarify.
|
||||
if len(strings.Fields(utterance)) <= 1 {
|
||||
d.Confidence = llmThinConfidence
|
||||
}
|
||||
switch Intent(a.Intent) {
|
||||
case IntentFact:
|
||||
d.Intent = IntentFact
|
||||
|
||||
@@ -258,4 +258,101 @@ func TestLLMFactGetsKeyFromParser(t *testing.T) {
|
||||
if !d.Slots.HasKey || d.Slots.Key != "water" {
|
||||
t.Fatalf("want key=water, got %+v", d.Slots)
|
||||
}
|
||||
if d.Clarify {
|
||||
t.Fatalf("the parser resolved the key, this must not clarify: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// --- confidence / stage-3 gate on the LLM path (Vikunja #359) -----------------
|
||||
|
||||
// A single-token utterance is thin evidence on its own — "вода" alone is a
|
||||
// fact/query coin flip. The gate must ask rather than guess confidently.
|
||||
func TestLLMRouterSingleTokenTripsClarify(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"query","text":"вода"}`)
|
||||
d, err := r.Route(context.Background(), "вода", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
if !d.Clarify {
|
||||
t.Fatalf("a bare single-token decision must clarify, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-word utterance with a clean answer must not be punished — the
|
||||
// whole point is not trading the confident cases away for clarify coverage.
|
||||
func TestLLMRouterMultiWordStaysConfident(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
||||
d, err := r.Route(context.Background(), "напомни позвонить маме", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
if d.Clarify {
|
||||
t.Fatalf("a clean multi-word decision must not clarify: %+v", d)
|
||||
}
|
||||
if d.Confidence != llmFullConfidence {
|
||||
t.Fatalf("want full confidence, got %v", d.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
// "бэкап" alone: the model guesses act, but nothing on the allowlist matches
|
||||
// "бэкап" as a verb — that must not fire a tool blind.
|
||||
func TestLLMRouterActWithoutFnTripsClarify(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"act","verb":"бэкап"}`)
|
||||
d, err := r.Route(context.Background(), "бэкап сделай пожалуйста расписание", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
if d.Slots.HasFn {
|
||||
t.Fatalf("test setup drifted: %q now resolves to an fn", d.Slots.Fn)
|
||||
}
|
||||
if !d.Clarify {
|
||||
t.Fatalf("an unresolved act must clarify rather than guess: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// An act that DOES resolve to an allowlisted fn must stay confident even
|
||||
// though its own verb is single-word-ish in spirit — guard against the fn
|
||||
// check firing on the happy path.
|
||||
func TestLLMRouterActWithFnStaysConfident(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"act","verb":"restart nginx"}`)
|
||||
d, err := r.Route(context.Background(), "слушай, restart nginx пожалуйста", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
if !d.Slots.HasFn {
|
||||
t.Fatalf("test setup drifted, want fn resolved: %+v", d.Slots)
|
||||
}
|
||||
if d.Clarify {
|
||||
t.Fatalf("a resolved act must not clarify: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// A fact where NEITHER the model NOR the deterministic parser can name a key
|
||||
// must clarify instead of silently writing under an empty/guessed key.
|
||||
func TestLLMRouterFactWithoutKeyTripsClarify(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"fact","value":"что-то"}`)
|
||||
d, err := r.Route(context.Background(), "у меня какая-то фигня случилась вот прямо только что", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
if d.Slots.HasKey {
|
||||
t.Fatalf("test setup drifted: parser now resolves a key for this utterance")
|
||||
}
|
||||
if !d.Clarify {
|
||||
t.Fatalf("a keyless fact must clarify rather than guess: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole point of #359: the classifier cascade cannot be traded away for
|
||||
// clarify coverage. A multi-word fact the parser CAN key must stay confident
|
||||
// through the full Router.Route path, not just the raw LLMRouter.
|
||||
func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) {
|
||||
r := newLLMTestRouter(t, `{"intent":"fact","text":"я выпил воду"}`)
|
||||
d, err := r.Route(context.Background(), "я выпил воду", refNow())
|
||||
if err != nil {
|
||||
t.Fatalf("route: %v", err)
|
||||
}
|
||||
if d.Clarify {
|
||||
t.Fatalf("a fact the parser could key must not clarify: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
|
||||
if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok {
|
||||
d.Utterance = utterance
|
||||
r.fillSlots(ctx, &d, now)
|
||||
r.gateLLMDecision(&d)
|
||||
return d, nil
|
||||
} else if err != nil {
|
||||
log.Printf("router: llm route fell back to classifier: %v", err)
|
||||
@@ -152,6 +153,35 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
|
||||
// Stage stays 1: it says who decided the route, and that was the LLM.
|
||||
}
|
||||
|
||||
// gateLLMDecision — stage 3 for the LLM path (Vikunja #359). This used to be
|
||||
// the classifier's job alone (see the threshold check at the bottom of
|
||||
// Route): the LLM branch returned straight from fillSlots and never touched
|
||||
// r.threshold at all, so a hardcoded Confidence: 1.0 in llmrouter.go could
|
||||
// never gate. Two more structural holes are checked here, after fillSlots
|
||||
// has had a chance to fill them from the deterministic parsers — checking
|
||||
// before fillSlots would flag e.g. every keyless fact the fact parser goes
|
||||
// on to resolve (TestLLMFactGetsKeyFromParser):
|
||||
// - a fact with no key even after the parser tried — nothing to write, or
|
||||
// worse, a confident write under the wrong key;
|
||||
// - an act that never resolved to an allowlisted fn — a confident guess
|
||||
// here means either silently doing nothing or, if the daemon is lax,
|
||||
// running something never on the allowlist. Don't guess; ask.
|
||||
//
|
||||
// Anything below threshold gets the exact same Clarify=true treatment the
|
||||
// classifier path already produces — same field, same daemon-side consumer
|
||||
// (cmd/mavend/clarify.go), nothing new to wire.
|
||||
func (r *Router) gateLLMDecision(d *Decision) {
|
||||
if d.Intent == IntentFact && !d.Slots.HasKey && d.Confidence > llmThinConfidence {
|
||||
d.Confidence = llmThinConfidence
|
||||
}
|
||||
if d.Intent == IntentAct && !d.Slots.HasFn && d.Confidence > llmThinConfidence {
|
||||
d.Confidence = llmThinConfidence
|
||||
}
|
||||
if d.Confidence < r.threshold {
|
||||
d.Clarify = true
|
||||
}
|
||||
}
|
||||
|
||||
// CorrectMisroute — the user corrected a bad classification. Appends a new
|
||||
// example for the corrected intent (append-only — grows the classifier, no
|
||||
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Digest entry statuses. pending = enqueued, waiting for a drain. drained =
|
||||
// spoken as part of a bundle. expired = the tick loop's expiry sweep found it
|
||||
// past its expires_ts before a drain happened — dropped, not delivered late.
|
||||
const (
|
||||
DigestPending = "pending"
|
||||
DigestDrained = "drained"
|
||||
DigestExpired = "expired"
|
||||
)
|
||||
|
||||
// DigestEntry — one gate-suppressed care candidate durably held for later
|
||||
// bundled delivery.
|
||||
type DigestEntry struct {
|
||||
ID int64
|
||||
Rule string
|
||||
Severity int
|
||||
Body string
|
||||
CreatedTs time.Time
|
||||
ExpiresTs time.Time
|
||||
}
|
||||
|
||||
// DigestBodyHash is the dedupe key for a digest entry: same rule, same
|
||||
// wording ⇒ the same suppressed nudge repeating across ticks, and he should
|
||||
// hear it once, not once per tick it kept getting suppressed.
|
||||
func DigestBodyHash(rule, body string) string {
|
||||
sum := sha256.Sum256([]byte(rule + "\x00" + body))
|
||||
return hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// EnqueueDigestEntry durably records a suppressed care candidate worth
|
||||
// resurfacing later. If a pending entry with the same rule+body already
|
||||
// exists, this is a no-op that returns the existing id and deduped=true —
|
||||
// the same suppressed nudge repeating across ticks must not pile up into
|
||||
// several copies of itself in the eventual bundle.
|
||||
func (s *Store) EnqueueDigestEntry(ctx context.Context, rule string, severity int, body string, now, expiresAt time.Time) (id int64, deduped bool, err error) {
|
||||
hash := DigestBodyHash(rule, body)
|
||||
var existing int64
|
||||
err = s.db.QueryRowContext(ctx,
|
||||
`SELECT id FROM digest_entries WHERE status = ? AND rule = ? AND body_hash = ? LIMIT 1`,
|
||||
DigestPending, rule, hash).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, true, nil
|
||||
}
|
||||
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO digest_entries (rule, severity, body, body_hash, status, created_ts, expires_ts)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
rule, severity, body, hash, DigestPending, now.UnixMilli(), expiresAt.UnixMilli())
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("enqueue digest entry: %w", err)
|
||||
}
|
||||
id, err = res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("enqueue digest entry: last insert id: %w", err)
|
||||
}
|
||||
return id, false, nil
|
||||
}
|
||||
|
||||
// PendingDigestEntries returns the live (not yet expired) pending entries,
|
||||
// oldest first — the order they were suppressed in, which is also the order
|
||||
// a bundled readout should mention them.
|
||||
func (s *Store) PendingDigestEntries(ctx context.Context, now time.Time) ([]DigestEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, rule, severity, body, created_ts, expires_ts
|
||||
FROM digest_entries WHERE status = ? AND expires_ts > ? ORDER BY created_ts ASC`,
|
||||
DigestPending, now.UnixMilli())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pending digest entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []DigestEntry
|
||||
for rows.Next() {
|
||||
var e DigestEntry
|
||||
var created, expires int64
|
||||
if err := rows.Scan(&e.ID, &e.Rule, &e.Severity, &e.Body, &created, &expires); err != nil {
|
||||
return nil, fmt.Errorf("pending digest entries: scan: %w", err)
|
||||
}
|
||||
e.CreatedTs = time.UnixMilli(created)
|
||||
e.ExpiresTs = time.UnixMilli(expires)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ExpireStaleDigestEntries marks pending entries whose expires_ts has passed
|
||||
// as expired — stale information (yesterday's battery warning) is noise, not
|
||||
// news, so it is dropped rather than delivered late. Called once per tick,
|
||||
// mirroring ReconcileStaleDeliveryAttempts's "sweep, don't guess" shape.
|
||||
// Returns the count expired, for logging.
|
||||
func (s *Store) ExpireStaleDigestEntries(ctx context.Context, now time.Time) (int, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE digest_entries SET status = ? WHERE status = ? AND expires_ts <= ?`,
|
||||
DigestExpired, DigestPending, now.UnixMilli())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expire stale digest entries: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expire stale digest entries: rows affected: %w", err)
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// DrainDigestEntries marks the given entries drained — they were folded into
|
||||
// a bundle that was successfully dispatched. Called only after a successful
|
||||
// send, same rule as the delivery outbox: a failed dispatch must not mark
|
||||
// entries drained, or the bundle is lost along with the failed send.
|
||||
func (s *Store) DrainDigestEntries(ctx context.Context, ids []int64, now time.Time) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("drain digest entries: begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
stmt, err := tx.PrepareContext(ctx,
|
||||
`UPDATE digest_entries SET status = ? WHERE id = ? AND status = ?`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("drain digest entries: prepare: %w", err)
|
||||
}
|
||||
defer stmt.Close()
|
||||
for _, id := range ids {
|
||||
if _, err := stmt.ExecContext(ctx, DigestDrained, id, DigestPending); err != nil {
|
||||
return fmt.Errorf("drain digest entry %d: %w", id, err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("drain digest entries: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestDigestEntryRoundTrips — a suppressed care candidate lands durably and
|
||||
// comes back out of PendingDigestEntries with its severity and body intact.
|
||||
func TestDigestEntryRoundTrips(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
id, deduped, err := s.EnqueueDigestEntry(ctx, "break", 2, "ты долго не отдыхала", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if deduped {
|
||||
t.Fatal("first enqueue must not report deduped")
|
||||
}
|
||||
if id == 0 {
|
||||
t.Fatal("want a nonzero id")
|
||||
}
|
||||
|
||||
entries, err := s.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != id {
|
||||
t.Fatalf("want 1 pending entry with id %d, got %+v", id, entries)
|
||||
}
|
||||
if entries[0].Rule != "break" || entries[0].Severity != 2 || entries[0].Body != "ты долго не отдыхала" {
|
||||
t.Fatalf("entry contents wrong: %+v", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEntrySurvivesRestart — durability is the whole point: a fresh
|
||||
// Store handle on the same file must see the same pending entry, exactly
|
||||
// like the delivery outbox's crash-recovery promise.
|
||||
func TestDigestEntrySurvivesRestart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
s1, err := Open(ctx, dir+"/m.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
id, _, err := s1.EnqueueDigestEntry(ctx, "break", 2, "перерыв", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
if err := s1.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
// simulated restart: a brand new Store handle on the same file.
|
||||
s2, err := Open(ctx, dir+"/m.db")
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
defer func() { _ = s2.Close() }()
|
||||
|
||||
entries, err := s2.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("pending after restart: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].ID != id {
|
||||
t.Fatalf("digest entry did not survive restart: %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEntryDedupesSameRuleAndBody — the same suppressed nudge
|
||||
// repeating across ticks (quiet hours holding for hours) must not pile up
|
||||
// into several copies of itself; he hears it once.
|
||||
func TestDigestEntryDedupesSameRuleAndBody(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
id1, deduped1, err := s.EnqueueDigestEntry(ctx, "break", 2, "перерыв нужен", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("first enqueue: %v", err)
|
||||
}
|
||||
if deduped1 {
|
||||
t.Fatal("first enqueue should not be deduped")
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
id2, deduped2, err := s.EnqueueDigestEntry(ctx, "break", 2, "перерыв нужен", now.Add(time.Minute), now.Add(25*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("repeat enqueue: %v", err)
|
||||
}
|
||||
if !deduped2 {
|
||||
t.Fatal("repeat enqueue of the same rule+body should report deduped")
|
||||
}
|
||||
if id2 != id1 {
|
||||
t.Fatalf("deduped enqueue should return the original id: want %d got %d", id1, id2)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := s.PendingDigestEntries(ctx, now)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("want exactly 1 pending entry after 3 enqueues of the same nudge, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEntryExpiresRatherThanDeliversLate — a stale entry (past its
|
||||
// expires_ts) must not surface in PendingDigestEntries, and the sweep should
|
||||
// mark it expired instead of leaving it around to be delivered late.
|
||||
func TestDigestEntryExpiresRatherThanDeliversLate(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
created := time.Now()
|
||||
expiresAt := created.Add(time.Hour)
|
||||
|
||||
id, _, err := s.EnqueueDigestEntry(ctx, "water", 1, "стакан воды", created, expiresAt)
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue: %v", err)
|
||||
}
|
||||
|
||||
afterExpiry := expiresAt.Add(time.Minute)
|
||||
|
||||
// even before the sweep runs, a stale entry must not be handed back as
|
||||
// pending — "not yet swept" must not mean "still deliverable".
|
||||
entries, err := s.PendingDigestEntries(ctx, afterExpiry)
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("stale entry must not be returned as pending, got %+v", entries)
|
||||
}
|
||||
|
||||
n, err := s.ExpireStaleDigestEntries(ctx, afterExpiry)
|
||||
if err != nil {
|
||||
t.Fatalf("expire sweep: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("want 1 entry expired, got %d", n)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM digest_entries WHERE id = ?`, id).Scan(&status); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if status != DigestExpired {
|
||||
t.Fatalf("status: want %q, got %q", DigestExpired, status)
|
||||
}
|
||||
|
||||
// idempotent: a second sweep finds nothing new.
|
||||
n2, err := s.ExpireStaleDigestEntries(ctx, afterExpiry.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("second sweep: %v", err)
|
||||
}
|
||||
if n2 != 0 {
|
||||
t.Fatalf("second sweep should find nothing, got %d", n2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDigestEntryDrainMarksDrainedNotDeleted — draining is bookkeeping, not
|
||||
// deletion: the row survives as an audit trail of what she actually said.
|
||||
func TestDigestEntryDrainMarksDrainedNotDeleted(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
|
||||
id1, _, err := s.EnqueueDigestEntry(ctx, "break", 2, "перерыв", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue 1: %v", err)
|
||||
}
|
||||
id2, _, err := s.EnqueueDigestEntry(ctx, "break2", 2, "другое", now, now.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("enqueue 2: %v", err)
|
||||
}
|
||||
|
||||
if err := s.DrainDigestEntries(ctx, []int64{id1, id2}, now.Add(time.Hour)); err != nil {
|
||||
t.Fatalf("drain: %v", err)
|
||||
}
|
||||
|
||||
entries, err := s.PendingDigestEntries(ctx, now.Add(time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("pending: %v", err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("drained entries must not still be pending, got %+v", entries)
|
||||
}
|
||||
|
||||
for _, id := range []int64{id1, id2} {
|
||||
var status string
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT status FROM digest_entries WHERE id = ?`, id).Scan(&status); err != nil {
|
||||
t.Fatalf("read back %d: %v", id, err)
|
||||
}
|
||||
if status != DigestDrained {
|
||||
t.Fatalf("entry %d status: want %q, got %q", id, DigestDrained, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,36 @@ func (s *Store) CreateEvent(ctx context.Context, factID int64, action, object st
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// EventPair identifies one action+object grouping in the events table — the
|
||||
// unit the pattern detector reasons about.
|
||||
type EventPair struct {
|
||||
Action string
|
||||
Object string
|
||||
}
|
||||
|
||||
// DistinctEventPairs returns every distinct action+object pair that has at
|
||||
// least one event, in no particular order. This is what lets the proactive
|
||||
// digestion tick run the pattern detector over everything accumulated so far
|
||||
// instead of only the pair touched by the utterance that just landed
|
||||
// (Vikunja #43) — the tick has no "current utterance," so it has to ask the
|
||||
// store what to look at.
|
||||
func (s *Store) DistinctEventPairs(ctx context.Context) ([]EventPair, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT DISTINCT action, object FROM events`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("distinct event pairs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []EventPair
|
||||
for rows.Next() {
|
||||
var p EventPair
|
||||
if err := rows.Scan(&p.Action, &p.Object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EventsFor returns all events matching action+object, ordered by ts ascending
|
||||
// (oldest first — the order the pattern detector needs for interval computation).
|
||||
func (s *Store) EventsFor(ctx context.Context, action, object string) ([]Event, error) {
|
||||
|
||||
+25
-5
@@ -6,7 +6,10 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
)
|
||||
|
||||
// WriteFact appends a fact row. confidence must be 1.0 for taps and (0,1) for
|
||||
@@ -79,17 +82,29 @@ func (s *Store) RecentFacts(ctx context.Context, n int) ([]Fact, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CalendarEvents returns caldav facts whose key date falls within [from, to).
|
||||
// CalendarEvents returns calendar facts whose key date falls within [from, to).
|
||||
// Calendar event keys have the format calendar_event_YYYYMMDD_<summary>.
|
||||
//
|
||||
// Every calendar source is included, not just the personal CalDAV poll: the work
|
||||
// calendar arrives as ambient:notif notifications (Vikunja #126) and belongs in
|
||||
// the same answer. The source stays on each Fact, along with its confidence, so
|
||||
// the caller can hedge a reading it did not get from a calendar server —
|
||||
// filtering by source here would have thrown that judgement away.
|
||||
func (s *Store) CalendarEvents(ctx context.Context, from, to time.Time) ([]Fact, error) {
|
||||
prefixFrom := fmt.Sprintf("calendar_event_%s", from.Format("20060102"))
|
||||
prefixTo := fmt.Sprintf("calendar_event_%s", to.Format("20060102"))
|
||||
prefixFrom := calendar.KeyPrefixForDay(from)
|
||||
prefixTo := calendar.KeyPrefixForDay(to)
|
||||
sources := calendar.Sources()
|
||||
args := make([]any, 0, len(sources)+2)
|
||||
for _, src := range sources {
|
||||
args = append(args, src)
|
||||
}
|
||||
args = append(args, prefixFrom, prefixTo)
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, ts, kind, key, value, source, confidence, voids_id
|
||||
FROM facts
|
||||
WHERE source = 'poll:caldav'
|
||||
WHERE source IN (`+placeholders(len(sources))+`)
|
||||
AND key >= ? AND key < ?
|
||||
ORDER BY key`, prefixFrom, prefixTo)
|
||||
ORDER BY key`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calendar events: %w", err)
|
||||
}
|
||||
@@ -254,3 +269,8 @@ func scanFact(r rowScanner) (Fact, error) {
|
||||
f.VoidsID = voids
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// placeholders renders n comma-separated SQL bind markers.
|
||||
func placeholders(n int) string {
|
||||
return strings.TrimSuffix(strings.Repeat("?,", n), ",")
|
||||
}
|
||||
|
||||
@@ -112,6 +112,25 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
DROP TABLE delivery_attempts;
|
||||
ALTER TABLE delivery_attempts_v12 RENAME TO delivery_attempts;
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_attempts_status ON delivery_attempts (status);`,
|
||||
|
||||
// #13 — durable digest outbox (Vikunja #281). A care nudge the restraint
|
||||
// gate suppresses (quiet hours / away / calendar-busy) is not necessarily
|
||||
// lost: if it's worth resurfacing, it lands here instead, and gets spoken
|
||||
// as one bundle at the next moment speaking is appropriate. body_hash
|
||||
// dedupes repeat suppressions of the "same" nudge; expires_ts bounds how
|
||||
// stale an entry may get before it's worthless and must be dropped rather
|
||||
// than delivered late.
|
||||
`CREATE TABLE IF NOT EXISTS digest_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
rule TEXT NOT NULL,
|
||||
severity INTEGER NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
body_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','drained','expired')),
|
||||
created_ts INTEGER NOT NULL,
|
||||
expires_ts INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_digest_entries_status ON digest_entries (status);`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
@@ -51,9 +51,10 @@ var (
|
||||
// keep finding the pattern, and every re-propose is refused here. Maven is not
|
||||
// a nag.
|
||||
//
|
||||
// TODO(vikunja#46): the detector currently only writes here from the voice
|
||||
// path. Once digestion runs the detector on its own tick, that tick should
|
||||
// call this too, so a pattern gets noticed even with nobody at the mic.
|
||||
// Vikunja #43: this is called both from the voice fact-write path (for the
|
||||
// immediate spoken confirmation) and from the digestion tick's proactive
|
||||
// scan (cmd/mavend/tick.go's detectPatterns, via patterns.go's
|
||||
// detectAndPropose), so a pattern gets noticed even with nobody at the mic.
|
||||
func (s *Store) CreateProposedRoutine(ctx context.Context, action, object string, intervalDays float64, ts time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO proposed_routines (action, object, interval_days, status, created_ts)
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
)
|
||||
|
||||
func newTestStore(t *testing.T) *Store {
|
||||
@@ -378,3 +380,47 @@ func TestCalendarEvents(t *testing.T) {
|
||||
t.Fatalf("expected 0 events on July 8, got %d", len(events))
|
||||
}
|
||||
}
|
||||
|
||||
// The work calendar arrives as relayed phone notifications, not a CalDAV read
|
||||
// (Vikunja #126). Those events belong in the same day's answer, and their
|
||||
// provenance has to survive the query so the caller can hedge them.
|
||||
func TestCalendarEventsIncludesAmbientSource(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
defer store.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
day := time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
store.WriteFact(ctx, day.Add(10*time.Hour), KindEnv, "calendar_event_20260803_Aaa-personal",
|
||||
`"Aaa personal @ 10:00-10:30"`, calendar.SourcePersonal, 1.0, sql.NullInt64{})
|
||||
store.WriteFact(ctx, day.Add(14*time.Hour), KindEnv, "calendar_event_20260803_Bbb-work",
|
||||
`"Bbb work @ 14:00-14:30"`, calendar.SourceAmbient, calendar.AmbientConfidence, sql.NullInt64{})
|
||||
// A fact that merely looks like one must still be excluded by source.
|
||||
store.WriteFact(ctx, day.Add(16*time.Hour), KindEnv, "calendar_event_20260803_Ccc-forged",
|
||||
`"Ccc forged @ 16:00-16:30"`, "tap:voice", 1.0, sql.NullInt64{})
|
||||
|
||||
events, err := store.CalendarEvents(ctx, day, day.AddDate(0, 0, 1))
|
||||
if err != nil {
|
||||
t.Fatalf("CalendarEvents: %v", err)
|
||||
}
|
||||
if len(events) != 2 {
|
||||
t.Fatalf("got %d events, want the personal and the ambient one: %+v", len(events), events)
|
||||
}
|
||||
bySource := map[string]Fact{}
|
||||
for _, e := range events {
|
||||
bySource[e.Source] = e
|
||||
}
|
||||
if _, ok := bySource[calendar.SourcePersonal]; !ok {
|
||||
t.Error("the personal CalDAV event is missing")
|
||||
}
|
||||
amb, ok := bySource[calendar.SourceAmbient]
|
||||
if !ok {
|
||||
t.Fatal("the ambient work event is missing")
|
||||
}
|
||||
if amb.Confidence >= 1.0 {
|
||||
t.Errorf("ambient confidence = %v, must stay below a calendar read's", amb.Confidence)
|
||||
}
|
||||
if _, ok := bySource["tap:voice"]; ok {
|
||||
t.Error("a non-calendar source must not be read as a calendar event")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user