Compare commits
525 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cae33a517 | |||
| 02e3d27aa9 | |||
| 78a9c61acb | |||
| 231248a990 | |||
| db50c34c23 | |||
| 1b5d35ad37 | |||
| 85a3397bf4 | |||
| 0b057df2a3 | |||
| 5b0b29dfad | |||
| a97764c5f7 | |||
| 9944ec8c58 | |||
| 98ab646206 | |||
| 40bf5562bd | |||
| 846fdc71ee | |||
| 4914c45cb0 | |||
| 5c01fe338b | |||
| 81ec4da56b | |||
| 8ee3b76af6 | |||
| 06576b406c | |||
| 28c2ffb84f | |||
| 8015fdbb79 | |||
| 35c6ff5a71 | |||
| da9114b623 | |||
| a0e6643465 | |||
| 80b6068e38 | |||
| de61b753ac | |||
| 7d0250a30b | |||
| 459fe7a903 | |||
| d7e8804db5 | |||
| 56254a51fa | |||
| 01c96abdd5 | |||
| fdee3de724 | |||
| f957a3ad13 | |||
| 8035a317d2 | |||
| 2cf8b7e1b5 | |||
| 240d53a96a | |||
| d8efb667c7 | |||
| 25ed201c4d | |||
| a926383827 | |||
| 557f5a3acc | |||
| 17e6195aeb | |||
| 14f2725452 | |||
| f8beee8416 | |||
| 634f82717c | |||
| 353b8f5a16 | |||
| d1b8519239 | |||
| c0f4074a5d | |||
| 9bb342569b | |||
| 5596cdddbc | |||
| 1c13d2265b | |||
| 95e7427153 | |||
| a1d018dc47 | |||
| 9f714b7ae8 | |||
| ef3ee1e00a | |||
| 99e73ea653 | |||
| ab1784f5e1 | |||
| 2c73493bf8 | |||
| ff202c0c35 | |||
| 02d96e611d | |||
| 62eef01c18 | |||
| 1a8aed35b8 | |||
| ce6a6821a9 | |||
| 479b0c4475 | |||
| 877b1fd4f8 | |||
| 21a42cb3e6 | |||
| b8279f6a22 | |||
| 8c30971a96 | |||
| d0ea927ac3 | |||
| 9c7bafd5b1 | |||
| 1f1e002789 | |||
| ce91d20ac8 | |||
| 50130cdffb | |||
| a9b480a78f | |||
| c5264deb46 | |||
| 1cb269886e | |||
| 7a9b9cc669 | |||
| 31b5093403 | |||
| 229890abd7 | |||
| 0db9ca084c | |||
| 96d97e8964 | |||
| 02f6e8ad4a | |||
| 999a5ad562 | |||
| 2ea39a3d41 | |||
| 8fb6f2154d | |||
| c938148619 | |||
| 2512d686a1 | |||
| f44abcc526 | |||
| a99932b427 | |||
| 6d5801bb1f | |||
| 8aba4845bf | |||
| 2ec92ee8bf | |||
| 7c77a378c1 | |||
| 672eabc134 | |||
| a1a2fa3704 | |||
| 22a4978459 | |||
| 1456336652 | |||
| b975716759 | |||
| 4b1edb0617 | |||
| 944e553669 | |||
| cc32c2c4ab | |||
| a1e97c94ac | |||
| c7f59e48f4 | |||
| 4666057066 | |||
| 7138086c3f | |||
| 83e168f326 | |||
| a4abcdefa3 | |||
| 68a3c85186 | |||
| 88c086482e | |||
| feabf9f350 | |||
| 50c6637c1b | |||
| ee9d55ca95 | |||
| a886217223 | |||
| de9884e063 | |||
| bbefda66e2 | |||
| a37c4138a1 | |||
| d6f391430f | |||
| 68b2aa9137 | |||
| f8fa0d1b44 | |||
| 9a333b23d7 | |||
| 663b5c47b9 | |||
| 45c521e1a6 | |||
| e34669a52e | |||
| d434f83c2c | |||
| c310115fd2 | |||
| 3024f76e5f | |||
| 6bc71553ab | |||
| ed1730431c | |||
| 01e80fce4a | |||
| e69f1bd0cf | |||
| f55bedee2e | |||
| e470435cf1 | |||
| 00f9239ef9 | |||
| 3513e508b7 | |||
| c15c2b7bd2 | |||
| b6eaa704a2 | |||
| 2597a7b34a | |||
| 7203cd56fd | |||
| ab3e818bb9 | |||
| b5500a5be8 | |||
| 9095ac847d | |||
| 4b5f6adbae | |||
| ecb8ba72eb | |||
| 4fdecf9a25 | |||
| 2bbd8edbf6 | |||
| beb093aebb | |||
| b1b326018f | |||
| 08889cad88 | |||
| a4630b9314 | |||
| 39d44bb384 | |||
| 65ee0f9c61 | |||
| 76938e206d | |||
| 0b3d81ecbf | |||
| 4be6852b94 | |||
| f7b76c572f | |||
| 05ddc5c92e | |||
| b55e68f98d | |||
| beaa24754c | |||
| aed8cac439 | |||
| af4eeceb6a | |||
| 7b507dec94 | |||
| 2c0334c4fe | |||
| 92cbdbfdd3 | |||
| e78b2d8992 | |||
| 9d58922462 | |||
| b5ac48c126 | |||
| 69d0f5ee78 | |||
| 661b5c1099 | |||
| ff70637a0d | |||
| 06c1cf247e | |||
| 400653810e | |||
| b3936348f5 | |||
| c61b0b3968 | |||
| 0a5211b038 | |||
| 38be702188 | |||
| d42372e996 | |||
| 45231ba69e | |||
| 42c7b8b927 | |||
| e5a1db995d | |||
| d32eae8aac | |||
| 63b645b405 | |||
| 0e82cb442f | |||
| d94ed2e630 | |||
| c8f74c39d6 | |||
| 44b8793e2f | |||
| a4b4733767 | |||
| 8f168ab811 | |||
| eb129c2fad | |||
| 0d5bd0a9f0 | |||
| 4d97280d74 | |||
| e5ec4abe04 | |||
| 7688dfde66 | |||
| e1f84a3474 | |||
| 7852aad60f | |||
| 034d4b4359 | |||
| 799cf5587d | |||
| c1b781fac0 | |||
| 7b2b9d479a | |||
| 92de4ae496 | |||
| a0293bac85 | |||
| c1d9a4547b | |||
| 6499f6365e | |||
| 97e1a44c1a | |||
| 1b3af05d0a | |||
| e7ecce2859 | |||
| 1f8e9f21ce | |||
| e7537d032e | |||
| 2b3e34c7e8 | |||
| dde556a3d3 | |||
| b86172a98d | |||
| 22edc3cdfb | |||
| 4f6dec0cf2 | |||
| 23ad5c0247 | |||
| 0445693a16 | |||
| c7d22858ba | |||
| 0b994ff1c3 | |||
| 12ecc30c57 | |||
| 190cf0c794 | |||
| 6b3749f5a2 | |||
| d156be3442 | |||
| f29bc107d4 | |||
| 10975eff07 | |||
| cc48309c7c | |||
| 4534101d10 | |||
| 5b8707e21e | |||
| 76d123edf3 | |||
| 62675e8fe4 | |||
| 46acf3cba0 | |||
| 373229ab7a | |||
| 2dbf476c45 | |||
| 13cb1903a9 | |||
| 8d20efcfbb | |||
| c661f7bd1a | |||
| e5158d8828 | |||
| 07f7550931 | |||
| 8c1e457150 | |||
| 6923a983aa | |||
| 1d10c9535c | |||
| 3b3660da9a | |||
| dd699b706f | |||
| 0b1efe4911 | |||
| 580959f856 | |||
| bf2587c7fa | |||
| 26ff646ace | |||
| 9e1958e7b0 | |||
| e6923490fd | |||
| d7a43afd90 | |||
| fabc3bc274 | |||
| b6eed20af2 | |||
| 936c6d71db | |||
| 72aa97dae8 | |||
| 1c0a1d0db0 | |||
| 37feee1eb3 | |||
| 94c273780a | |||
| 93c08f9de1 | |||
| 4f96bbd6ec | |||
| 7dba1b7935 | |||
| 8102c73f83 | |||
| 8c36e7ef84 | |||
| 95cbf82e38 | |||
| 5bca435146 | |||
| 69270f4cfb | |||
| 01230bf16b | |||
| ebce90b984 | |||
| 04584fb2da | |||
| 5447f08c06 | |||
| 650363ce67 | |||
| 85456d3833 | |||
| 901354002e | |||
| f4a021d3da | |||
| 316fb197a8 | |||
| 67decc42f0 | |||
| 201fe03d20 | |||
| fec572c997 | |||
| 5187f3bd14 | |||
| 1b5d093148 | |||
| 502327678f | |||
| 5d2fd91c06 | |||
| 33c2d782a9 | |||
| e8ece874b1 | |||
| 1fa14e95a4 | |||
| dd6da78aeb | |||
| d5d4166710 | |||
| 6ec4220668 | |||
| 59cc882265 | |||
| be18649953 | |||
| 41d3a4a903 | |||
| 188d9fc02f | |||
| 5a85d37fa5 | |||
| d70cb7e9ab | |||
| c0aee1558f | |||
| 0d49745a17 | |||
| 2063f8e770 | |||
| 173531be8c | |||
| 01c78ef369 | |||
| bac8673f05 | |||
| 5cc51c5b6e | |||
| be869c6a48 | |||
| 8559f1f450 | |||
| b908c39e45 | |||
| 8c774abe5b | |||
| e2777177b0 | |||
| b8250a8711 | |||
| 02ce730cb2 | |||
| aaf1f0236b | |||
| e488ee2285 | |||
| b6680398c3 | |||
| 0feb8d3dbd | |||
| 0886662360 | |||
| 439ceb5d8e | |||
| ecaf3407fa | |||
| b60264701c | |||
| 60540fa934 | |||
| db8cbdc20a | |||
| 91f6ea84a0 | |||
| b726658692 | |||
| 5f5f14eba7 | |||
| 59b98d5c4d | |||
| 59210cf07d | |||
| 828e034c96 | |||
| c699c139b4 | |||
| b705a786ef | |||
| 20fe909f76 | |||
| ceba69c4bb | |||
| 95eeef13dd | |||
| cfbef45feb | |||
| 3bb82a90db | |||
| abbf0fe60d | |||
| b46bab99f7 | |||
| b9ee858421 | |||
| 594bfc2bc3 | |||
| c600b426f3 | |||
| b95566aa98 | |||
| 795ecf67a5 | |||
| cb3b507ed5 | |||
| 7262310fce | |||
| 69eda3ceee | |||
| 43dc487113 | |||
| 22a8eed1c3 | |||
| 1e35a10f33 | |||
| 69a6eb0fb9 | |||
| 4f7ad7d99a | |||
| 01b47e3864 | |||
| 12c2ae1d17 | |||
| d457c97355 | |||
| 4761c20ad6 | |||
| ed0331c774 | |||
| 968477ea59 | |||
| 82d0384020 | |||
| 1b6d51dc71 | |||
| 9ed259660d | |||
| c8a5b5416e | |||
| 40152e3688 | |||
| 7f411656c2 | |||
| e4f0508a2f | |||
| 5fede2acb7 | |||
| 9ea178be99 | |||
| b49755302c | |||
| 9ef6f286a9 | |||
| c0b99828f9 | |||
| 7414ef4c39 | |||
| 231a4e00f6 | |||
| 52548abe02 | |||
| 70b32af8a7 | |||
| 521c315b30 | |||
| 2acfeb4453 | |||
| 1c20df70f8 | |||
| fffd0cb5fa | |||
| 77a7c994d7 | |||
| 63812af920 | |||
| 869580c913 | |||
| 31deb7d565 | |||
| 1338ec6e2a | |||
| 7843728174 | |||
| a3ad9b5040 | |||
| cb0a3a4f20 | |||
| 6abd2768e8 | |||
| 6e6f73da35 | |||
| 1a64c30427 | |||
| 5753f90752 | |||
| 4d94277836 | |||
| 530c3ff395 | |||
| e031f8f5f5 | |||
| 6c24e19b83 | |||
| 934a28d67c | |||
| cf28f6fdf0 | |||
| eec3d9bed2 | |||
| a5b245dbf5 | |||
| 3e6a427e85 | |||
| 5ac7347c38 | |||
| 5417692566 | |||
| b56e0e6248 | |||
| 0558dfed0f | |||
| 13a5ef0100 | |||
| ac78f83406 | |||
| 40c59aa275 | |||
| 84a75274bf | |||
| da2d11dab6 | |||
| de3f2b5fc2 | |||
| e8f4baf407 | |||
| 92eb6cf6e1 | |||
| 6759ff6003 | |||
| 39284cd851 | |||
| ea0eb167fd | |||
| b6305f1b6e | |||
| 5bd1406c7a | |||
| d988154063 | |||
| 1b76fa8205 | |||
| e87088afb8 | |||
| 1b8d2c60d3 | |||
| 12667fd3b8 | |||
| e4fd6140a9 | |||
| 2ba5d0a60e | |||
| c6b11a6d1d | |||
| 45622eff3d | |||
| 5815f0b8f3 | |||
| f68d49d9e2 | |||
| 36bc603f52 | |||
| 59214b4fdd | |||
| e94c868160 | |||
| de4c47459a | |||
| 27bb9119fb | |||
| 0ab5dc1482 | |||
| 35ae1f41da | |||
| a9db82b04c | |||
| 278eeeffdf | |||
| b23596f54f | |||
| 6e3bb3be97 | |||
| aa7ef33bbf | |||
| f2851b3729 | |||
| d49067f7dd | |||
| b528a8f5c9 | |||
| 44320ee496 | |||
| 337a777d2e | |||
| 576dfd8b4c | |||
| ed9db8dc44 | |||
| a8710c859b | |||
| 1bacbb7952 | |||
| 82d9a3324e | |||
| 03d48ab789 | |||
| 570b204571 | |||
| cb350efb19 | |||
| 43b0c32154 | |||
| b95a0278a4 | |||
| a6b17ada8b | |||
| 92949e886f | |||
| 6b2667b7af | |||
| 494a7721e0 | |||
| 46b58f0278 | |||
| a88c984d16 | |||
| 496559c9dd | |||
| d21b4a65da | |||
| be62660be9 | |||
| cd6549fa51 | |||
| c259ed6c73 | |||
| 8f2377d27d | |||
| d850f1f5fd | |||
| 4ed951040f | |||
| 8d2c1b6f99 | |||
| ee4c26f13c | |||
| 49cadcf1b7 | |||
| 988c2ae981 | |||
| 982c25118a | |||
| de10d7664f | |||
| a08403087f | |||
| 3e87ad7eb6 | |||
| 47128bb1ca | |||
| 8e3b288858 | |||
| 52a4772962 | |||
| 52d80394ec | |||
| 42a7bd88b2 | |||
| b789676244 | |||
| b621c477a0 | |||
| fa67dd82fe | |||
| 52fd218c70 | |||
| 33032b859a | |||
| 0d8cbaec01 | |||
| 1f38e71d1a | |||
| 2de5a339fb | |||
| 1ee9a930d3 | |||
| f48c2280bd | |||
| 888c1c6768 | |||
| 7cacbc8b21 | |||
| dc3cda666e | |||
| 1b354e9b39 | |||
| c07722266a | |||
| be758d9a59 | |||
| 8bbdcd2727 | |||
| f8947bef5a | |||
| b752ec037e | |||
| 4dbeca5a2e | |||
| 9e15ff36aa | |||
| 7955a41105 | |||
| a93a16d7b3 | |||
| dd63180e44 | |||
| 9d80a39a30 | |||
| b5b599e287 | |||
| bb51c28a19 | |||
| 549d4c8380 | |||
| 4766167c3a | |||
| f6f9e75eac | |||
| 1524991adc | |||
| 9da468810e | |||
| 7b4fb6229a | |||
| cdd81e2ad5 | |||
| 32d5f68710 | |||
| fdc18edd87 | |||
| a98d25ecac | |||
| 113508eaac | |||
| 6dc2622596 | |||
| dd91c6961c | |||
| 0560684b35 | |||
| c4cf06d610 | |||
| acd985323e | |||
| 598f4fc011 | |||
| 69db1cf849 | |||
| f5480e281b | |||
| cc72f69769 | |||
| b954e0cea6 | |||
| c82dbd1e65 | |||
| 69ecea19d5 | |||
| 8a13d189bb | |||
| 8e7aa0d451 | |||
| 00595c2211 | |||
| b54ccccd0a | |||
| 4cfef41541 |
@@ -54,6 +54,10 @@ opencode.json
|
||||
# Test coverage output
|
||||
coverage.out
|
||||
|
||||
# Python service/test bytecode.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Agent worktrees and local agent state. The workflow itself is tracked: the
|
||||
# hooks, the skills and the prose dictionary are how a session behaves, so they
|
||||
# get reviewed like code. Everything else under .claude/ is scratch.
|
||||
@@ -70,3 +74,5 @@ coverage.out
|
||||
|
||||
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
|
||||
.env
|
||||
# silero-vad, downloaded (see AGENTS.md)
|
||||
/models/vad/
|
||||
|
||||
@@ -95,6 +95,22 @@ model: the code puts `query: ` in front of a question and `passage: ` in front
|
||||
of a stored note, which is how e5 was trained. The quantized file is the one
|
||||
that is downloaded, deployed and measured.
|
||||
|
||||
## Voice activity model for mavwaked
|
||||
|
||||
`mavwaked` decides an utterance has started with silero-vad when `-vad-model`
|
||||
points at it, and with an energy threshold when it does not. The model is 2.3MB
|
||||
and is not committed:
|
||||
|
||||
```sh
|
||||
mkdir -p models/vad
|
||||
curl -sL -o models/vad/silero_vad.onnx \
|
||||
https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx
|
||||
```
|
||||
|
||||
It needs the same `libonnxruntime.so` the embedder needs, passed as `-onnx-lib`
|
||||
or read from `MAVEN_ONNX_LIB`. The measurement is
|
||||
`docs/evals/2026-08-09-silero-vad.md`, and the tests skip without the file.
|
||||
|
||||
**Also need ONNX Runtime** (`libonnxruntime.so`):
|
||||
|
||||
```sh
|
||||
@@ -119,37 +135,41 @@ Russian recall — you may see many "clarify" responses).
|
||||
|
||||
## Qwen3 resident model for router + phraser
|
||||
|
||||
The target daemon uses the locally trained Qwen3-1.7B checkpoint for both
|
||||
routing and phrasing. Training is Qwen3 Base → RU CPT → joint persona/router
|
||||
SFT → merged GGUF, and is still in flight (#122) — until it lands, the deployed
|
||||
resident model is stock **Qwen3.5-0.8B** (`Q4_K_M`), see `deploy/mavend.json`.
|
||||
The deployed resident model is stock **Qwen3-1.7B** (`UD-Q4_K_XL`), a Thinking
|
||||
variant at `n_ctx` 4096. `CLAUDE.md` carries the rule on which models qualify.
|
||||
Without a configured model, `StubPhraser` plus the classifier remain the
|
||||
deterministic floor.
|
||||
|
||||
During training, use the runbook in
|
||||
`docs/plans/2026-07-18-qwen3-resident-training-eval.md`. After the decision gate
|
||||
and SFT pass, copy the merged GGUF into the mounted model directory and set:
|
||||
A locally trained Qwen3-1.7B checkpoint is still in flight (V-122). Training
|
||||
runs Qwen3 Base, then RU CPT, then joint persona and router SFT, then a merged
|
||||
GGUF. The
|
||||
runbook is `docs/plans/2026-07-18-qwen3-resident-training-eval.md`. After the
|
||||
decision gate and SFT pass, copy the merged GGUF into the mounted model
|
||||
directory and point `model_path` at it.
|
||||
|
||||
**Configure in `deploy/mavend.json`.** This is the deployed `phraser` block:
|
||||
|
||||
```json
|
||||
"phraser": {
|
||||
"model_path": "/opt/maven/models/llm/Qwen3-Maven-1.7B-Q8_0.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
|
||||
"n_ctx": 4096,
|
||||
"cache_ram_mib": 512,
|
||||
"timeout": "60s"
|
||||
}
|
||||
```
|
||||
|
||||
**Configure in `deploy/mavend.json`** — the `phraser` block points at this
|
||||
model and the daemon spawns `llama-server` as a subprocess. The router and
|
||||
replier use the same llama-server via the shared `internal/llm` client.
|
||||
The daemon spawns `llama-server` as a subprocess. The router and replier reach
|
||||
that one server through the shared `internal/llm` client. Model files live in
|
||||
`/mnt/hdd1/llms`, bind-mounted over `models/llm/`, so a gguf sitting in the repo
|
||||
is loaded by nothing.
|
||||
|
||||
Telegram tokens are read from `deploy/telegram.env` (gitignored), expanded
|
||||
via `${VAR}` in the JSON config.
|
||||
|
||||
**Routing is Qwen-first** with classifier fallback. The LLM router runs
|
||||
after stage-0 (exact-match grammar) and before the classifier cascade. On any
|
||||
error or parse failure, the classifier handles the utterance — the turn never
|
||||
breaks on the model.
|
||||
The cascade order, and which stage may decline to the next, is in
|
||||
`docs/routing.md`. It is not restated here.
|
||||
|
||||
## Web UI conventions
|
||||
|
||||
|
||||
@@ -1,353 +1,214 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance for Claude Code (claude.ai/code) working in this repository.
|
||||
|
||||
Maven is a self-hosted, privacy-first voice assistant (Russian + English). Go daemons
|
||||
talking over unix sockets; one resident small model for routing + phrasing; whisper.cpp STT, piper TTS.
|
||||
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.
|
||||
**This is a rules file.** It loads into every session, so it carries only what
|
||||
changes what an agent does. A measurement belongs in `docs/evals/`, dated and
|
||||
never edited after the day. A subsystem's reasoning belongs in its living doc
|
||||
under `docs/`. Read that doc before changing the subsystem.
|
||||
|
||||
**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 `docs/evals/2026-07-31-model-bakeoff.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`.
|
||||
|
||||
See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for the folded design spec, and
|
||||
`AGENTS.md` for local-preview + model-download recipes.
|
||||
|
||||
**Model work is moving to the workstation** (owner's call, 2026-08-02). homesrv cannot grow a
|
||||
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
|
||||
remotes with a floor on homesrv. The workstation is never assumed up. Fall back silently when
|
||||
it would only do the job better. Name the gap when the 1.7B cannot do it at all. The embedder
|
||||
stays on homesrv permanently, because it backs that floor. It is multilingual-e5-small,
|
||||
quantized and asymmetric — `EmbedQuery` and `EmbedPassage` apply the `query:`/`passage:`
|
||||
prefixes it was trained with, and calling plain `Embed` on a note is a bug. It replaced
|
||||
MiniLM and bought ten points of recall@1 and 2.5× the speed; see
|
||||
`docs/evals/2026-08-04-recall-e5-small.md`. Read `docs/offload.md` before
|
||||
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||||
are the work.
|
||||
|
||||
Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently
|
||||
through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A
|
||||
world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not
|
||||
free — `worldGap` in `cmd/mavend/worldmodel.go`, which he hears instead of an invented
|
||||
answer. A box with no `workstation` block behaves exactly as it did before the seam: naming
|
||||
a gap requires a gap. The offload table in `docs/offload.md` says which caller is which.
|
||||
|
||||
## Build & test
|
||||
|
||||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
||||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||||
|
||||
```sh
|
||||
make build # all 9 binaries
|
||||
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
||||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||||
```
|
||||
|
||||
Run a single test (must carry the CGO env for packages that touch STT/TTS/voice):
|
||||
|
||||
```sh
|
||||
CGO_CFLAGS="-I$(pwd)/deps/include -I$(pwd)/deps/whisper.cpp/ggml/include" \
|
||||
CGO_LDFLAGS="-L$(pwd)/deps/lib -Wl,-rpath,$(pwd)/deps/lib" \
|
||||
LD_LIBRARY_PATH="$(pwd)/deps/lib" \
|
||||
deps/go/go/bin/go test -run TestName ./internal/router/
|
||||
```
|
||||
|
||||
Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test ./pkg/`.
|
||||
|
||||
## The daemons (`cmd/`)
|
||||
|
||||
| Binary | Role |
|
||||
| Read this | Before |
|
||||
|---|---|
|
||||
| `mavend` | **Core.** Router, phraser, memory, reminders, digestion tick. Owns the DB + IPC socket. |
|
||||
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
||||
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
||||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||||
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
|
||||
| `mavpoll` | Telegram long-poll reach. |
|
||||
| `mavcaldav` | CalDAV calendar sync. |
|
||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||
| `docs/routing.md` | touching `internal/router/` or `queryWalk` |
|
||||
| `docs/deployment.md` | touching a daemon, compose, a systemd unit or the web UI |
|
||||
| `docs/offload.md` | touching a daemon seam or adding a model caller |
|
||||
| `docs/world.md` | touching search, Kiwix or the world chain |
|
||||
| `docs/language.md` | changing a prompt contract or a Russian word list |
|
||||
| `docs/ecosystem.md` | touching Nexus, Praxis or Hexis |
|
||||
| `docs/spec.md` | asking what a capability is for, or whether it is done |
|
||||
| `docs/roadmap.md` | picking what to work on next |
|
||||
| `docs/rearchitecture.md`, `docs/design.md` | changing the shape of anything |
|
||||
| `docs/workflow.md` | the five stores, the doc tiers, the guards |
|
||||
| `docs/caveats/` | a known limit, its task id and its revisit trigger |
|
||||
| `docs/CLAUDE.md` | which tier a doc belongs in, and what each one holds |
|
||||
| `AGENTS.md` | local preview, screenshots, model downloads |
|
||||
|
||||
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
||||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||||
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
||||
## What Maven is
|
||||
|
||||
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
|
||||
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
||||
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
|
||||
daemon there listens to nobody. They belong on a client machine where he is standing.
|
||||
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
|
||||
needs building to allow it, but no such machine exists yet. **The consequence: the wake
|
||||
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
|
||||
sitting at the box changes that.** Push-to-talk through `/dash` is what QA actually covers.
|
||||
A self-hosted, privacy-first voice assistant in Russian and English. Go daemons
|
||||
talk over unix sockets. One resident small model routes and phrases. whisper.cpp
|
||||
does speech-to-text and piper does text-to-speech.
|
||||
|
||||
The resident model is **Qwen3-1.7B** (`UD-Q4_K_XL`) on homesrv, a Thinking
|
||||
variant at `n_ctx` 4096. Keep it at 1.7B or under. Sub-500M models are unusable
|
||||
in Russian (`docs/evals/2026-07-31-model-bakeoff.md`). Model files live in
|
||||
`/mnt/hdd1/llms`, bind-mounted over the repo's `models/llm/`, so a gguf sitting
|
||||
in the repo is loaded by nothing.
|
||||
|
||||
The workstation is workpc and it holds the remote model and speech-to-text.
|
||||
**It is never assumed up.** **Fall back silently** when it would only do the job
|
||||
better. **Name the gap** when the resident model cannot do the job at all.
|
||||
|
||||
**The embedder stays on homesrv permanently**, because it backs that floor.
|
||||
`EmbedQuery` and `EmbedPassage` apply the `query:` and `passage:` prefixes
|
||||
multilingual-e5-small was trained with. Calling plain `Embed` on a note is a bug.
|
||||
|
||||
## Build and test
|
||||
|
||||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored
|
||||
toolchain wired through the Makefile. **Do not call `go build` on them bare**,
|
||||
and **do not hand-write the CGO preamble**. This box runs zsh, so an unquoted
|
||||
`-run Test*` dies on "no matches found" before `go` is reached. `make t` also
|
||||
carries `-count=1` and sets `MAVEN_ONNX_LIB`. Without that variable the four
|
||||
`TestONNX*` measurements self-skip and the run still prints `ok`.
|
||||
|
||||
```sh
|
||||
make build # all 11 binaries. make build-web for one (web/waked/poll/caldav skip CGO)
|
||||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||||
make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # V=1 for -v, RACE=0 to drop -race
|
||||
make analyze # staticcheck, deadcode and govulncheck. Not in `test`: all three need the network
|
||||
```
|
||||
|
||||
**The static gates pass against a baseline, not against zero**
|
||||
(`scripts/analyzers/*.baseline`, reasoning in `docs/workflow.md`). A fix must
|
||||
delete its baseline entry, because the gate also fails on an entry whose finding
|
||||
is gone. **`make audit` is a git-grep inventory, not analysis.** Do not cite it
|
||||
as a reachability check.
|
||||
|
||||
## The daemons
|
||||
|
||||
Eleven binaries under `cmd/`, wired socket-to-socket over `internal/ipc`, not
|
||||
linked. `mavend` is the core and owns the DB and the IPC socket.
|
||||
`deploy/mavend.json` sets sockets, model paths and the phraser and embedder
|
||||
blocks, with `${VAR}` expansion from gitignored `deploy/telegram.env`.
|
||||
**`docker-compose.yml` runs five**: `mavend`, `mavsttd`, `mavttsd`, `mavweb`,
|
||||
`mavpoll`. Count against compose, not against `make build`. `mavwaked` runs on
|
||||
workpc under systemd. `docs/deployment.md` says who else is absent and why.
|
||||
|
||||
- **Passwords are read from files, never taken as flag values.**
|
||||
- **The voice wire is plaintext with no auth.** mavend's voice port stays on
|
||||
homesrv loopback and reaches workpc over ssh. Do not LAN-bind it.
|
||||
`SurfaceVoice` caps acts at L0, and L0 does not cap reading.
|
||||
- **A GPU service added beside mavgpud goes in `cmd/mavgpud`, never in systemd.**
|
||||
The card needs one owner. A second unit made mavgpud evict llama-server every
|
||||
few seconds and took the model arm down for eight minutes.
|
||||
|
||||
## The ecosystem: Nexus, Praxis, Hexis
|
||||
|
||||
Maven is one of four services. It owns conversation and personal memory. It does not
|
||||
Nexus identifies, Praxis observes, Hexis acts, Maven understands. Maven does not
|
||||
own identity, operational state, or execution. Full contract in
|
||||
`docs/ecosystem.md`.
|
||||
`docs/ecosystem.md`. All three are `nil` unless configured and each degrades
|
||||
alone. An outage means a named gap, never a broken turn or a guess.
|
||||
|
||||
```text
|
||||
Nexus identifies. Praxis observes. Hexis acts. Maven understands and coordinates.
|
||||
```
|
||||
|
||||
| Service | Owns | Maven's client | Configured at |
|
||||
|---|---|---|---|
|
||||
| **Nexus** | Canonical entity ids, names, aliases, relationships. Projects, services, devices, people, pets, places. | `nexusClient` in `cmd/mavend/ecosystem.go`, `POST /api/v1/resolve` | `nexus.url` (`http://nexus:9740`) |
|
||||
| **Praxis** | Operational attention and item lifecycle. What needs looking at, what changed, what is still unresolved. | `praxisClient`, the HTTP tools API under `/api/v1/tools/` | `praxis.url` (`http://praxis:8989`) |
|
||||
| **Hexis** | The capability registry and the only path to executing anything. | vendored `github.com/kami/hexis/pkg/client` | `hexis.url` (`http://hexis:9741`) |
|
||||
|
||||
All three are `nil` unless configured, and every one of them degrades on its own.
|
||||
An outage means a named gap in the answer, never a broken turn and never a guess.
|
||||
|
||||
Rules that are not negotiable:
|
||||
|
||||
- **No component reads another component's database.** Praxis attention comes over
|
||||
HTTP, never from its SQLite file.
|
||||
- **Identity lives in Nexus.** Do not invent a local fact key for something Nexus
|
||||
resolves. `actionFact` already sets `Subject`, and `cmd/mavend/factenrichment.go`
|
||||
resolves it in the background against Nexus.
|
||||
- **Free text never reaches a mutating Hexis call.** Resolve to a canonical entity id
|
||||
first. Ambiguous resolution asks the owner, it does not pick.
|
||||
- **No component reads another component's database.** Praxis attention comes
|
||||
over HTTP, never from its SQLite file.
|
||||
- **Identity lives in Nexus.** Do not invent a local fact key for something
|
||||
Nexus resolves. `cmd/mavend/factenrichment.go` resolves `actionFact.Subject`.
|
||||
- **Free text never reaches a mutating Hexis call.** Resolve to a canonical
|
||||
entity id first. Ambiguous resolution asks the owner, it does not pick.
|
||||
- **LLM output is not authorization.** Confirmation binds capability id, target
|
||||
entity, arguments, requester and expiry. See `cmd/mavend/confirm.go`.
|
||||
- **Praxis lifecycle words mean different things.** Surfaced is not acknowledged,
|
||||
acknowledged is not resolved, execution success is not recovery. Reading an item
|
||||
aloud calls `Surface`, never `Acknowledge`.
|
||||
- **No automatic attention-to-action path.** Digestion may summarise Praxis. It may
|
||||
not call Hexis.
|
||||
entity, arguments, requester and expiry (`cmd/mavend/confirm.go`).
|
||||
- **Praxis lifecycle words differ.** Surfaced is not acknowledged, acknowledged
|
||||
is not resolved, execution success is not recovery. Reading an item aloud
|
||||
calls `Surface`, never `Acknowledge`.
|
||||
- **No automatic attention-to-action path.** Digestion may summarise Praxis and
|
||||
may not call Hexis.
|
||||
- Every cross-service call carries a correlation id minted once per action
|
||||
(`withCorrelationID`), a contract version header, and `X-Requested-By: maven`.
|
||||
|
||||
Every cross-service call carries a correlation id minted once per action
|
||||
(`withCorrelationID`), a contract version header, and `X-Requested-By: maven`.
|
||||
## Routing
|
||||
|
||||
## Routing — read this before touching the router
|
||||
**Read `docs/routing.md` before touching `internal/router/` or `queryWalk`.** It
|
||||
carries the reasoning, the measurements and every rule's why. A route produces
|
||||
two decisions. **Intent** is one of seven values. **Source** is where the answer
|
||||
lives and is read on `IntentQuery` alone. Score them separately. The cascade is
|
||||
stage 0 grammars, then the routing heads, then the resident model, then the
|
||||
classifier. Every stage may decline and the next one answers.
|
||||
|
||||
`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.
|
||||
- **The classifier is the floor, not dead code.** It answers when the resident
|
||||
model is off, absent, or erroring. **Any model error falls through.**
|
||||
- **The stage 0 set lives in `router.StageZeroGrammars`**, and both `buildRouter`
|
||||
and the eval fixture call it. Add a grammar there, in the right place, and read
|
||||
the comment above the line you insert after. Do not restate the list anywhere.
|
||||
- **Go's `\b` is ASCII-only** and never fires after a Cyrillic letter. A Russian
|
||||
pattern needs an explicit `(\s|[?!.]|$)`.
|
||||
- **`PraxisGrammars()` is the only path to Praxis**, not a faster one.
|
||||
- **`voice.embedder.heads_path` must never point at `model_path`.** Recall
|
||||
depends on the resident e5-small scoring what it scored. Fine-tune a copy.
|
||||
Refused at config load since V-692, symlinks included.
|
||||
- **Routing traces are retained 14 days**, enforced on write and again on start.
|
||||
- **Bump `tokenizerRev` on any change to what `encodeWord` emits**, so a
|
||||
tokenizer fix triggers `ReembedAll` the way swapping the model file does.
|
||||
- **A new rung in the `runTurn` ladder needs its name in `preRouteLadder`**
|
||||
(`cmd/mavend/decisiontrace.go`), or it is missing from the decision record.
|
||||
|
||||
- **LLM router (the intended design, docs/rearchitecture.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.
|
||||
**`queryWalk` takes query sources out and moves none** (`actions_query.go`). That
|
||||
is the safety argument and it is not negotiable. The table's order is
|
||||
load-bearing and carries "the owner's data first, then the world".
|
||||
`SourceUnknown` is the floor and walks the whole chain. A named destination
|
||||
removes only the sources marked `guesses: true`, so a source that looks rather
|
||||
than guesses is always asked. **The personal boundary is the one exception and
|
||||
it is deliberate.** It guesses, so naming `SourceWorld` drops it. **Only a stage
|
||||
0 grammar may drop it** (owner's call, V-666). `queryWalk` reads
|
||||
`Decision.SourceAnchored` for the source marked `boundary: true` and no other.
|
||||
|
||||
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.
|
||||
Judge a routing change against the classifier and the resident model, since
|
||||
those always answer. **Their scores live in `docs/routing.md`, never here.** A
|
||||
pair copied into this file goes stale silently. The fixture has changed size
|
||||
more than once, so a number compares only to another number on the same
|
||||
fixture.
|
||||
|
||||
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
|
||||
full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from
|
||||
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
|
||||
seed additions, both of which now score inside the classifier baseline. Qwen3-1.7B scores
|
||||
77.9% intent-only / 72.7% through the cascade. So the router buys about 4 points of accuracy,
|
||||
not a doubling, and the trade is worth re-arguing rather than assuming. **The ≈2.7s figure
|
||||
that stood here until 2026-08-02 was contention, not the model.** See `docs/evals/2026-07-31-routing.md` line 61, which measures the LLM router at
|
||||
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
||||
work off the bakeoff table.
|
||||
## Language: model output and Russian
|
||||
|
||||
**The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing
|
||||
completes through `llm.Pair` against gemma-4-12b and scores **84.4% full / 93.5% intent-only at
|
||||
p50 329ms** — better than the resident model and about 2.5× faster (`docs/evals/2026-08-02-workstation-gemma4-12b.md`,
|
||||
Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a
|
||||
routing change against the classifier and the resident model, since those are what always answer.
|
||||
Both contracts are in `docs/language.md`. What must not be broken:
|
||||
|
||||
`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.
|
||||
- **One parser for model text, `parseResponseMood`** in
|
||||
`internal/phraser/parse.go`. Every phrasing path reaches it. Mood is an enum.
|
||||
- **The router prompt is a separate contract** over 7 intents, and
|
||||
`llm/check_prompt_parity.py` keeps the Go and relabelling copies identical.
|
||||
- **Russian words are matched by three mechanisms and no fourth**:
|
||||
`internal/lexicon` for closed classes, `internal/morph` for grammar, and
|
||||
`cmd/mavend/topics.go` with the embedder for open sets. A regex whose output
|
||||
is a fact or a route is the defect. A regex over structured input is not.
|
||||
- **Seeds are scoring data.** Editing one moves a recogniser and must be
|
||||
re-measured against the `TestONNX*` tests, not eyeballed.
|
||||
|
||||
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,
|
||||
`"поужинал"`, was a real defect: the single-token rule was an English intuition and does not
|
||||
transfer to Russian, where one word is routinely a whole sentence.
|
||||
## Non-goals and hard constraints
|
||||
|
||||
Narrowed 01-08-2026. `thinSingleToken` (`internal/router/singletoken.go`) still thins a bare
|
||||
one-word nominal — "вода", "бэкап" — but spares two classes: a closed lexicon of social and
|
||||
control singles ("привет", "спасибо", "стоп", "yes"), and any token carrying a Russian verb
|
||||
ending (past tense, 2nd person, reflexive), because a verb already contains its subject. Both
|
||||
tests are offline and cost nothing. Re-measured: **false clarifies 3 → 2, intent-only 74.0% →
|
||||
75.3%, full accuracy unchanged at 70.1%, missed clarify still 1.** The two remaining false
|
||||
clarifies are the act-with-no-allowlisted-fn arm of the gate, not this rule.
|
||||
Not a nag, not autonomous.
|
||||
**The persona is feminine.** Russian self-reference takes feminine forms: `рада`
|
||||
not `рад`, `поняла` not `понял`. The owner is male and she speaks to him
|
||||
informally. Use "ты", singular, never "вы" or "ваш", and never "он" or "его".
|
||||
She talks TO the owner, not about him. Pet names such as "милый" are forbidden.
|
||||
The name "Ками" is not. `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||||
`internal/phraser/eval/checks.go` enforce this, scored by `make eval-phrasing`.
|
||||
|
||||
Agenda questions taken off the model, 01-08-2026. `AgendaQueryGrammars` (`stage0.go`, wired
|
||||
after the clock rules in `buildRouter`) routes "что у меня сегодня", "во сколько у меня
|
||||
встреча" and anything naming a calendar to `IntentQuery` at stage 0. They were going to
|
||||
`IntentSystem`, where `replySystem` has no agenda arm and answered "пока не умею" — the
|
||||
fixture had said `query` since ru-query-019 was written. Measured: **full accuracy 70.1% →
|
||||
72.7%, intent-only 75.3% → 77.9%, calendar 0/2 → 2/2**, clarify counts unchanged. Note that
|
||||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
||||
explicit `(\s|[?!.]|$)`.
|
||||
**"Never phones home" is deprecated** (owner's call, 2026-07-31). She reads
|
||||
external sources, and `docs/world.md` carries that chain. What holds regardless:
|
||||
|
||||
Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside
|
||||
`AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar`
|
||||
(`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про
|
||||
X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model
|
||||
called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a
|
||||
latency and fixture defect, not a correctness one. The narrative rule reads the same
|
||||
`narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a
|
||||
joke, a bedtime story, herself — because the query chain has no source that answers those.
|
||||
New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) →
|
||||
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
|
||||
llama-server in that run), so judge it again before quoting a cascade number.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||
`internal/phraser/llmphraser.go`), with fallback to plain text and the legacy
|
||||
`{"body","summary"}`. Mood is a fixed enum. Router prompt is a separate contract:
|
||||
`[{"intent":<enum>, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder,
|
||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||
workspace enforces that the Go and relabelling prompts remain identical.
|
||||
|
||||
## Russian patterns — three mechanisms, no fourth
|
||||
|
||||
Hand-written Russian stem patterns were swept out on 2026-08-04 (owner's call: not a
|
||||
pattern, and the resident model cannot be asked per turn either). A regex whose output is a
|
||||
fact or a route is the defect; a regex over structured input — HTML, MIME, JSON, a URL, an
|
||||
argv list — is not. Before writing a Russian word list, pick one of these:
|
||||
|
||||
- **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives,
|
||||
capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months,
|
||||
spoken hours. Editing a word is a data change, and there is exactly one copy: months used
|
||||
to live in three files. Cardinals carry the oblique forms, because a spoken time declines
|
||||
and `в семь` / `к семи` are one hour.
|
||||
- **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm`
|
||||
and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb
|
||||
slot that means the imperative must be matched exactly — `говори` and `говорил` are one
|
||||
lemma and only one of them is a command (`cmd/mavend/quiet_toggle.go`).
|
||||
- **`cmd/mavend/topics.go` and the embedder** — open sets, where the question is what a
|
||||
turn is ABOUT. Frozen seeds per subject plus a real `other` class, scored against the
|
||||
turn's own query vector. Same shape as the personal boundary in `personalboundary.go`,
|
||||
with one difference: a topic must clear the runner-up by `topicMargin`, because a false
|
||||
claim here spends a network scan rather than one honest "не знаю". The old keyword tests
|
||||
stay as the offline floor and may remain narrow, since they are no longer the only answer.
|
||||
- **The ecosystem trio** — when the answer is not in the utterance at all. Identity is
|
||||
Nexus's, never a local pattern.
|
||||
|
||||
Seeds are scoring data. Editing one moves a recogniser and must be re-measured against the
|
||||
`TestONNX*` tests, not eyeballed.
|
||||
|
||||
## Non-goals (hard constraints)
|
||||
|
||||
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.
|
||||
- **His data first, then the world.** Every source that reads his facts, notes, calendar,
|
||||
tasks or house runs before anything outside, and the personal boundary sits between them.
|
||||
Reading beats recalling for a small model.
|
||||
- **In the world, live search leads and the ZIMs are the fallback** (owner's call,
|
||||
2026-08-02). A self-hosted SearXNG (`search` block) answers first; the Kiwix ZIMs on
|
||||
homesrv answer when the search is empty, unreachable, or the line is down.
|
||||
- **External search is allowed and off unless configured**, like the weather and telegram
|
||||
capabilities. The code default is still off. `deploy/mavend.json` now ships a `search`
|
||||
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
|
||||
it off again.
|
||||
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
|
||||
his stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||||
out, never the persona block, history, or matched notes.
|
||||
|
||||
## Web UI conventions
|
||||
|
||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
|
||||
partial in `cmd/mavweb/shell.html`: a page opens with `{{template "shellTop" "<page-key>"}}`
|
||||
and closes with `{{template "shellBottom"}}`, and the key marks the active sidebar link.
|
||||
Every page is its own embedded `.html` file next to `main.go` — no page markup lives in Go,
|
||||
and the sidebar is data (`sidebarSections`, `pageIcon`) the template renders. No
|
||||
per-page `<style>` beyond true one-offs. Wrap every table in `<div class=scroll>` so wide
|
||||
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||||
|
||||
## Vikunja
|
||||
|
||||
This repo is project **Maven** (ID 2) in Vikunja. MCP: `http://localhost:9100/mcp` (or
|
||||
`http://192.168.1.104:9100/mcp` from workpc). Feature/bug/deploy tasks go there.
|
||||
|
||||
Vikunja is the durable task store. A task holds the goal, the constraints and the
|
||||
assumption ledger. Work without a task id is work nobody can resume, so a session that
|
||||
has no id asks for one before it starts.
|
||||
- **No telemetry, no cloud model, no third-party account.** Inference stays on
|
||||
the box and nothing about Maven is reported to anyone.
|
||||
- **The owner's data first, then the world.** Every source reading his facts,
|
||||
notes, calendar, tasks or house runs first, and the personal boundary sits
|
||||
between them and anything outside.
|
||||
- **His notes and facts are never search input.** Only the utterance goes out,
|
||||
never the persona block, the history, or matched notes.
|
||||
- **External search is allowed and off unless configured.** Deleting the
|
||||
`search` block in `deploy/mavend.json` turns it off.
|
||||
- **`Response.Empty()` is the whole gate** on a world answer. There is no
|
||||
quality threshold in front of it and four candidate signals all failed.
|
||||
|
||||
## Session workflow
|
||||
|
||||
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
|
||||
session, one PR.
|
||||
`docs/workflow.md` carries the five stores, the doc tiers and the guards. One
|
||||
task, one session, one PR. `/pickup` opens a session and `/wrap` closes it. Wrap
|
||||
at roughly half context rather than letting the session compact.
|
||||
|
||||
```sh
|
||||
task start <vikunja-id> # branch off origin/master, write TASK.md, fetch review comments
|
||||
task pr # push, open or refresh the PR, label Vikunja, notify
|
||||
task comments # re-pull this branch's review comments into .task/
|
||||
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
|
||||
```
|
||||
|
||||
Around that, `/pickup` opens a session and `/wrap` closes it. Wrap at roughly half
|
||||
context rather than letting the session compact.
|
||||
|
||||
Five stores, and each one owns something the others must not hold:
|
||||
|
||||
| Store | Holds | Lifetime |
|
||||
|---|---|---|
|
||||
| Vikunja task | goal, constraints, assumption ledger, status | durable |
|
||||
| `CLAUDE.md`, `AGENTS.md` | what an agent must know before touching code | durable |
|
||||
| `docs/` | design, measurements, decisions | durable |
|
||||
| `TASK.md` | the brief for this branch, written by `task start`, immutable | one branch |
|
||||
| `HANDOFF.md` | only what the next agent needs to resume | one session |
|
||||
|
||||
`TASK.md` and `.task/` are excluded through `.git/info/exclude`. `HANDOFF.md` is
|
||||
gitignored and injected at session start. If a line in the handoff would still matter
|
||||
next week, it is in the wrong file.
|
||||
|
||||
Docs are tiered by path, so staleness is visible from the filename. Files directly under
|
||||
`docs/` are living and carry a `Last verified: <date> @ <sha>` line. Files under
|
||||
`docs/evals/` are dated measurements and are never edited after the day, so a newer
|
||||
number is a new file. Files under `docs/archive/` are dead and read by nobody by default.
|
||||
|
||||
## Git guards
|
||||
|
||||
Two hooks in `.githooks/`, tracked, wired with `core.hooksPath`. Fresh clone:
|
||||
|
||||
```sh
|
||||
git config core.hooksPath .githooks
|
||||
```
|
||||
|
||||
- `pre-commit` refuses master, and refuses more than 300 changed lines in non-markdown
|
||||
files. Markdown is exempt and may land as one batch.
|
||||
- `commit-msg` requires the subject to end with `(V-<id>)`. `V-` and not `#`, because
|
||||
Gitea autolinks `#123` to a Gitea issue, which is the wrong tracker.
|
||||
|
||||
Two more guards live outside the repo, in `~/.claude/hooks/`. `diff-budget.sh` blocks
|
||||
further edits past 600 changed lines on a `task/` branch. `prose_lint_hook.py` checks
|
||||
prose on every write. Both measure against `origin/master`, so a local master that is
|
||||
ahead of the remote makes the diff budget read high.
|
||||
|
||||
`--no-verify` exists. Using it means saying why in the commit body.
|
||||
- This repo is Vikunja project **Maven** (ID 2), MCP at
|
||||
`http://localhost:9100/mcp`, or `http://192.168.1.104:9100/mcp` from workpc.
|
||||
- **A session with no task id asks for one before it starts**, because work
|
||||
without one is work nobody can resume.
|
||||
- **Close a finished task with `done: true` and nothing else** (owner's call,
|
||||
2026-08-07). `update_task` carrying a `description` resets `done` to false.
|
||||
- **`pre-commit` refuses master** and more than 300 changed lines in
|
||||
non-markdown files. Markdown is exempt and may land as one batch.
|
||||
- **`commit-msg` requires the subject to end with `(V-<id>)`.** `V-` and not
|
||||
`#`, because Gitea autolinks `#123` to the wrong tracker.
|
||||
- **`diff-budget.sh` blocks edits past 600 changed lines** on a `task/` branch.
|
||||
- **`--no-verify` exists.** Using it means saying why in the commit body.
|
||||
|
||||
+11
@@ -82,11 +82,22 @@ FROM debian:trixie-slim AS runtime
|
||||
# tzdata so the TZ env (set in compose) resolves — otherwise Go can't load the
|
||||
# zone and time.Now() stays UTC, and mavend answers clock/date queries and
|
||||
# evaluates quiet-hours in UTC.
|
||||
#
|
||||
# TZ is a build arg as well as an env because the image was self-inconsistent
|
||||
# without it (V-545): compose set TZ=Europe/Samara and Go read it, but
|
||||
# /etc/localtime still pointed at Etc/UTC, so anything asking the system zone
|
||||
# instead of the environment answered UTC. The reminder path shells out to
|
||||
# python dateparser, which is exactly such a caller. Compose passes the same
|
||||
# zone it already declares, so the zone is written in one place.
|
||||
ARG TZ=Etc/UTC
|
||||
ENV TZ=$TZ
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 tzdata \
|
||||
python3 python3-pip && \
|
||||
pip3 install --no-cache-dir --break-system-packages 'dateparser==1.4.1' && \
|
||||
apt-get purge -y --auto-remove python3-pip && \
|
||||
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime && \
|
||||
echo "$TZ" > /etc/timezone && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# runtime native libs: whisper/ggml (incl. vulkan) are real files in deps/lib.
|
||||
|
||||
+341
@@ -0,0 +1,341 @@
|
||||
# Maven completion journal
|
||||
|
||||
This journal tracks the autonomous completion goal started on 2026-08-13. It
|
||||
is an operational index, not a substitute for living subsystem documentation,
|
||||
dated evaluations, Vikunja tasks, or focused caveat entries.
|
||||
|
||||
## 2026-08-13 — baseline and backlog reconstruction
|
||||
|
||||
Goal: make Maven usable end to end with every current and planned feature
|
||||
wired, tested, and polished. Completion requires clean automated gates and
|
||||
successful sessions across local, degraded-ecosystem, and integrated modes.
|
||||
|
||||
Initial observations:
|
||||
|
||||
- `HEAD` is `2cf8b7e`, identical to both local and remote `master`, while the
|
||||
checked-out branch is the stale `task/704-...` branch.
|
||||
- The worktree already contained staged documentation/evaluation changes,
|
||||
staged transcript deletions, an unstaged `deploy/mavwaked.service` change,
|
||||
and untracked `deploy/asoundrc`. These are pre-existing work and are being
|
||||
preserved and validated before any commit.
|
||||
- The repository has no prior goal journal. Durable subsystem facts continue
|
||||
to belong under `docs/`; unresolved limits continue to belong under
|
||||
`docs/caveats/` with a task and revisit trigger.
|
||||
|
||||
Work streams started:
|
||||
|
||||
- Vikunja project 2: inventory every open task and recover acceptance criteria.
|
||||
- Repository: compare feature plans, caveats, routing/ecosystem contracts, and
|
||||
implementation.
|
||||
- Verification: run build, race tests, simulator, analyzer gates, and inspect
|
||||
skipped hardware/model evaluations separately.
|
||||
- Runtime: exercise the web, IPC, voice, model, and sibling-service paths with
|
||||
real local dependencies where available and explicit degraded-mode probes
|
||||
otherwise.
|
||||
|
||||
References: `docs/workflow.md`, `docs/qa.md`, `docs/ecosystem.md`,
|
||||
`docs/routing.md`, `docs/caveats/CLAUDE.md`, and Vikunja Maven project 2.
|
||||
|
||||
### Backlog correction
|
||||
|
||||
The first Vikunja page was accidentally read without a `done: false` filter and
|
||||
mixed closed history into the working set. Re-querying all pages strictly open
|
||||
produced 127 records: 89 implementation-open, 20 shipped-but-QA-open, 12
|
||||
external/owner gates, and 6 duplicate or stale-open records. Closed tasks are
|
||||
used only as commit provenance; they are not work to redo. V-704 was the only
|
||||
open hanging task and was closed after its measured correction landed.
|
||||
|
||||
### Model-aware baseline
|
||||
|
||||
The ordinary `make test` passed but does not set `MAVEN_ONNX_LIB`, so model-aware
|
||||
tests can self-skip. The explicit ONNX boundary gate exposed V-702/V-703: the
|
||||
held-out `я рассказывал тебе про байкал?` was the sole miss at 28/29. A
|
||||
three-neighbour class score fixed the semantic collision without adding a word
|
||||
pattern or copying the held-out sentence. Boundary is now 29/29 and the adjacent
|
||||
topic gate remains 43/43. Measurement:
|
||||
`docs/evals/2026-08-13-personal-boundary-neighbourhood.md`.
|
||||
|
||||
That narrow result was not accepted as the completion gate. A second agent
|
||||
wrote a balanced 72-case RU/EN matrix across remembered speech, possession,
|
||||
narrative, proper nouns, personal preambles, and advice/current questions. It
|
||||
contains no production seeds and no Baikal paraphrase. The top-three candidate
|
||||
scores only 61/72 (84.7%); top-two reaches 62/72, one-neighbour 56/72, and a
|
||||
whole-class centroid 54/72. V-702 therefore remains open while a principled
|
||||
classifier is developed against the independent matrix. The 29/29 measurement
|
||||
describes the narrow regression set, not general boundary quality.
|
||||
|
||||
### Live delivery incident
|
||||
|
||||
The five-service compose stack was running, but a due reminder was being
|
||||
re-phrased and retried through ntfy every tick. The sink returned HTTP 403 each
|
||||
time. Only secret names were inspected: the configured ntfy and workstation
|
||||
token variables were absent from the deployed environment file; no secret
|
||||
values were read into this journal. The durable outbox records each failed
|
||||
attempt, but the retry path has no backoff or alternate channel and spends the
|
||||
resident model again before every failure. This is active V-651 behavior, with
|
||||
the repeated-phrasing shape related to V-687.
|
||||
|
||||
V-715 now owns the incident acceptance criteria. In the working tree, phrases,
|
||||
collapsed-group identity, attempts, and next-attempt time are durable; definite
|
||||
failure backs off from one minute to a capped hour; retries and restarts reuse
|
||||
the exact phrase; and away delivery tries ntfy then Telegram, stopping at the
|
||||
first success. The committed deployment explicitly disables the uncredentialed
|
||||
ntfy block. Independent review added a real occurrence key for collapsed
|
||||
bundles, suppresses crash-ambiguous attempts from automatic replay, classifies
|
||||
HTTP 401/403 as permanent, blocks permanently unreachable reminders visibly,
|
||||
and commits the successful outbox result plus every collapsed original in one
|
||||
SQLite transaction. The store, delivery, IPC, loop, config, and mavweb race
|
||||
suites pass. A live rebuild and one-time delivery of the existing backlog are
|
||||
still required before V-715 is closed.
|
||||
|
||||
### Explicit integration enablement
|
||||
|
||||
V-691's deployment boundary was audited against every `${VAR}` reference. The
|
||||
canonical `deploy/telegram.env.example` now names Telegram, ntfy, workstation
|
||||
model, workstation STT, Home Assistant, ambient, CW2, and database-key inputs.
|
||||
Enabled Telegram, ntfy, ambient, non-loopback workstation model, and
|
||||
non-loopback workstation STT paths refuse missing credentials; each arm has an
|
||||
explicit disabled state. The live config disables the currently uncredentialed
|
||||
ntfy and workstation-model arms while retaining the separately credentialed
|
||||
STT arm. CW2 also refuses a non-loopback bind without its token.
|
||||
|
||||
Focused Go race suites, the Python CW2 startup contract, deploy-config drift
|
||||
test, secret-expanded config validation (values not printed), and
|
||||
`docker compose config --quiet` pass. The workpc is unreachable from this host,
|
||||
so installing the updated CW2 script there remains an external deployment step;
|
||||
the affected model arm is explicitly dark rather than ambiguously half-live.
|
||||
|
||||
### Traceable web failures
|
||||
|
||||
V-689 gives every mavweb response a server-generated request ID and routes
|
||||
every handler failure through one sanitized problem envelope. Stable error
|
||||
codes and the request ID reach the browser; the wrapped internal error reaches
|
||||
only the server log beside the same ID. Degraded inline panels use stable public
|
||||
text rather than backend paths or tokens, and direct ecosystem reads propagate
|
||||
the web request ID as their correlation ID. An AST guard prevents new production
|
||||
handlers from bypassing the contract with `http.Error`. The full mavweb race
|
||||
suite passes, including disclosure, untrusted-ID, log-join, and propagation
|
||||
tests.
|
||||
|
||||
### Bounded external responses
|
||||
|
||||
Three audit defects were repaired and committed directly to `master`:
|
||||
|
||||
- V-608 (`d7e8804`): llama completion responses are capped at 1 MiB, including
|
||||
the LAN workstation seam.
|
||||
- V-675 (`459fe7a`): remote STT requires nonblank text and an explicit finite
|
||||
confidence in `[0,1]`, caps JSON at 64 KiB, and falls back to mavsttd on a
|
||||
malformed HTTP 200.
|
||||
- V-676 (`7d0250a`): Open-Meteo geocoding and forecasts are bounded, required
|
||||
fields are nullable/validated, and coordinates/weather values are range
|
||||
checked so `{}` cannot become plausible zero-degree weather.
|
||||
|
||||
Each focused race suite passed and each task was closed only after the commit.
|
||||
|
||||
### Transport shutdown
|
||||
|
||||
V-679 (`de61b75`) adds the listener's `done` channel to TCP `Accept`. A
|
||||
concurrent-close test holds a silent peer in handshake and proves an in-flight
|
||||
accept returns `net.ErrClosed`; the race test passed twenty consecutive runs.
|
||||
|
||||
V-688 (`80b6068`, caveat retirement `a0e6643`) bounds the browser push-to-talk
|
||||
body at ten minutes of mono PCM and configures header, idle, and read limits on
|
||||
the web server. The unused `/ws` handler was removed instead of retaining a
|
||||
second unauthenticated streaming transport with no browser caller. Focused race
|
||||
tests prove the exact-size request succeeds and an oversized request returns
|
||||
HTTP 413.
|
||||
|
||||
### Conversation continuity
|
||||
|
||||
V-542 (`da9114b`) repairs the five-turn monitor conversation without changing
|
||||
single-turn intent classification. Exact user utterances are now persisted
|
||||
separately from normalized intent slots and retained in chronological order.
|
||||
An anaphoric query with live transcript context reaches the chat path, while
|
||||
non-anaphoric sources are unchanged and acts stay fail-closed. An explicit
|
||||
conversation opener extends the session lifetime through later fact/query/chat
|
||||
routes without suppressing the grounded fact write.
|
||||
|
||||
The deterministic scenario now names the monitor in all four contextual
|
||||
replies, proves that the original raw turn reached `PhraseChat` four times,
|
||||
stores the fact once, and produces zero unsolicited sends across five turns and
|
||||
one tick. Focused race tests passed for `cmd/mavend`, `internal/dialogue`,
|
||||
`internal/router`, and `internal/lexicon`. Measurement:
|
||||
`docs/evals/2026-08-13-conversation-continuity.md`.
|
||||
|
||||
### Personal-data boundary
|
||||
|
||||
V-702 replaces the narrow nearest-neighbour privacy gate with a frozen,
|
||||
class-balanced logistic head over multilingual-e5-small. It introduces no
|
||||
lexical exception and leaves the decision threshold at 0.5. Historical
|
||||
regressions score 29/29 and the balanced 72-case RU/EN fixture scores 72/72.
|
||||
|
||||
The first 24-case challenge found one private-configuration miss. That result
|
||||
was treated as model-selection data rather than advertised as independent
|
||||
proof. Shrinkage LDA and an LDA/logistic ensemble repaired it but regressed the
|
||||
72-case gate, so both were rejected. Increasing the logistic L2 coefficient
|
||||
from 0.0001 to 0.0003 repairs the miss while improving four-fold corpus
|
||||
cross-validation from 97/104 to 99/104 and whole-shape holdout from 91/104 to
|
||||
92/104. A fresh 24-case challenge written only after that head was frozen scores
|
||||
24/24 at minimum signed probability margin +0.1718. The original challenge is
|
||||
also 24/24 but its +0.0001 edge remains documented as a regression, not fresh
|
||||
evidence. Full measurement:
|
||||
`docs/evals/2026-08-13-personal-boundary-linear-head.md`.
|
||||
|
||||
During the audit, running multiple ONNX-backed tests in one `go test` process
|
||||
showed that only the first initializes; later tests self-skip because the
|
||||
runtime is process-global. All V-702 figures were therefore rerun in separate
|
||||
processes. V-716 tracks fixing that harness gap rather than hiding it in this
|
||||
feature.
|
||||
|
||||
### Forced dialogue and repair state
|
||||
|
||||
V-573 closes all four repair seams exposed by the dialogue contract: a
|
||||
correction wins before a parked clarify answer; a repaired decision is checked
|
||||
for required slots before acting; a request completed through clarification is
|
||||
correctable; and declined or stale repairs do not prematurely spend the repair
|
||||
pointer. Same-intent corrections are handled explicitly without redoing the
|
||||
action, so their prose cannot route fresh and overwrite the retained pointer.
|
||||
|
||||
The independent state audit found two deeper stack losses. A handled repair
|
||||
could leave an older question silently parked with its old TTL, and a repaired
|
||||
request needing clarification could overwrite—or, on completion, delete—the
|
||||
older flow. Repairs now suspend and audibly resume live questions, repaired
|
||||
questions push onto the bounded dialogue stack, and completion/cancellation
|
||||
pops only the active top before resuming the flow underneath.
|
||||
|
||||
`MAVEN_DIALOGUE_NO_SKIP=1 go test -race ./cmd/mavend -run
|
||||
'^TestDialogueTraces$' -count=1` passes all 22 traces. The complete forced
|
||||
`cmd/mavend` race suite passes in 208.031s. The integrated race command over
|
||||
`cmd/mavend`, `internal/dialogue`, and all `internal/router` packages also
|
||||
passes (162.310s for mavend; every package green). Focused structural
|
||||
possession, repair-pointer, nested-stack, and repaired-clarify tests pass under
|
||||
the race detector.
|
||||
|
||||
### ONNX test/runtime lifecycle
|
||||
|
||||
V-716 found that each embedder constructor tried to initialize ONNX Runtime,
|
||||
while `Close` destroyed only its session. In one package process the first
|
||||
model-aware test ran and later tests converted “already initialized” into a
|
||||
green skip. The router now owns the process-global environment through
|
||||
reference-counted leases held by each embedder and routing-head session; the
|
||||
last owned lease performs cleanup, and close is idempotent.
|
||||
|
||||
The router and mavend test packages hold a lease across their model gates.
|
||||
`make eval-router` additionally requires proof that both named aggregate gates
|
||||
actually executed. In one process the classifier baseline scored 72/96 and the
|
||||
routing heads 93/96; destination was 11/33 and 25/33 respectively, and ecosystem
|
||||
reach remained 28/30. The lifecycle reacquire test, focused race suite, full
|
||||
aggregate command, and portable no-runtime packages all pass. Measurement:
|
||||
`docs/evals/2026-08-13-onnx-runtime-lifecycle.md`.
|
||||
|
||||
### Clarification exhaustion is fail-closed
|
||||
|
||||
V-717 closes the terminal-policy hole found during the V-573 audit. A request
|
||||
with two required gaps could spend its only question on the first, fill that
|
||||
slot, and then reach `applyAction` with the second still absent. The attempt cap
|
||||
was accidentally acting as permission to execute a partial action.
|
||||
|
||||
The resolver now rebuilds the pending action and re-runs the canonical
|
||||
`missingFor` schema after every filled gap. One remaining gap produces exactly
|
||||
one next question only while the shared `PendingAction.CanAsk` budget permits
|
||||
it. Exhaustion visibly gives up, removes only the active stack level, and makes
|
||||
no write or action. `finishRebuilt` repeats the same invariant at the execution
|
||||
boundary. Reminder time answers remain separate from the clean payload but are
|
||||
included in the schema decision used for validation.
|
||||
|
||||
The original `TestClarifySecondGapRespectsTheAttemptCap` now asserts the exact
|
||||
give-up and zero reminders. New tests cover direct boundary refusal and a
|
||||
two-level stack where exhausting the top appends the surviving lower question
|
||||
to the same reply. The focused V-717 race cases pass in 4.529s; every clarify
|
||||
case plus all 22 forced dialogue traces pass under the race detector in
|
||||
26.202s; `internal/dialogue` passes under race in 2.293s. Routing contract:
|
||||
`docs/routing.md` section “Required slots and attempt exhaustion”.
|
||||
|
||||
### A suppressed nudge is identified before it is phrased
|
||||
|
||||
V-687 closes the phrase-before-dedupe hole in the digestion worker. The dedupe
|
||||
was reported by `EnqueueDigestEntry`, which runs after `PhraseNudge` has already
|
||||
been paid, and the `else if deduped { continue }` meant to skip the cost was the
|
||||
last statement in the loop body. Every tick that kept suppressing the same rule
|
||||
spent the resident model again, against the cache claim in the comment above it.
|
||||
|
||||
The fix gives a rule a durable semantic identity instead of hashing its prose. A
|
||||
rule eligible for the digest declares `DigestIdentity`, a function of state
|
||||
beside its predicate; `loop.DigestCandidateFingerprint` frames the rule name and
|
||||
severity around it so two rules cannot alias on a shared fact. `BreakRule`
|
||||
anchors on the last completed break rather than on `desk_active`, which is
|
||||
freshness evidence the poller refreshes without the unmet need changing. A rule
|
||||
with no declared identity does not enter the digest, because inventing a generic
|
||||
state hash would either change every tick or ignore an input the rule reads.
|
||||
|
||||
`tick_digest.go` now looks up `LiveDigestEntry` by rule and fingerprint before
|
||||
phrasing. Migration #26 adds `candidate_fingerprint` with a partial unique index
|
||||
over live pending rows; `EnqueueDigestEntry` expires a matching stale row and
|
||||
inserts inside one transaction, so sweep order is not part of correctness and a
|
||||
second caller cannot race the pre-phrase read into a duplicate. Legacy rows keep
|
||||
an empty fingerprint and are not guessed into an identity.
|
||||
|
||||
Six tests cover the contract: one phrase call across three suppressed ticks,
|
||||
zero after a daemon restart, and two when the meaning changes, when the entry
|
||||
expires, and when it has been drained. `./cmd/mavend/ -run TestSuppressedCareDigest`
|
||||
passes under race in 4.626s, the digest store and loop cases in 4.123s and
|
||||
1.046s, and the three full packages in 264.076s, 64.496s and 4.280s. The caveat
|
||||
`docs/caveats/workers.md#nudges` and the `SA4006` baseline entry are deleted.
|
||||
|
||||
## 2026-08-15 — authoritative-state continuation
|
||||
|
||||
The continuation began by treating the checked-out tree and external task state
|
||||
as authoritative. `master` was clean, identical to `origin/master`, and already
|
||||
contained the V-717 and V-687 branch merges. Their interrupted worker messages
|
||||
were therefore not used as evidence of missing work.
|
||||
|
||||
### Live reminder catch-up closes V-570 and V-715
|
||||
|
||||
The rebuilt stack loaded the resident Qwen model, multilingual ONNX embedder,
|
||||
and routing heads. On its first eligible tick it phrased the three overdue
|
||||
reminders once as one catch-up message. Disabled ntfy left one durable failed
|
||||
attempt for delivery group `reminder #83`; ordered fallback then left exactly
|
||||
one successful Telegram attempt for the same group. All originals became
|
||||
`fired`. Four later ticks produced no second phrase, send, pending attempt, or
|
||||
unknown attempt. `/`, `/reminders`, and `/notifications` each returned HTTP 200
|
||||
with a server-generated request ID. This supplies the live evidence that was
|
||||
still missing in the 2026-08-13 entry; V-715 and the stale-open V-570 are closed.
|
||||
|
||||
The first HTTP repro command also exposed a zsh test-harness trap: `path` is a
|
||||
special array tied to `PATH`, so using it as a loop variable removed command
|
||||
lookup inside the loop. The corrected probe used `probe_path` and `wget
|
||||
--no-proxy`; the deployed web process had not crashed.
|
||||
|
||||
### Stale-open task reconciliation
|
||||
|
||||
The open Vikunja list was read with `done: false` and compared with the current
|
||||
tree before choosing work. V-397 already described itself as done and merged;
|
||||
V-557 is present as `ea0eb16` plus the forced missing-slot dialogue contract;
|
||||
V-570 is covered by the live catch-up proof above. Those three records were
|
||||
closed rather than reimplemented. V-651 remains open: its nil-sink and reminder
|
||||
spin halves landed with V-715, but the first failed severity-4 Telegram send is
|
||||
still not represented in the repeat-until-ack stream.
|
||||
|
||||
### Stable hash-floor performance evidence [V-718]
|
||||
|
||||
`TestPersonalBoundaryHashFloorLatency` coupled correctness to ambient machine
|
||||
load while running a numeric training loop under race and coverage
|
||||
instrumentation. It is now a deterministic fit-and-score test that also proves
|
||||
the 1024-dimensional head was built. Elapsed time remains observable through
|
||||
`BenchmarkPersonalBoundaryHashFloorFitAndScore`, where three one-iteration runs
|
||||
on this host measured 75.1 ms, 76.8 ms and 81.4 ms without making those host
|
||||
figures a CI pass condition.
|
||||
|
||||
The exact race-plus-coverage focused test passed in 9.445s. `make test` then
|
||||
passed on its first run: formatting, vet, CW2 configuration tests, and every
|
||||
internal and command package under race plus coverage; `cmd/mavend` completed
|
||||
in 205.712s. This closes V-718 without raising a brittle timeout.
|
||||
|
||||
### Delegation availability
|
||||
|
||||
All three available subagent slots were filled: clarification exhaustion,
|
||||
durable nudge identity, and a read-only live deployment probe. The first two
|
||||
left complete merges on `master`; all three later reported the same shared
|
||||
Codex usage limit, with capacity unavailable until 2026-08-20. Work continues
|
||||
serially. The temporary constraint and revisit trigger are recorded at
|
||||
`docs/caveats/workers.md#agent-quota` under the V-714 completion umbrella.
|
||||
@@ -4,7 +4,7 @@
|
||||
# `test` below fail on the two packages that have no test files. deps-go builds
|
||||
# the missing tools in, so the vendored tree is self-sufficient. Keep the version
|
||||
# here in step with the `go` directive in go.mod.
|
||||
GO_VERSION := 1.25.5
|
||||
GO_VERSION := 1.25.12
|
||||
GO := $(shell pwd)/deps/go/go/bin/go
|
||||
export GOTOOLCHAIN := local
|
||||
GOFLAGS :=
|
||||
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
|
||||
.PHONY: t audit simulate stt-fixtures test-stt-golden test-cw2-config all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel deps-vuln vuln deps-lint lint deadcode analyze tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
|
||||
|
||||
all: build
|
||||
|
||||
@@ -73,7 +73,7 @@ run-web: build-web
|
||||
# builds them on demand, but `go test -coverprofile` calls covdata through
|
||||
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
|
||||
GO_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
||||
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
||||
GO_SHA256 := 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1
|
||||
deps-go: deps-sentinel
|
||||
@mkdir -p deps/go
|
||||
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
||||
@@ -95,6 +95,70 @@ deps-sentinel:
|
||||
@mkdir -p deps
|
||||
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
|
||||
|
||||
# vuln — the advisory gate the 2026-08-10 audit found missing (V-682). It reads
|
||||
# the published database over the network, so it is not part of `test`, which
|
||||
# has to pass on a box with no route out. Run it before a toolchain or
|
||||
# dependency bump lands, because that is what it grades: on 2026-08-11 the
|
||||
# pinned Go 1.25.5 and x/text 0.14.0 carried 20 reachable advisories and the
|
||||
# bumped pair carries none.
|
||||
#
|
||||
# govulncheck is a tool and not a dependency, so it is installed into deps/ like
|
||||
# the toolchain rather than added to go.mod. The version is pinned here for the
|
||||
# same reason GO_VERSION is: a gate that moves on its own is not a gate.
|
||||
GOVULNCHECK_VERSION := v1.6.0
|
||||
GOVULNCHECK := $(shell pwd)/deps/bin/govulncheck
|
||||
|
||||
deps-vuln: deps-sentinel
|
||||
@mkdir -p deps/bin
|
||||
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
|
||||
$(GO) install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
|
||||
|
||||
# The CGO env is the same one `test` carries: govulncheck loads the packages,
|
||||
# and the four CGO daemons do not load without it.
|
||||
vuln: deps-vuln
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local $(GOVULNCHECK) ./...
|
||||
|
||||
# lint and deadcode — the other two analyzers the 2026-08-10 audit asked for
|
||||
# (V-694). They are not part of `test` for the same reason `vuln` is not: they
|
||||
# install over the network, and they are slow enough that a change to one Go
|
||||
# file should not pay for them.
|
||||
#
|
||||
# Neither reports zero, so neither fails on its own output. The accepted set
|
||||
# lives in scripts/analyzers/*.baseline and scripts/analyzer-gate.sh decides.
|
||||
# What is new fails, and so does a baseline entry whose finding is gone.
|
||||
#
|
||||
# deadcode runs with -test, so a test file is a root. Without it the report is
|
||||
# 172 lines, most of internal/router/eval, and none of it is a mistake.
|
||||
STATICCHECK_VERSION := v0.7.0
|
||||
DEADCODE_VERSION := v0.48.0
|
||||
STATICCHECK := $(shell pwd)/deps/bin/staticcheck
|
||||
DEADCODE := $(shell pwd)/deps/bin/deadcode
|
||||
|
||||
deps-lint: deps-sentinel
|
||||
@mkdir -p deps/bin
|
||||
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
|
||||
$(GO) install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)
|
||||
GOTOOLCHAIN=local GOBIN=$(shell pwd)/deps/bin \
|
||||
$(GO) install golang.org/x/tools/cmd/deadcode@$(DEADCODE_VERSION)
|
||||
|
||||
# Both load the packages, so both carry the CGO env `test` carries. Without it
|
||||
# the four CGO daemons do not load and the analyzer reports a build error
|
||||
# instead of a finding -- which analyzer-gate.sh fails on rather than filters.
|
||||
ANALYZER_ENV = CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" \
|
||||
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
PATH="$(shell pwd)/deps/go/go/bin:$$PATH" GOTOOLCHAIN=local
|
||||
|
||||
lint: deps-lint
|
||||
@$(ANALYZER_ENV) $(STATICCHECK) ./... | scripts/analyzer-gate.sh staticcheck
|
||||
|
||||
deadcode: deps-lint
|
||||
@$(ANALYZER_ENV) $(DEADCODE) -test ./... | scripts/analyzer-gate.sh deadcode
|
||||
|
||||
# Every static gate in one command. Not `check`, because it is not the thing to
|
||||
# run before a commit: vuln reads the network and all three are slow.
|
||||
analyze: lint deadcode vuln
|
||||
|
||||
# Run the tidy the sentinel makes possible. Not part of `test`: it rewrites
|
||||
# go.mod, and a build target that edits the module file is a surprise.
|
||||
# vendor/ is committed, so a tidy that drops a requirement must be followed by
|
||||
@@ -124,19 +188,55 @@ simulate:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -v -count=1 -run TestSimulator ./cmd/mavend/
|
||||
|
||||
test: fmt-check vet
|
||||
test-cw2-config:
|
||||
python3 -m unittest discover -s deploy/cw2 -p 'test_*.py'
|
||||
|
||||
test: fmt-check vet test-cw2-config
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
||||
|
||||
# t — run ONE package or ONE test with the toolchain env already wired. This is
|
||||
# the iteration target; `test` is the gate. Reach for it instead of pasting the
|
||||
# CGO_CFLAGS/CGO_LDFLAGS/LD_LIBRARY_PATH preamble by hand, which is how it was
|
||||
# done ~390 times across past sessions and is where the shell-quoting failures
|
||||
# came from -- the interactive shell here is zsh, and an unquoted `-run Test*`
|
||||
# or `--include=*.go` dies on "no matches found" before go ever starts.
|
||||
#
|
||||
# make t # whole tree (same scope as `test`)
|
||||
# make t PKG=./internal/router/
|
||||
# make t PKG=./cmd/mavend/ RUN=TestSimulator
|
||||
# make t PKG=./internal/router/eval/ RUN='TestONNX' V=1
|
||||
# make t PKG=./internal/store/ RACE=0 # drop -race when iterating hot
|
||||
#
|
||||
# -race is on by default so a green `make t` cannot turn red under `make test`.
|
||||
# -count=1 because a cached PASS from before your edit is worse than no answer.
|
||||
# MAVEN_ONNX_LIB is set for the same reason: the four TestONNX* measurements
|
||||
# self-skip when it is unset, so a targeted eval run would otherwise report the
|
||||
# deterministic hash ratchet and look like it scored the real embedder.
|
||||
PKG ?= ./internal/... ./cmd/...
|
||||
RUN ?=
|
||||
V ?=
|
||||
RACE ?= 1
|
||||
|
||||
t:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" \
|
||||
$(GO) test $(if $(V),-v,) $(if $(filter-out 0,$(RACE)),-race,) -count=1 \
|
||||
$(if $(RUN),-run '$(RUN)',) $(PKG)
|
||||
|
||||
# eval-router — score the held-out RU routing fixture (internal/router/eval).
|
||||
# Verbose so the report tables land in the terminal. MAVEN_ONNX_LIB points the
|
||||
# prod-representative baseline at the vendored runtime; override it or set it
|
||||
# empty to run only the deterministic hash ratchet. This is the measurement
|
||||
# Vikunja #319 compares before #320 flips the route decider.
|
||||
# Vikunja #319 compares before #320 flips the route decider. With a non-empty
|
||||
# runtime path the package must prove that at least two model gates executed;
|
||||
# a constructor skip after the first process-global initialization is a failure.
|
||||
MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so
|
||||
|
||||
eval-router:
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" \
|
||||
MAVEN_ONNX_REQUIRED_GATES="$(if $(MAVEN_ONNX_LIB),2,0)" \
|
||||
$(GO) test -v -count=1 ./internal/router/eval/
|
||||
|
||||
# eval-reach — score the held-out ecosystem reach fixture (internal/router/eval,
|
||||
# ru_ecosystem_v1.json). Answers "does a real Russian utterance actually arrive
|
||||
@@ -189,6 +289,16 @@ eval-models:
|
||||
# scores the fixtures against ggml-small and self-skips when the model is
|
||||
# absent, and TestGoldenFixturesAreCanonical, which checks the committed audio
|
||||
# and the manifest with no model at all.
|
||||
# audit — the repo inventory: LOC per package, open TODOs, real stubs, living-doc
|
||||
# staleness, test shape, packages with no test. Read-only, prints, writes nothing.
|
||||
# Run it instead of rebuilding the same greps by hand; past sessions spent 93 of
|
||||
# them on this before their first edit. SECTION=loc|todo|stubs|docs|tests|gaps
|
||||
# narrows it.
|
||||
SECTION ?= all
|
||||
|
||||
audit:
|
||||
@SECTION="$(SECTION)" ./scripts/audit.sh
|
||||
|
||||
stt-fixtures:
|
||||
./scripts/gen-stt-fixtures.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// e2eprobe is a temporary typed IPC driver used by the 2026-08-15 isolated
|
||||
// whole-Maven acceptance session. It is removed after the session; keeping the
|
||||
// driver inside the module lets it import Maven's internal IPC contract rather
|
||||
// than peeking into sqlite.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "e2eprobe:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
fs := flag.NewFlagSet("e2eprobe", flag.ContinueOnError)
|
||||
sock := fs.String("sock", "", "mavend unix socket")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
argv := fs.Args()
|
||||
if len(argv) == 0 {
|
||||
return errors.New("usage: e2eprobe -sock PATH COMMAND [ARGS]")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
if argv[0] == "score-pair" {
|
||||
out, err := scorePair(ctx, argv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return encode(out)
|
||||
}
|
||||
if argv[0] == "parse-task-status" {
|
||||
if len(argv) != 2 {
|
||||
return errors.New("parse-task-status needs TEXT")
|
||||
}
|
||||
parsed, ok := router.ParseTaskStatus(argv[1])
|
||||
return encode(map[string]any{"accepted": ok, "parsed": parsed})
|
||||
}
|
||||
if *sock == "" {
|
||||
return errors.New("usage: e2eprobe -sock PATH COMMAND [ARGS]")
|
||||
}
|
||||
|
||||
cli, err := ipc.DialWait(*sock, 15*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cli.Close()
|
||||
|
||||
var out any
|
||||
switch argv[0] {
|
||||
case "ping":
|
||||
out, err = cli.Ping(ctx)
|
||||
case "chat":
|
||||
if len(argv) < 3 {
|
||||
return errors.New("chat needs CONVERSATION TEXT")
|
||||
}
|
||||
out, err = cli.Chat(ctx, argv[1], strings.Join(argv[2:], " "))
|
||||
case "create-reminder":
|
||||
if len(argv) < 3 || len(argv) > 4 {
|
||||
return errors.New("create-reminder needs RFC3339 TEXT [CRON]")
|
||||
}
|
||||
fire, parseErr := time.Parse(time.RFC3339, argv[1])
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
cron := ""
|
||||
if len(argv) == 4 {
|
||||
cron = argv[3]
|
||||
}
|
||||
id, createErr := cli.CreateReminder(ctx, fire, `{"text":`+quote(argv[2])+`}`, cron)
|
||||
out, err = map[string]any{"id": id}, createErr
|
||||
case "cancel-reminder":
|
||||
id, parseErr := oneID(argv)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
err = cli.CancelReminder(ctx, id)
|
||||
out = map[string]any{"cancelled": id}
|
||||
case "mark-reminder":
|
||||
if len(argv) != 3 {
|
||||
return errors.New("mark-reminder needs ID STATUS")
|
||||
}
|
||||
id, parseErr := strconv.ParseInt(argv[1], 10, 64)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
err = cli.MarkReminder(ctx, id, argv[2])
|
||||
out = map[string]any{"marked": id, "status": argv[2]}
|
||||
case "reminders":
|
||||
n, parseErr := optionalN(argv, 200)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.ListReminders(ctx, n)
|
||||
case "pending-reminders":
|
||||
n, parseErr := optionalN(argv, 0)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.ListPendingReminders(ctx, n)
|
||||
case "create-task":
|
||||
if len(argv) != 2 {
|
||||
return errors.New("create-task needs TEXT")
|
||||
}
|
||||
out, err = cli.CaptureTask(ctx, ipc.CaptureTaskReq{
|
||||
Text: argv[1], Source: "tap:web", Status: store.TaskOpen, Ts: time.Now(),
|
||||
})
|
||||
case "tasks":
|
||||
status := "live"
|
||||
if len(argv) == 2 {
|
||||
status = argv[1]
|
||||
} else if len(argv) != 1 {
|
||||
return errors.New("tasks takes optional STATUS")
|
||||
}
|
||||
out, err = cli.ListTasks(ctx, status)
|
||||
case "notes":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentNotes(ctx, n)
|
||||
case "query-notes":
|
||||
if len(argv) != 2 {
|
||||
return errors.New("query-notes needs TEXT")
|
||||
}
|
||||
embedder, embedErr := router.NewONNXEmbedder(
|
||||
"models/embedder/multilingual-e5-small/model_quantized.onnx",
|
||||
"models/embedder/multilingual-e5-small/tokenizer.json",
|
||||
"deps/onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so",
|
||||
)
|
||||
if embedErr != nil {
|
||||
return embedErr
|
||||
}
|
||||
defer embedder.Close()
|
||||
vec, embedErr := router.EmbedQuery(ctx, embedder, argv[1])
|
||||
if embedErr != nil {
|
||||
return embedErr
|
||||
}
|
||||
out, err = cli.QueryNotes(ctx, vec, 10)
|
||||
case "score-pair":
|
||||
out, err = scorePair(ctx, argv)
|
||||
case "facts":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentFacts(ctx, n)
|
||||
case "decisions":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.TurnDecisions(ctx, n)
|
||||
case "events":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentEvents(ctx, n)
|
||||
case "eco-traces":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentEcosystemTraces(ctx, n)
|
||||
case "delivery-attempts":
|
||||
status := ""
|
||||
if len(argv) == 2 {
|
||||
status = argv[1]
|
||||
} else if len(argv) != 1 {
|
||||
return errors.New("delivery-attempts takes optional STATUS")
|
||||
}
|
||||
out, err = cli.DeliveryAttempts(ctx, status, 200)
|
||||
case "nudges":
|
||||
n, parseErr := optionalN(argv, 50)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
out, err = cli.RecentNudges(ctx, n)
|
||||
case "tools":
|
||||
status := ""
|
||||
if len(argv) == 2 {
|
||||
status = argv[1]
|
||||
} else if len(argv) != 1 {
|
||||
return errors.New("tools takes optional STATUS")
|
||||
}
|
||||
out, err = cli.ListTools(ctx, status)
|
||||
case "plan":
|
||||
out, err = cli.DayPlan(ctx)
|
||||
case "correct":
|
||||
if len(argv) != 3 {
|
||||
return errors.New("correct needs TRACE_ID SHOULD_BE")
|
||||
}
|
||||
id, parseErr := strconv.ParseInt(argv[1], 10, 64)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
err = cli.CorrectTurn(ctx, id, argv[2])
|
||||
out = map[string]any{"corrected": id, "should_be": argv[2]}
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q", argv[0])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return encode(out)
|
||||
}
|
||||
|
||||
func encode(out any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(out)
|
||||
}
|
||||
|
||||
func scorePair(ctx context.Context, argv []string) (any, error) {
|
||||
if len(argv) != 3 {
|
||||
return nil, errors.New("score-pair needs QUERY PASSAGE")
|
||||
}
|
||||
embedder, err := router.NewONNXEmbedder(
|
||||
"models/embedder/multilingual-e5-small/model_quantized.onnx",
|
||||
"models/embedder/multilingual-e5-small/tokenizer.json",
|
||||
"deps/onnxruntime-linux-x64-1.26.0/lib/libonnxruntime.so",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer embedder.Close()
|
||||
qvec, err := router.EmbedQuery(ctx, embedder, argv[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pvec, err := router.EmbedPassage(ctx, embedder, argv[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(qvec) != len(pvec) {
|
||||
return nil, fmt.Errorf("embedding widths differ: %d != %d", len(qvec), len(pvec))
|
||||
}
|
||||
var dot float64
|
||||
for i := range qvec {
|
||||
dot += float64(qvec[i]) * float64(pvec[i])
|
||||
}
|
||||
return map[string]any{"score": math.Round(dot*1e9) / 1e9}, nil
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
b, _ := json.Marshal(s)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func oneID(argv []string) (int64, error) {
|
||||
if len(argv) != 2 {
|
||||
return 0, errors.New("command needs ID")
|
||||
}
|
||||
return strconv.ParseInt(argv[1], 10, 64)
|
||||
}
|
||||
|
||||
func optionalN(argv []string, fallback int) (int, error) {
|
||||
if len(argv) == 1 {
|
||||
return fallback, nil
|
||||
}
|
||||
if len(argv) != 2 {
|
||||
return 0, errors.New("command takes optional N")
|
||||
}
|
||||
return strconv.Atoi(argv[1])
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Command labelgen labels utterances with the stage 0 grammars and prints JSONL.
|
||||
//
|
||||
// docs/plans/18-routing-heads-on-e5-small.md calls the labeled set the whole
|
||||
// project, and it names the stage 0 grammars as the high-precision label
|
||||
// functions to start from. This runs them — the real ones, in the real
|
||||
// buildRouter order — rather than a reimplementation, so a rule change moves
|
||||
// the training data with it.
|
||||
//
|
||||
// A grammar that declines leaves the line unlabeled. Those go to the model, and
|
||||
// keeping them is the point: a set labeled only by the rules teaches only the
|
||||
// rules.
|
||||
//
|
||||
// go run ./cmd/labelgen < utterances.txt > labeled.jsonl
|
||||
//
|
||||
// The wakeword-act grammar is absent, because its allowlist is the deployment's
|
||||
// enabled tool names and this tool has no deployment. Every other rule is here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// label is one output row. The grammar name rides along so a reviewer can see
|
||||
// which rule made the claim, and so a rule that turns out to be wrong can have
|
||||
// its rows pulled without re-running everything.
|
||||
type label struct {
|
||||
Utterance string `json:"utterance"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Grammar string `json:"grammar,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Fn string `json:"fn,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Labeled bool `json:"labeled"`
|
||||
}
|
||||
|
||||
// grammars is the daemon's canonical ordered stage-zero set. Label generation
|
||||
// must not maintain a second copy: that drift was the defect fixed by V-693.
|
||||
func grammars() []router.Grammar {
|
||||
return router.StageZeroGrammars(router.DefaultActMatcher{})
|
||||
}
|
||||
|
||||
func match(gs []router.Grammar, utterance string) label {
|
||||
out := label{Utterance: utterance}
|
||||
for _, g := range gs {
|
||||
d, matched, ok := g.Evaluate(utterance)
|
||||
if !matched || !ok {
|
||||
continue
|
||||
}
|
||||
out.Intent = string(d.Intent)
|
||||
out.Grammar = g.Name
|
||||
out.Key = d.Slots.Key
|
||||
out.Value = d.Slots.Value
|
||||
out.Fn = d.Slots.Fn
|
||||
out.Text = d.Slots.Text
|
||||
out.Labeled = true
|
||||
return out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
gs := grammars()
|
||||
in := bufio.NewScanner(os.Stdin)
|
||||
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
defer out.Flush()
|
||||
|
||||
enc := json.NewEncoder(out)
|
||||
var seen, labeled int
|
||||
for in.Scan() {
|
||||
line := strings.TrimSpace(in.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
l := match(gs, line)
|
||||
if l.Labeled {
|
||||
labeled++
|
||||
}
|
||||
if err := enc.Encode(l); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if err := in.Err(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Coverage on stderr, so the count is visible without polluting the JSONL.
|
||||
fmt.Fprintf(os.Stderr, "labelgen: %d/%d labeled by %d grammars\n", labeled, seen, len(gs))
|
||||
}
|
||||
+41
-9
@@ -50,10 +50,10 @@ func run(args []string) error {
|
||||
socket := fs.String("socket", "", "core IPC socket path (required)")
|
||||
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
||||
passFile := fs.String("pass-file", "", "file holding the CalDAV basic-auth password (required — never passed as a flag value)")
|
||||
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)")
|
||||
renderPassFile := fs.String("render-pass-file", "", "file holding the password for -render-url (defaults to -pass-file)")
|
||||
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")
|
||||
@@ -63,13 +63,22 @@ func run(args []string) error {
|
||||
if *socket == "" {
|
||||
return fmt.Errorf("-socket is required")
|
||||
}
|
||||
if *url == "" || *user == "" || *pass == "" {
|
||||
return fmt.Errorf("-url, -user, -pass are required")
|
||||
if *url == "" || *user == "" || *passFile == "" {
|
||||
return fmt.Errorf("-url, -user, -pass-file are required")
|
||||
}
|
||||
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The password is read from a file, never taken as a flag value: an argv
|
||||
// secret is visible in `ps` to every user on the box and lands in the compose
|
||||
// file and the shell history. Same rule mavmaild and mavpoll follow. Read
|
||||
// once at start, so a rotated password means a restart.
|
||||
pass, err := readSecret(*passFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -85,17 +94,20 @@ func run(args []string) error {
|
||||
http: hc,
|
||||
url: strings.TrimRight(*url, "/"),
|
||||
user: *user,
|
||||
pass: *pass,
|
||||
pass: pass,
|
||||
}
|
||||
|
||||
var rend *renderer
|
||||
if *renderURL != "" {
|
||||
ru, rp := *renderUser, *renderPass
|
||||
ru, rp := *renderUser, pass
|
||||
if ru == "" {
|
||||
ru = *user
|
||||
}
|
||||
if rp == "" {
|
||||
rp = *pass
|
||||
if *renderPassFile != "" {
|
||||
rp, err = readSecret(*renderPassFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
||||
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
||||
@@ -131,6 +143,21 @@ func run(args []string) error {
|
||||
// It takes the whole read set, not one URL. The guarantee in the package
|
||||
// comment is about every calendar maven reads, and a second read target added
|
||||
// later must not quietly fall outside the check.
|
||||
// readSecret reads one credential from a file and refuses an empty one. An
|
||||
// empty file is a deployment mistake, not a password, and CalDAV basic auth
|
||||
// would send it and get a 401 every poll.
|
||||
func readSecret(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password file: %w", err)
|
||||
}
|
||||
secret := strings.TrimSpace(string(raw))
|
||||
if secret == "" {
|
||||
return "", fmt.Errorf("password file %s is empty", path)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func checkRenderTarget(readURLs []string, renderURL string) error {
|
||||
if renderURL == "" {
|
||||
return nil
|
||||
@@ -188,6 +215,11 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// maxResponseBody bounds every CalDAV response this daemon reads (the poller's
|
||||
// GET and the renderer's PROPFIND) — a misbehaving or malicious server gets a
|
||||
// truncated read, not an unbounded one.
|
||||
const maxResponseBody = 4 << 20
|
||||
|
||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
@@ -203,7 +235,7 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,12 +5,38 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The password comes from a file so it never reaches argv. An empty or missing
|
||||
// file must fail at start rather than authenticate as "" against his calendar.
|
||||
func TestReadSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
good := filepath.Join(dir, "ok")
|
||||
if err := os.WriteFile(good, []byte(" hunter2\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := readSecret(good); err != nil || got != "hunter2" {
|
||||
t.Fatalf("readSecret(good) = %q, %v; want \"hunter2\", nil", got, err)
|
||||
}
|
||||
|
||||
empty := filepath.Join(dir, "empty")
|
||||
if err := os.WriteFile(empty, []byte("\n \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readSecret(empty); err == nil {
|
||||
t.Fatal("readSecret(empty) = nil error, want refusal")
|
||||
}
|
||||
if _, err := readSecret(filepath.Join(dir, "absent")); err == nil {
|
||||
t.Fatal("readSecret(absent) = nil error, want refusal")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts map[string]ipc.Fact // composite key "key|source" → Fact
|
||||
|
||||
@@ -142,7 +142,7 @@ func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -31,15 +31,11 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
@@ -155,9 +151,3 @@ func writeWAV(path string, a audio.Audio) error {
|
||||
// jsonUnmarshal — kept local rather than pulling encoding/json into main.go
|
||||
// top-level space.
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// keep strconv + io + net + time alive for future duration/size helpers.
|
||||
var _ = strconv.Atoi
|
||||
var _ io.Reader = (io.Reader)(nil)
|
||||
var _ = net.IPv4
|
||||
var _ = time.Second
|
||||
|
||||
@@ -61,7 +61,7 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
|
||||
if h.phraser == nil {
|
||||
return "поговорили."
|
||||
}
|
||||
history := h.chatHistory()
|
||||
history := h.chatHistory(ctx)
|
||||
// The phraser hands back its own fallback text alongside the error, so the
|
||||
// turn survives a dead server and the failure still reaches the log.
|
||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||
|
||||
@@ -15,6 +15,14 @@ import (
|
||||
// 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 {
|
||||
// An allowlist or a model route is evidence about WHAT could run, never
|
||||
// authority to run it. Keep the user's negative command at the execution
|
||||
// boundary too: actionAct is also reached by rebuilt decisions outside the
|
||||
// ordinary pre-route ladder.
|
||||
if refusesCommand(dec) {
|
||||
return commandProhibitionReply
|
||||
}
|
||||
|
||||
// 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).
|
||||
@@ -24,6 +32,14 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
}
|
||||
}
|
||||
|
||||
// The board is Maven's own store, so a spoken status change is answered here
|
||||
// and never offered to an ecosystem client (Vikunja #512). First, because
|
||||
// task_status is on no allowlist and no capability registry: reaching either
|
||||
// of them would answer a turn about his own task list with a gap.
|
||||
if dec.Slots.Fn == router.TaskStatusFn {
|
||||
return h.resolveTaskStatus(ctx, dec)
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
@@ -33,7 +49,7 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
|
||||
// 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 h.ecosystem != nil && h.ecosystem.hexis != nil && router.ActHasEntityTarget(dec) {
|
||||
if reply := h.handleHexisAct(ctx, dec); reply != "" {
|
||||
return reply
|
||||
}
|
||||
@@ -52,6 +68,17 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
|
||||
case errors.Is(err, tool.ErrUnknownTarget):
|
||||
// The verb reached a tool and the tail did not reach a target, so
|
||||
// nothing ran. Saying which word she could not place is the whole
|
||||
// answer: he either renames it or gives the row an alias that
|
||||
// carries the target, and both are one turn away (V-634).
|
||||
word := ""
|
||||
var unknown *tool.UnknownTargetError
|
||||
if errors.As(err, &unknown) {
|
||||
word = unknown.Target
|
||||
}
|
||||
return phraser.A(phraser.ActUnknownTarget, map[string]string{"name": word})
|
||||
case errors.Is(err, tool.ErrNeedsAuthedSurface):
|
||||
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
||||
// help: everything that proposed this act — the STT, the router,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -37,6 +38,15 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
// claim the turn before any real source ran.
|
||||
q.Slots.Key, q.Slots.HasKey = "", false
|
||||
q.Slots.Value = ""
|
||||
// Defensive reconstruction must preserve the same literal destination
|
||||
// the stage-0 router would have named. A learned fact decision has no
|
||||
// source, and without restoring this anchored world frame the personal
|
||||
// boundary can claim "latest Go version" by similarity and prevent the
|
||||
// live source from ever being asked.
|
||||
if world, ok := router.WorldQueryDecision(dec.Utterance); ok {
|
||||
q.Source = world.Source
|
||||
q.SourceAnchored = world.SourceAnchored
|
||||
}
|
||||
return h.actionQuery(ctx, q)
|
||||
}
|
||||
// A complaint is not a fact either (#481). "сеть какая-то медленная" and
|
||||
@@ -89,7 +99,15 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
// hears; storing the utterance meant recall answered with his own sentence
|
||||
// rather than the value. The utterance stays alongside as provenance —
|
||||
// readable on /trace, never the answer and never embedded.
|
||||
//
|
||||
// The vector id carries a timestamp, so a second tap of the same key adds a
|
||||
// row rather than replacing one, and recall then scores the superseded
|
||||
// value against the current one. CorrectValue and VoidLatestFact already
|
||||
// drop the key's vectors; an ordinary re-tap is the third way a value is
|
||||
// superseded and it did not (#493). Dropping first keeps exactly one vector
|
||||
// per key, which is what "recall answers with the current value" means.
|
||||
if h.recall.memStore != nil {
|
||||
pruneFactVectors(ctx, h.recall.memStore, dec.Slots.Key)
|
||||
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||
log.Printf("voice: embed fact for memory: %v", err)
|
||||
@@ -114,3 +132,29 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
}
|
||||
return "" // replier phrases the success reply
|
||||
}
|
||||
|
||||
// vectorPruner — the part of the vector index this file needs and memory.Store
|
||||
// does not carry. store.MemoryStore implements it; the in-memory test double
|
||||
// may not, and a double that cannot prune is not a reason to fail a fact write.
|
||||
type vectorPruner interface {
|
||||
DeletePrefix(ctx context.Context, prefix string) (int64, error)
|
||||
}
|
||||
|
||||
// pruneFactVectors drops every vector for one fact key, so the insert that
|
||||
// follows is the only one left. Best-effort and silent on a store that cannot
|
||||
// prune: the fact row is the truth, and a stale vector costs a wrong recall,
|
||||
// not a lost fact.
|
||||
func pruneFactVectors(ctx context.Context, ms memory.Store, key string) {
|
||||
p, ok := ms.(vectorPruner)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := p.DeletePrefix(ctx, "fact:"+key+":")
|
||||
if err != nil {
|
||||
log.Printf("voice: prune memory vectors for %q: %v", key, err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
log.Printf("voice: %q superseded, dropped %d stale memory vector(s)", key, n)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,12 @@ func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCap
|
||||
return "", false
|
||||
}
|
||||
|
||||
// listFloor — the keyword test behind topicList, in the shape turnIsAbout takes.
|
||||
func listFloor(u string) bool {
|
||||
_, ok := router.ParseListQuery(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// queryList — "что в списке покупок?", "что мне купить?".
|
||||
//
|
||||
// A query source, so it sits in querySources and either claims the turn or
|
||||
@@ -102,10 +108,15 @@ func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCap
|
||||
// source is: the notes pass would otherwise answer a list question with
|
||||
// whatever note is nearest.
|
||||
func (h *reactiveHandler) queryList(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
list, ok := router.ParseListQuery(t.dec.Utterance)
|
||||
if !ok || h.dataStore == nil {
|
||||
if h.dataStore == nil {
|
||||
return "", false
|
||||
}
|
||||
// The seeds decide the subject and listQueryPrefixes is the floor behind
|
||||
// them (V-522). Which list he named is a noun lookup either way.
|
||||
if !h.turnIsAbout(ctx, t, topicList, listFloor) {
|
||||
return "", false
|
||||
}
|
||||
list := router.ListNamedIn(t.dec.Utterance)
|
||||
items, err := h.dataStore.ListItems(ctx, list, "")
|
||||
if err != nil {
|
||||
log.Printf("voice: list items: %v", err)
|
||||
|
||||
@@ -9,9 +9,25 @@ import (
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// nothingToCorrectReply — what she says to a correction that points at
|
||||
// nothing. Filing it would put a sentence in his memory that reads as a fact.
|
||||
const nothingToCorrectReply = "не поняла, что поправить. скажи целиком, и я запишу."
|
||||
|
||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||
// index it for recall.
|
||||
//
|
||||
// The stored body comes only from dec.Utterance (V-576/V-721). An explicit
|
||||
// leading capture frame is structurally removed; an unmarked note is otherwise
|
||||
// byte-for-byte his utterance. It is never Slots.Text, phraser output or any
|
||||
// other model string: a note is durable, the embedder indexes it, and it comes
|
||||
// back later as recall in his own words. Phrasing belongs in the confirmation.
|
||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||
// A correction with no referent. Everything that could own one has already
|
||||
// run by here: clarify, confirm and repair are all resolved before routing,
|
||||
// so a fragment reaching the note path has nothing behind it (V-576).
|
||||
if correctionFragment(dec.Utterance) {
|
||||
return nothingToCorrectReply
|
||||
}
|
||||
// An utterance that explicitly files a task is work, not recall, and
|
||||
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||
// is paid for. Everything else is a note, exactly as before.
|
||||
@@ -24,16 +40,20 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
if reply, ok := h.captureListFromNote(ctx, dec); ok {
|
||||
return reply
|
||||
}
|
||||
noteText := dec.Utterance
|
||||
if body, explicit := router.ParseNoteCapture(dec.Utterance); explicit {
|
||||
noteText = body
|
||||
}
|
||||
// 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.recall.embedder, dec.Utterance)
|
||||
vec, err := router.EmbedPassage(ctx, h.recall.embedder, noteText)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
}
|
||||
noteTs := h.now()
|
||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||
noteID, err := h.api.WriteNote(ctx, noteTs, noteText, vec, "tap:voice")
|
||||
if err != nil {
|
||||
log.Printf("voice: write note: %v", err)
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
@@ -44,7 +64,7 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "note",
|
||||
"text": dec.Utterance,
|
||||
"text": noteText,
|
||||
"ts": strconv.FormatInt(noteTs.Unix(), 10),
|
||||
}); err != nil {
|
||||
log.Printf("voice: memory insert: %v", err)
|
||||
|
||||
+323
-65
@@ -8,9 +8,12 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/crawl"
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/kiwix"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
@@ -57,6 +60,35 @@ type querySource struct {
|
||||
// sources search text with no notion of a day. When one of them grows a
|
||||
// date parameter, flip its flag here.
|
||||
dateAware bool
|
||||
|
||||
// dest — the destination this source serves, when the cascade named one
|
||||
// (V-655). Several sources share a destination: the three recall passes and
|
||||
// the fact-by-key lookup are all SourceRecall, because which of them lands
|
||||
// the hit is an ordering detail no utterance can name. A source with no
|
||||
// dest is reachable only by walking the chain.
|
||||
dest router.Source
|
||||
|
||||
// guesses — this source decides whether the turn is its own by scoring the
|
||||
// utterance against frozen seeds, rather than by looking something up and
|
||||
// coming back empty.
|
||||
//
|
||||
// The distinction is the whole point of the field. A source that looks can
|
||||
// be wrong about relevance and still harmless, because the miss shows up as
|
||||
// no rows. A source that guesses answers whatever it claims: weather has no
|
||||
// local table to miss against, so "что такое TCP?" became "для какого
|
||||
// города?". So when the cascade names a destination, the guessers that were
|
||||
// not named do not get to try. The lookups still run, because a named
|
||||
// destination is evidence and not a promise.
|
||||
guesses bool
|
||||
|
||||
// boundary — dropping this source widens what leaves the box, so only a
|
||||
// literal pattern may do it (V-666, owner's call of 2026-08-09).
|
||||
//
|
||||
// Every other guesser costs an answer when it is wrongly taken off a turn.
|
||||
// This one costs the rule that a question about him never reaches an
|
||||
// upstream engine. A grammar read the words to name a destination. A model
|
||||
// and a softmax both inferred one, and neither may spend that.
|
||||
boundary bool
|
||||
}
|
||||
|
||||
// querySources is the ordered chain actionQuery walks; first source to claim
|
||||
@@ -65,79 +97,85 @@ type querySource struct {
|
||||
// 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{
|
||||
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey},
|
||||
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey, dest: router.SourceRecall},
|
||||
// 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.
|
||||
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan},
|
||||
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan, dest: router.SourceCalendar},
|
||||
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||||
// and the habit question is the more specific one. Its matcher requires a
|
||||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||
// Wednesday still reaches the calendar.
|
||||
{name: "habits", answer: (*reactiveHandler).queryHabits},
|
||||
{name: "habits", answer: (*reactiveHandler).queryHabits, dest: router.SourceCalendar},
|
||||
// Before "calendar" and before the recall sources: "что мне нужно
|
||||
// сделать?" is a question about the task list, and the notes pass would
|
||||
// otherwise answer it with whatever note happens to be nearest. Its
|
||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||
// date-bearing question still reaches the calendar.
|
||||
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
||||
{name: "tasks", answer: (*reactiveHandler).queryTasks, dest: router.SourceTasks},
|
||||
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
||||
// question about the operational state Praxis holds, and it used to fall
|
||||
// through every source to the web search (Vikunja #475). Its matcher needs
|
||||
// an attention marker, and it falls through when Praxis is not configured.
|
||||
{name: "attention", answer: (*reactiveHandler).queryAttention},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{name: "attention", answer: (*reactiveHandler).queryAttention, dest: router.SourceAttention, guesses: true},
|
||||
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
||||
// about the shopping list, and the recall pass would otherwise answer it
|
||||
// from an old note about the shop. Its matcher needs an explicit list
|
||||
// marker, so "надо бы съездить в магазин" is untouched.
|
||||
{name: "list", answer: (*reactiveHandler).queryList},
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||
{name: "list", answer: (*reactiveHandler).queryList, dest: router.SourceList, guesses: true},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney, dest: router.SourceMoney},
|
||||
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||||
// the facts he tapped in, and the notes pass would answer it with whatever
|
||||
// note is nearest (Vikunja #456). Its matcher needs both halves of a
|
||||
// history phrase and bails out when he names a topic, so "что я говорил
|
||||
// про сервер" is still recall.
|
||||
{name: "history", answer: (*reactiveHandler).queryHistory},
|
||||
{name: "history", answer: (*reactiveHandler).queryHistory, dest: router.SourceRecall},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// a question about the feeds she reads, and general knowledge would answer
|
||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||
// "у меня новая лента в инстаграме" is untouched.
|
||||
{name: "feeds", answer: (*reactiveHandler).queryFeeds},
|
||||
{name: "feeds", answer: (*reactiveHandler).queryFeeds, dest: router.SourceFeeds, guesses: true},
|
||||
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||||
// a question about the house, and the notes pass would otherwise answer it
|
||||
// from whatever he once said about the lights. Its matcher needs a house
|
||||
// marker plus an ask plus a device word, and it bails out on weather
|
||||
// wording, so "какая температура на улице?" still reaches the weather
|
||||
// source.
|
||||
{name: "home", answer: (*reactiveHandler).queryHome},
|
||||
{name: "home", answer: (*reactiveHandler).queryHome, dest: router.SourceHome, guesses: true},
|
||||
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||
// question about the LAN, and the recall pass would otherwise answer it
|
||||
// from an old note about the router. Its matcher needs a network word plus
|
||||
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||
{name: "network", answer: (*reactiveHandler).queryNetwork},
|
||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
||||
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
||||
{name: "embed", answer: (*reactiveHandler).queryEmbed},
|
||||
{name: "memory", answer: (*reactiveHandler).queryMemory},
|
||||
{name: "notes", answer: (*reactiveHandler).queryNotes},
|
||||
{name: "network", answer: (*reactiveHandler).queryNetwork, dest: router.SourceNetwork, guesses: true},
|
||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true, dest: router.SourceCalendar},
|
||||
{name: "weather", answer: (*reactiveHandler).queryWeather, dest: router.SourceWeather, guesses: true},
|
||||
// A question about her, above the three sources that search his own data
|
||||
// (Vikunja #555). It has no answer anywhere else: below the boundary
|
||||
// SearXNG answers about somebody else's assistant, and above it his notes
|
||||
// answer by proximity — "кто ты" came back from a note of his, measured on
|
||||
// the box, because the recall index has no idea the subject is her.
|
||||
{name: "self", answer: (*reactiveHandler).querySelf, dest: router.SourceSelf, guesses: true},
|
||||
{name: "embed", answer: (*reactiveHandler).queryEmbed, dest: router.SourceRecall},
|
||||
{name: "memory", answer: (*reactiveHandler).queryMemory, dest: router.SourceRecall},
|
||||
{name: "notes", answer: (*reactiveHandler).queryNotes, dest: router.SourceRecall},
|
||||
// THE BOUNDARY. Everything above answers from his own data; everything
|
||||
// below answers from the world's. A question about him that got this far
|
||||
// has no answer in his data, and no outside source can supply one, so this
|
||||
// stops the walk rather than let the encyclopedia and the model guess.
|
||||
{name: "personal", answer: (*reactiveHandler).queryPersonal},
|
||||
{name: "personal", answer: (*reactiveHandler).queryPersonal, dest: router.SourceRecall, guesses: true, boundary: true},
|
||||
// The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats
|
||||
// a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at
|
||||
// stake by this point — the boundary above already stopped every question
|
||||
// about him, and only the query string leaves the box.
|
||||
{name: "search", answer: (*reactiveHandler).querySearch},
|
||||
{name: "search", answer: (*reactiveHandler).querySearch, dest: router.SourceWorld},
|
||||
// The offline encyclopedia, now the fallback for when the line is down or
|
||||
// the search comes back empty. It reads the way it always did; what changed
|
||||
// is that it no longer gets first refusal on a world question.
|
||||
{name: "kiwix", answer: (*reactiveHandler).queryKiwix},
|
||||
{name: "kiwix", answer: (*reactiveHandler).queryKiwix, dest: router.SourceWorld},
|
||||
// LAST before the model answers from memory, and that position is the whole
|
||||
// design (Vikunja #259): everything of his, then the search, then the ZIMs,
|
||||
// and only then a page he named. The model does NOT come first: it
|
||||
@@ -145,14 +183,70 @@ var querySources = []querySource{
|
||||
// a 1.7B guessing at a page it cannot read is how contents get invented.
|
||||
// This source only claims a turn where he named a URL, so it never competes
|
||||
// with a local answer.
|
||||
{name: "web", answer: (*reactiveHandler).queryWeb},
|
||||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral},
|
||||
{name: "web", answer: (*reactiveHandler).queryWeb, dest: router.SourceWorld},
|
||||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral, dest: router.SourceWorld},
|
||||
}
|
||||
|
||||
// queryWalk narrows the chain for one turn against the destination the cascade
|
||||
// named, and says which sources were left out (V-655).
|
||||
//
|
||||
// It takes sources OUT and never moves one, which is the whole safety argument.
|
||||
// The table's order is load-bearing and every comment on it argues a reason
|
||||
// between two sources; none of those reasons is about this. Above all, the
|
||||
// order carries "his data first, then the world", and a destination named by a
|
||||
// model must not be able to reverse that. Naming SourceWorld does not send the
|
||||
// turn outside — it stops the guessers from claiming it on the way.
|
||||
//
|
||||
// What comes out is exactly the sources that guess. Those decide whether a turn
|
||||
// is theirs by scoring it against frozen seeds, and then answer whatever they
|
||||
// claimed, because they have no lookup that can come back empty. That is the
|
||||
// whole of the 2026-08-07 defect: weather claiming "что такое TCP?", the feed
|
||||
// claiming "какой у меня любимый язык?", the personal boundary claiming "кто
|
||||
// такой Линус Торвальдс?". The sources that look are all still asked, so a
|
||||
// wrong destination costs nothing but the guess it prevented.
|
||||
//
|
||||
// No destination named ⇒ the table exactly as written, which is what shipped
|
||||
// before the field existed. That is the floor. The classifier arm names
|
||||
// nothing, so a box whose model is down routes queries the way it always did.
|
||||
// The personal boundary is the one exception, and anchored is what buys it
|
||||
// (V-666). A grammar matched a literal pattern to name the destination. The
|
||||
// routing heads and the resident model inferred one, and an inferred SourceWorld
|
||||
// takes the boundary off a question about him. That widens what is asked
|
||||
// upstream rather than costing a local answer, so those two keep it.
|
||||
func queryWalk(dest router.Source, anchored bool) (walk, skipped []querySource) {
|
||||
if dest == router.SourceUnknown {
|
||||
return querySources, nil
|
||||
}
|
||||
for _, s := range querySources {
|
||||
if s.guesses && s.dest != dest && (anchored || !s.boundary) {
|
||||
skipped = append(skipped, s)
|
||||
continue
|
||||
}
|
||||
walk = append(walk, s)
|
||||
}
|
||||
return walk, skipped
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||||
t := &queryTurn{dec: dec}
|
||||
for _, src := range querySources {
|
||||
// The roster, so the record can say which sources were never reached rather
|
||||
// than leaving them out and letting a reader assume they looked and passed
|
||||
// (V-564). Finish names everyone below the winner.
|
||||
decision.Expect(ctx, decision.StageQuery, querySourceNames())
|
||||
rec := decision.From(ctx)
|
||||
walk, skipped := queryWalk(dec.Source, dec.SourceAnchored)
|
||||
for _, src := range skipped {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||
Reason: "it decides by similarity and the cascade named " + string(dec.Source),
|
||||
})
|
||||
}
|
||||
for _, src := range walk {
|
||||
if dec.Continued && !src.dateAware {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||
Reason: "a continuation turn only asks the date-aware sources",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if reply, ok := src.answer(h, ctx, t); ok {
|
||||
@@ -161,10 +255,20 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// no query-source field, so a wrong answer could not be told from a
|
||||
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
|
||||
// the utterance and the answer are already on the voice lines above
|
||||
// and below this one.
|
||||
// and below this one. The same name goes to the turn's sink when the
|
||||
// caller asked for one, so /chat can show it (V-539).
|
||||
log.Printf("voice: query claimed by source %q", src.name)
|
||||
noteQuerySource(ctx, src.name)
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name,
|
||||
Intent: string(dec.Intent), Outcome: decision.Won,
|
||||
})
|
||||
return reply
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.Declined,
|
||||
Reason: "it had no answer for this turn",
|
||||
})
|
||||
}
|
||||
if dec.Continued {
|
||||
// The previous question cannot be re-asked for another day. Saying so
|
||||
@@ -208,7 +312,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
//
|
||||
// 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.
|
||||
// day, so that phrasing trims what has already passed and reads only the next
|
||||
// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts
|
||||
// nothing, and she read 43 entries aloud in one sentence (V-618).
|
||||
//
|
||||
// "что у меня сегодня?" is a different question and is not narrowed here — it
|
||||
// carries no plan word, so IsDayPlanQuery declines it and the calendar source
|
||||
// answers the whole day.
|
||||
//
|
||||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||
@@ -227,16 +337,23 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
}
|
||||
// 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}
|
||||
//
|
||||
// The instants are put back in the asking clock's zone on the way in. They
|
||||
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
|
||||
// hours in the plan's own frame, so setting that frame here is what makes
|
||||
// the recital name his clock rather than the store's (V-614).
|
||||
zone := h.now().Location()
|
||||
p := morning.Plan{Date: plan.Date.In(zone)}
|
||||
for _, it := range plan.Items {
|
||||
p.Items = append(p.Items, morning.PlanEntry{
|
||||
At: it.At,
|
||||
At: it.At.In(zone),
|
||||
Text: it.Text,
|
||||
Kind: morning.PlanKind(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
})
|
||||
}
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
return p.Next(h.now(), morning.NextSpoken).FormatRU(), true
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
@@ -289,6 +406,14 @@ const (
|
||||
feedReadOut = 3
|
||||
)
|
||||
|
||||
// feedFloor — the keyword test behind topicFeed, in the one-string shape
|
||||
// turnIsAbout takes. router.ParseFeedQuery returns the category too, which the
|
||||
// gate has no use for; the caller reads it separately.
|
||||
func feedFloor(u string) bool {
|
||||
_, ok := router.ParseFeedQuery(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||
// (Vikunja #258).
|
||||
//
|
||||
@@ -296,10 +421,13 @@ const (
|
||||
// never speaks; asking is the trigger. If that ever changes, the thing that
|
||||
// changed is "Maven is not a nag", not a detail of this file.
|
||||
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||||
if !ok {
|
||||
// The seeds decide the subject; router.ParseFeedQuery is the floor behind
|
||||
// them (V-522). The category still comes from the utterance either way,
|
||||
// because a topic is marked by a preposition and needs no recogniser.
|
||||
if !h.turnIsAbout(ctx, t, topicFeed, feedFloor) {
|
||||
return "", false
|
||||
}
|
||||
category := router.FeedCategoryOf(t.dec.Utterance)
|
||||
if !h.feedsOn {
|
||||
// Claim only when nothing below can read the world. The reason this
|
||||
// source used to claim unconditionally was that general knowledge would
|
||||
@@ -324,7 +452,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
|
||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), category) {
|
||||
continue
|
||||
}
|
||||
// The note carries title, summary, category tag and link; she reads the
|
||||
@@ -336,7 +464,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if q.Category != "" {
|
||||
if category != "" {
|
||||
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||
}
|
||||
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||
@@ -357,6 +485,19 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
if isWeatherQuery(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
// Weather was one instance of a wider class (Vikunja #552). Naming a day
|
||||
// does not make a question his agenda: "какой сегодня курс доллара" and
|
||||
// "во сколько закат сегодня" both answered "ничего нет", which reads as an
|
||||
// answer about a subject she never looked at. All of them have an answer
|
||||
// in search, and search sits below this source. So the question must ask
|
||||
// about his schedule, not merely name a day.
|
||||
//
|
||||
// A continuation is exempt. "а завтра?" names no agenda and cannot: the
|
||||
// subject was in the turn before it, and this is the only date-aware
|
||||
// source there is.
|
||||
if !t.dec.Continued && !router.IsAgendaQuestion(t.dec.Utterance) {
|
||||
return "", false
|
||||
}
|
||||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||
if !ok {
|
||||
return "", false
|
||||
@@ -377,6 +518,10 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
return f.FormatEntries(entries, date), true
|
||||
}
|
||||
|
||||
// homeTimeout — the whole house read. Longer than the weather call because the
|
||||
// hub is polled over the LAN and answers for every device at once.
|
||||
const homeTimeout = 10 * time.Second
|
||||
|
||||
// queryHome answers a question about the house. Read-only by construction: it
|
||||
// calls States and nothing else, so there is no confirm turn here — the only
|
||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||
@@ -393,7 +538,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
// unreachable case is different and homeSummary covers it.
|
||||
return "", false
|
||||
}
|
||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
ctxH, cancel := context.WithTimeout(ctx, homeTimeout)
|
||||
defer cancel()
|
||||
return h.home.homeSummary(ctxH)
|
||||
}
|
||||
@@ -417,6 +562,10 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
// weatherTimeout — one geocode plus one forecast read. He asked a question with
|
||||
// a one-line answer, so a provider that is slower than this is a failure.
|
||||
const weatherTimeout = 5 * time.Second
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
|
||||
return "", false
|
||||
@@ -427,7 +576,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
// so is the only honest answer; picking a city would be inventing one.
|
||||
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
ctxWT, cancel := context.WithTimeout(ctx, weatherTimeout)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
if errors.Is(err, weather.ErrNotConfigured) {
|
||||
@@ -452,17 +601,54 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
|
||||
// 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.
|
||||
// never claims the turn.
|
||||
//
|
||||
// It used to claim on an embedder error, and that made a RAG hint a hard gate
|
||||
// over everything below it (V-568): one failing EmbedQuery and the memory, the
|
||||
// notes, the boundary, the search, the ZIMs, the named page and the model all
|
||||
// answered "не смогла ответить", including the questions search and Kiwix
|
||||
// would have answered without ever touching the embedder. A failed embed means
|
||||
// this source cannot claim, not that the turn is over — same shape as
|
||||
// turnVector in topics.go, which had it right.
|
||||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
// A topic source above already paid for this one; see turnVector.
|
||||
if len(t.vec) > 0 {
|
||||
return "", false
|
||||
}
|
||||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// Logged once, here, and the chain walks on. The two recall sources
|
||||
// below read the empty vector and pass; the boundary drops to its
|
||||
// offline floor.
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
return "", false
|
||||
}
|
||||
t.vec = vec
|
||||
return "", false
|
||||
}
|
||||
|
||||
// memoryRecallWidth and noteRecallWidth — how many candidates each recall pass
|
||||
// pulls before the gate reads them. Both are small on purpose: the gate wants a
|
||||
// best hit and its runner-up, and every further row is a margin the top match
|
||||
// has to beat.
|
||||
const (
|
||||
memoryRecallWidth = 3
|
||||
noteRecallWidth = 5
|
||||
)
|
||||
|
||||
// recallOnTopic — the topic veto both recall sources apply after the score gate
|
||||
// (#470). A memory about his slow network scored high enough to answer "почему
|
||||
// небо синее?", because the right-note and must-be-silent score ranges overlap
|
||||
// and no threshold sits between them.
|
||||
func recallOnTopic(utterance, text string) bool {
|
||||
if memory.RecallAllowed(utterance, text,
|
||||
router.IsOpenQuestionShaped(utterance), router.IsLocativeQuestionShaped(utterance)) {
|
||||
return true
|
||||
}
|
||||
log.Printf("voice: recall %q rejected for %q: no structural ask with a shared topic, or a world/locative question with no shared topic", text, utterance)
|
||||
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
|
||||
@@ -477,7 +663,13 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
if h.recall.memStore == nil {
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
|
||||
if len(t.vec) == 0 {
|
||||
// No query vector: the embed above failed or there is no embedder.
|
||||
// Searching on an empty vector is not a search, and its scores are not
|
||||
// a "there is nothing" answer — pass rather than gate the chain.
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, memoryRecallWidth)
|
||||
if herr != nil {
|
||||
log.Printf("voice: memory search: %v", herr)
|
||||
return "", false
|
||||
@@ -487,12 +679,8 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
return "", false
|
||||
}
|
||||
text := hit.Meta["text"]
|
||||
// The score cleared the gate and the topic still has to match (#470). A
|
||||
// note about his slow network scored high enough to answer "почему небо
|
||||
// синее?", because the right-note and must-be-silent score ranges overlap
|
||||
// and no threshold sits between them.
|
||||
if !memory.RecallAllowed(t.dec.Utterance, text) {
|
||||
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance)
|
||||
// The score cleared the gate and the topic still has to match.
|
||||
if !recallOnTopic(t.dec.Utterance, text) {
|
||||
return "", false
|
||||
}
|
||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||
@@ -523,10 +711,18 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
// 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 len(t.vec) == 0 {
|
||||
// Same reason as queryMemory above (V-568): with no query vector this
|
||||
// source could not look, and could-not-look passes.
|
||||
return "", false
|
||||
}
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, noteRecallWidth)
|
||||
if err != nil {
|
||||
// The store failed, so this source could not look either. It used to
|
||||
// claim here, which stopped the search, the ZIMs and the model from
|
||||
// answering a question that never needed a note (V-568).
|
||||
log.Printf("voice: query notes: %v", err)
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
return "", false
|
||||
}
|
||||
t.notes = notes
|
||||
noteScores := make([]float64, len(notes))
|
||||
@@ -536,10 +732,9 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||
return "", false
|
||||
}
|
||||
// Same topic veto as queryMemory above: the best note must be about what
|
||||
// he asked, not merely the nearest vector in the index.
|
||||
if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) {
|
||||
log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance)
|
||||
// The best note must be about what he asked, not merely the nearest vector
|
||||
// in the index.
|
||||
if !recallOnTopic(t.dec.Utterance, notes[0].Text) {
|
||||
return "", false
|
||||
}
|
||||
texts := make([]string, len(notes))
|
||||
@@ -561,6 +756,23 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
// prompt, the persona block and the reply.
|
||||
const webPageContextRunes = 1500
|
||||
|
||||
// webFetchTimeout — the whole named-page source. Longer than the other outside
|
||||
// sources because he named this page himself, so waiting for it is what he asked
|
||||
// for, and there is nothing below that can answer instead.
|
||||
const webFetchTimeout = 30 * time.Second
|
||||
|
||||
// readBackRunes — how much of the evidence is read out when the phraser gave
|
||||
// nothing back. It is spoken aloud, so it is a couple of sentences and not a
|
||||
// page.
|
||||
const readBackRunes = 300
|
||||
|
||||
// readBack — what an outside source says when the phraser gave nothing back.
|
||||
// The evidence is read out plainly rather than dropped, because the fetch did
|
||||
// happen and its result is a better answer than silence.
|
||||
func readBack(evidence string) string {
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(evidence, readBackRunes)})
|
||||
}
|
||||
|
||||
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||
//
|
||||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||
@@ -579,7 +791,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
// guess dressed as an answer (Vikunja #479).
|
||||
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||
}
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, webFetchTimeout)
|
||||
defer cancel()
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
@@ -600,7 +812,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
|
||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, readBackRunes)}), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -666,7 +878,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best evidence rather than
|
||||
// pretend the search did not happen.
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
|
||||
return readBack(resp.Snippets()[0]), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -693,11 +905,19 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
ctxK, cancel := context.WithTimeout(ctx, kiwixTimeout)
|
||||
defer cancel()
|
||||
|
||||
// The ZIMs are English and kiwix ranks by keyword overlap, not meaning, so
|
||||
// a Russian sentence matches nothing at all. The rewriter turns it into a
|
||||
// handful of English keywords with the resident model.
|
||||
// A Russian question reads the Russian ZIM verbatim when there is one
|
||||
// (V-508). Kiwix ranks by keyword overlap rather than meaning, so an English
|
||||
// book matches a Russian sentence not at all, and the rewriter exists to
|
||||
// turn the question into English keywords with the resident model. Against a
|
||||
// Russian book that is a translation of his own words back at him: it costs
|
||||
// a model call and drops whatever the keywords do not carry.
|
||||
book, verbatim := h.kiwix.book, false
|
||||
if h.kiwix.bookRU != "" && hasCyrillic(t.dec.Utterance) {
|
||||
book, verbatim = h.kiwix.bookRU, true
|
||||
}
|
||||
|
||||
pattern := t.dec.Utterance
|
||||
if h.kiwix.rewriter != nil {
|
||||
if h.kiwix.rewriter != nil && !verbatim {
|
||||
q, err := h.kiwix.rewriter.Rewrite(ctxK, t.dec.Utterance)
|
||||
if err != nil {
|
||||
// Fall through to the verbatim question rather than give up. It
|
||||
@@ -708,7 +928,29 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
}
|
||||
|
||||
hits, err := h.kiwix.client.Search(ctxK, pattern, h.kiwix.book, h.kiwix.max)
|
||||
// The topic, not the sentence (V-668). Kiwix ranks by keyword overlap, so
|
||||
// the question words outrank the one word that names the article: measured
|
||||
// on 2026-08-09, "что такое TCP" returns "Перехват TCP-соединения" and
|
||||
// "TCP" returns TCP. Only the verbatim path needs this. The rewriter
|
||||
// already reduces a question to English keywords, and reducing twice would
|
||||
// take the topic off the input it reads.
|
||||
if verbatim {
|
||||
if topic := kiwix.Topic(pattern); topic != "" {
|
||||
// The article named exactly, before any ranking runs. A ZIM is
|
||||
// addressable by title and a wrong title is a 404, so this either
|
||||
// answers or costs one request that says nothing.
|
||||
for _, cand := range kiwix.TitleCandidates(topic) {
|
||||
page, err := h.kiwix.client.Article(ctxK, kiwix.TitlePath(book, cand), h.kiwix.runes)
|
||||
if err == nil && page.Text != "" {
|
||||
log.Printf("voice: kiwix: %q in %q → title hit %q", topic, book, page.Title)
|
||||
return h.kiwixReply(ctx, t, page.Title, page.Text)
|
||||
}
|
||||
}
|
||||
pattern = topic
|
||||
}
|
||||
}
|
||||
|
||||
hits, err := h.kiwix.client.Search(ctxK, pattern, book, h.kiwix.max)
|
||||
if err != nil {
|
||||
log.Printf("voice: kiwix: search %q: %v", pattern, err)
|
||||
return "", false
|
||||
@@ -720,7 +962,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
// Logged on the way through, not only on failure. Without this there is no
|
||||
// way to tell from the outside whether an answer came off a ZIM or out of
|
||||
// the model's weights, and those are the two cases worth telling apart.
|
||||
log.Printf("voice: kiwix: %q → %d hits, top %q", pattern, len(hits), top.Title)
|
||||
log.Printf("voice: kiwix: %q in %q → %d hits, top %q", pattern, book, len(hits), top.Title)
|
||||
|
||||
// The top hit only, read as an article rather than as a snippet. Kiwix
|
||||
// builds its snippet from wherever the keyword matched, which on Wikipedia
|
||||
@@ -740,18 +982,34 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
page = crawl.Page{Title: top.Title, Text: top.Snippet}
|
||||
}
|
||||
// Handed over the same way a note or a page is: context for the question he
|
||||
// asked, not something to recite.
|
||||
snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes)
|
||||
return h.kiwixReply(ctx, t, top.Title, page.Text)
|
||||
}
|
||||
|
||||
// kiwixReply hands one article over the same way a note or a page is handed
|
||||
// over: context for the question he asked, not something to recite.
|
||||
func (h *reactiveHandler) kiwixReply(ctx context.Context, t *queryTurn, title, text string) (string, bool) {
|
||||
snippet := title + "\n" + crawl.TrimRunes(text, h.kiwix.runes)
|
||||
reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet})
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||
// the search did not happen.
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
||||
return readBack(title + " — " + text), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// hasCyrillic reports whether the text carries a Cyrillic letter, which is the
|
||||
// whole test for "he asked this in Russian". A question mixing a Latin proper
|
||||
// noun into a Russian sentence is still Russian, so one letter is enough.
|
||||
func hasCyrillic(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Cyrillic, r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// queryPersonal — stop the walk on a question about him that his own data did
|
||||
// not answer.
|
||||
//
|
||||
|
||||
@@ -2,8 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
@@ -11,12 +14,21 @@ import (
|
||||
// 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 {
|
||||
// The pre-route belt normally answers this before routing. Keep the write
|
||||
// boundary guarded as well: a model calling the sentence a reminder does
|
||||
// not turn "don't ..." into permission to create a row.
|
||||
if refusesCommand(dec) {
|
||||
return commandProhibitionReply
|
||||
}
|
||||
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 {
|
||||
// Same gate as the extractor (V-577, V-579, V-610): a request whose
|
||||
// hour was not spoken, or was spoken and not read, gets asked about
|
||||
// and is never completed from the clock.
|
||||
if err == nil && ok && router.ResolvedTheHour(dec.Utterance, t) {
|
||||
dec.Slots.Time = t
|
||||
dec.Slots.HasTime = true
|
||||
}
|
||||
@@ -33,5 +45,24 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
}
|
||||
return ""
|
||||
// Phrased from the row, never from the utterance (Vikunja #507). The
|
||||
// replier only ever saw Slots.Text, so it named whatever hour the sentence
|
||||
// contained — including one the parser had rejected or read differently.
|
||||
// A confirmation naming an hour no row holds is worse than a clarify,
|
||||
// because he stops thinking about it.
|
||||
return reminderConfirm(dec.Slots.Time, h.now())
|
||||
}
|
||||
|
||||
// reminderConfirm — the confirmation for a reminder that exists, naming the
|
||||
// stored fire time. Deterministic on purpose: the one sentence that must match
|
||||
// a database row is not one to hand to a 1.7B.
|
||||
func reminderConfirm(fire, now time.Time) string {
|
||||
when := dayPrefix(now, fire)
|
||||
if when == "это" {
|
||||
// Further out than the day words reach — say the date instead of a
|
||||
// word that would be wrong.
|
||||
date := fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
|
||||
return "хорошо, напомню " + date + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
return "хорошо, напомню " + when + " в " + fire.Format("15:04") + "."
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
@@ -80,8 +81,96 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
||||
for _, r := range spoken {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
|
||||
}
|
||||
h.offerCandidates(cands)
|
||||
return tasks.FormatRU(ranked), true
|
||||
h.offerCandidates(ctx, cands)
|
||||
reply := tasks.FormatRU(ranked)
|
||||
// The counted shapes, after the list and only when there are any (V-512).
|
||||
// They answer "what is going wrong with this list" without assessing any of
|
||||
// it, and they are said here rather than announced: no tick rule reads them.
|
||||
if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" {
|
||||
if !strings.HasSuffix(reply, ".") {
|
||||
reply += "."
|
||||
}
|
||||
reply += " " + stalls
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
// resolveTaskStatus moves a task he named out loud (Vikunja #512).
|
||||
//
|
||||
// The position path already worked: resolveCandidate answers "первую сделал"
|
||||
// against the list she just read. This is the other half — naming the task
|
||||
// instead of its position, which reached no code at all before the stage-0 rule
|
||||
// in internal/router/taskstatus.go filled the fn slot.
|
||||
//
|
||||
// Three answers besides the move, and none of them guesses. No match says so. A
|
||||
// match on more than one asks which, because closing the wrong task is work he
|
||||
// never finished being marked done. No task named asks which too, since the
|
||||
// router claims the turn without the referent and the list lives here.
|
||||
func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision) string {
|
||||
live, err := h.api.ListTasks(ctx, "live")
|
||||
if err != nil {
|
||||
log.Printf("voice: task status: list: %v", err)
|
||||
return "не получилось посмотреть задачи."
|
||||
}
|
||||
if dec.Slots.Text == "" {
|
||||
return "какую задачу?"
|
||||
}
|
||||
match := matchTaskText(live, dec.Slots.Text)
|
||||
switch len(match) {
|
||||
case 0:
|
||||
return "не нашла такой задачи."
|
||||
case 1:
|
||||
default:
|
||||
return "у тебя несколько подходящих — какую именно?"
|
||||
}
|
||||
pick := match[0]
|
||||
status := dec.Slots.Value
|
||||
// A candidate is work Maven proposed and he never confirmed, and the store
|
||||
// refuses candidate → done: the legal move is to open it first. Saying it is
|
||||
// done IS the confirmation, so both writes happen rather than the turn
|
||||
// naming a gap about a distinction he did not make.
|
||||
if pick.Status == store.TaskCandidate && status == store.TaskDone {
|
||||
if err := h.api.SetTaskStatus(ctx, pick.ID, store.TaskOpen, h.now(), string(sourceVoice)); err != nil {
|
||||
log.Printf("voice: task status: promote %d: %v", pick.ID, err)
|
||||
return "не получилось изменить задачу."
|
||||
}
|
||||
}
|
||||
if err := h.api.SetTaskStatus(ctx, pick.ID, status, h.now(), string(sourceVoice)); err != nil {
|
||||
log.Printf("voice: task status: %d → %s: %v", pick.ID, status, err)
|
||||
return "не получилось изменить задачу."
|
||||
}
|
||||
log.Printf("voice: task %d (%q) → %s", pick.ID, pick.Text, status)
|
||||
if status == store.TaskDropped {
|
||||
return "убрала: " + pick.Text
|
||||
}
|
||||
return "закрыла: " + pick.Text
|
||||
}
|
||||
|
||||
// matchTaskText finds the live tasks he could have meant.
|
||||
//
|
||||
// Normalised containment, either direction, over store.NormalizeTaskText — the
|
||||
// same key capture dedupes on, so a task he can file twice is a task he can name
|
||||
// twice. Either direction because he shortens what he said ("молоко" for
|
||||
// "купить молоко") as often as he pads it.
|
||||
//
|
||||
// Deliberately not fuzzy. A ranked best guess would always return exactly one
|
||||
// answer, and the one thing this must be able to say is that it is not sure.
|
||||
func matchTaskText(live []ipc.Task, named string) []ipc.Task {
|
||||
want := store.NormalizeTaskText(named)
|
||||
if want == "" {
|
||||
return nil
|
||||
}
|
||||
var out []ipc.Task
|
||||
for _, t := range live {
|
||||
have := store.NormalizeTaskText(t.Text)
|
||||
if have == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(have, want) || strings.Contains(want, have) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||
|
||||
@@ -26,6 +26,9 @@ type taskAPI struct {
|
||||
tasks []ipc.Task
|
||||
listArg string
|
||||
listErr error
|
||||
|
||||
moved []setStatusCall
|
||||
moveErr error
|
||||
}
|
||||
|
||||
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||
@@ -253,3 +256,117 @@ func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he
|
||||
// says is done can be shown to take both legal moves.
|
||||
type setStatusCall struct {
|
||||
id int64
|
||||
status string
|
||||
by string
|
||||
}
|
||||
|
||||
func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
|
||||
a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by})
|
||||
return a.moveErr
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||
{ID: 8, Text: "оплатить интернет", Status: "open"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"},
|
||||
})
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg)
|
||||
}
|
||||
if len(api.moved) != 1 {
|
||||
t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved)
|
||||
}
|
||||
if api.moved[0].id != 7 || api.moved[0].status != "done" {
|
||||
t.Errorf("moved %+v, want id 7 → done", api.moved[0])
|
||||
}
|
||||
if !strings.Contains(reply, "купить молоко") {
|
||||
t.Errorf("reply = %q, want the task named back", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The regression crosses the grammar/action seam instead of handing the
|
||||
// action a repaired Decision. The stored title is a normal imperative title,
|
||||
// while the spoken marker names only its topic; framing words must not become
|
||||
// identity and the unrelated live row must remain untouched.
|
||||
func TestActionActMarkerReferentMovesOnlyTheNamedStoredTask(t *testing.T) {
|
||||
api := &taskAPI{tasks: []ipc.Task{
|
||||
{ID: 17, Text: "настроить бэкапы", Status: "open"},
|
||||
{ID: 18, Text: "обновить сертификаты", Status: "open"},
|
||||
}}
|
||||
h := taskHandler(api)
|
||||
dec, matched, accepted := router.TaskStatusGrammar().Evaluate("отметь задачу про бэкапы как сделанную")
|
||||
if !matched || !accepted {
|
||||
t.Fatalf("task-status grammar matched=%v accepted=%v", matched, accepted)
|
||||
}
|
||||
|
||||
reply := h.actionAct(context.Background(), dec)
|
||||
if api.listArg != "live" {
|
||||
t.Errorf("listed %q, want live", api.listArg)
|
||||
}
|
||||
if len(api.moved) != 1 {
|
||||
t.Fatalf("moved %+v, want exactly the named stored task", api.moved)
|
||||
}
|
||||
if got := api.moved[0]; got.id != 17 || got.status != "done" || got.by != "tap:voice" {
|
||||
t.Errorf("moved %+v, want task 17 → done by tap:voice", got)
|
||||
}
|
||||
if !strings.Contains(reply, "настроить бэкапы") {
|
||||
t.Errorf("reply = %q, want the transitioned stored title", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tasks []ipc.Task
|
||||
named string
|
||||
want string
|
||||
}{
|
||||
{"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"},
|
||||
{"two matches", []ipc.Task{
|
||||
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||
{ID: 8, Text: "купить молоко и хлеб", Status: "open"},
|
||||
}, "купить молоко", "несколько"},
|
||||
{"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
api := &taskAPI{tasks: c.tasks}
|
||||
h := taskHandler(api)
|
||||
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named},
|
||||
})
|
||||
if len(api.moved) != 0 {
|
||||
t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved)
|
||||
}
|
||||
if !strings.Contains(reply, c.want) {
|
||||
t.Errorf("reply = %q, want it to contain %q", reply, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) {
|
||||
// The store refuses candidate → done. Saying it is done is the confirmation
|
||||
// the candidate was waiting for, so the arm makes both legal moves.
|
||||
api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}}
|
||||
h := taskHandler(api)
|
||||
h.resolveTaskStatus(context.Background(), router.Decision{
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"},
|
||||
})
|
||||
if len(api.moved) != 2 {
|
||||
t.Fatalf("moved %+v, want open then done", api.moved)
|
||||
}
|
||||
if api.moved[0].status != "open" || api.moved[1].status != "done" {
|
||||
t.Errorf("moved %+v, want open then done", api.moved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
|
||||
// pending telegram nudge every repeat_interval, and nothing in the tree could
|
||||
// ever mark one acked. None of what follows can be reproduced by hand without
|
||||
// sitting in front of the box for hours, so it is covered here or nowhere.
|
||||
|
||||
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
|
||||
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
|
||||
t.Helper()
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
|
||||
t.Fatalf("seed service_down:db=%s: %v", value, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
|
||||
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
|
||||
// repeat these tests are about never happens. The daemon wires it (main.go).
|
||||
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
|
||||
t.Helper()
|
||||
rules := loop.DefaultRules()
|
||||
g := loop.NewGatherer(st, rules)
|
||||
d := delivery.NewDispatcher(delivery.Config{
|
||||
Voice: sink, Ntfy: sink, Telegram: sink,
|
||||
Ack: st, Nudges: st, Reminders: st,
|
||||
})
|
||||
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// telegramSends counts sends that went out on the telegram reach.
|
||||
func telegramSends(sink *fakeSink, rule string) int {
|
||||
n := 0
|
||||
for _, s := range sink.sends {
|
||||
if s.RuleName == rule {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// outcomes returns the outcome of every nudge row for a rule, newest first.
|
||||
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
|
||||
t.Helper()
|
||||
rows, err := st.RecentNudges(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("recent nudges: %v", err)
|
||||
}
|
||||
var out []string
|
||||
for _, n := range rows {
|
||||
if n.Rule == rule {
|
||||
out = append(out, n.Outcome)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
|
||||
// The condition clearing is the ending that should happen. StillTrue reads
|
||||
// the same DownServices helper the phraser reads, so the repeat stops on
|
||||
// exactly the monitor he was told about.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
if telegramSends(sink, "service_down") == 0 {
|
||||
t.Fatal("the alarm never went out; the rest of this test proves nothing")
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
|
||||
}
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeResolved {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
|
||||
// Still down, still un-acked, and nobody has answered in two hours. That is
|
||||
// not one more repeat away from being answered.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(6*time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
|
||||
}
|
||||
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
|
||||
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
|
||||
}
|
||||
// Ignored, not resolved: nothing says the service got better.
|
||||
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||
if o != store.NudgeIgnored {
|
||||
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
|
||||
// Down, up, down again. Closing the first run must not make the second run
|
||||
// unreportable, and must not silently reopen the closed rows either.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.tick(ctx, now)
|
||||
first := len(outcomes(t, st, ctx, "service_down"))
|
||||
|
||||
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||
tl.tick(ctx, now.Add(2*time.Minute))
|
||||
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
|
||||
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
|
||||
}
|
||||
|
||||
seedDown(t, st, ctx, "down", now.Add(25*time.Minute))
|
||||
sink.sends = nil
|
||||
tl.tick(ctx, now.Add(31*time.Minute))
|
||||
|
||||
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
|
||||
}
|
||||
got := outcomes(t, st, ctx, "service_down")
|
||||
if len(got) <= first {
|
||||
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
|
||||
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
|
||||
// that says nothing must keep its alarm until the age cap, or a rule author
|
||||
// silences their own alarm by omission.
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
sink := &fakeSink{}
|
||||
tl := newAlarmTickLoop(t, st, sink)
|
||||
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
|
||||
|
||||
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
|
||||
t.Fatalf("record nudge: %v", err)
|
||||
}
|
||||
|
||||
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
|
||||
if len(live) != 1 {
|
||||
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
|
||||
}
|
||||
|
||||
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
|
||||
if len(live) != 0 {
|
||||
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An empty attention list used to be answered "ничего не требует внимания"
|
||||
// unconditionally, which is an all-clear Maven had no way to know was true
|
||||
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||
func TestAttentionEmptyWithHealthySourcesIsAllClear(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("healthy and quiet should be an all-clear, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttentionEmptyWithAFailedSourceHedges(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[
|
||||
{"source_id":"src_ntfy","health":"ok"},
|
||||
{"source_id":"src_llamacpp","health":"failed"},
|
||||
{"source_id":"src_imap","health":"stale"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a failed source must not read as all-clear, got %q", reply)
|
||||
}
|
||||
for _, want := range []string{"src_llamacpp", "src_imap"} {
|
||||
if !strings.Contains(reply, want) {
|
||||
t.Errorf("reply names no %s: %q", want, reply)
|
||||
}
|
||||
}
|
||||
if strings.Contains(reply, "src_ntfy") {
|
||||
t.Errorf("the healthy source is named as a problem: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// A Praxis that polls nothing knows nothing, which is the state the box is in.
|
||||
func TestAttentionEmptyWithNoSourcesHedges(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("a Praxis with no sources must not answer all-clear, got %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, "источник") {
|
||||
t.Errorf("reply does not say why she cannot tell: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The spec's own mechanism, which the deployed Praxis does not send yet: the
|
||||
// envelope's degraded array is believed without a second call.
|
||||
func TestAttentionDegradedEnvelopeIsReadWithoutASourcesCall(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t,
|
||||
`{"items":[],"degraded":["src_metrics"]}`,
|
||||
`[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "src_metrics") {
|
||||
t.Fatalf("the envelope's degraded source is not named: %q", reply)
|
||||
}
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Path == "/api/v1/sources" {
|
||||
t.Error("sources was read even though the response carried degraded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A sources endpoint that errors is not evidence of a fault: the attention call
|
||||
// itself succeeded, and hedging on it would make her permanently uncertain.
|
||||
func TestAttentionKeepsAllClearWhenSourcesCannotBeRead(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||
praxis.SetRouteFault("/api/v1/sources", 500)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||
t.Fatalf("an unreadable sources list should leave the answer alone, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// Both response shapes decode, because the spec says one and the box sends the
|
||||
// other.
|
||||
func TestPraxisAttentionDecodesBothShapes(t *testing.T) {
|
||||
var bare praxisAttention
|
||||
if err := json.Unmarshal([]byte(`[{"id":"item_1"}]`), &bare); err != nil {
|
||||
t.Fatalf("bare array: %v", err)
|
||||
}
|
||||
if len(bare.Items) != 1 || len(bare.Degraded) != 0 {
|
||||
t.Errorf("bare array decoded as %+v", bare)
|
||||
}
|
||||
|
||||
var env praxisAttention
|
||||
if err := json.Unmarshal([]byte(`{"items":[{"id":"item_2"}],"degraded":["src_a"]}`), &env); err != nil {
|
||||
t.Fatalf("envelope: %v", err)
|
||||
}
|
||||
if len(env.Items) != 1 || len(env.Degraded) != 1 || env.Degraded[0] != "src_a" {
|
||||
t.Errorf("envelope decoded as %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// A source that reports no health at all counts as healthy. A Praxis that never
|
||||
// fills the field would otherwise make every quiet turn a hedge.
|
||||
func TestUnhealthySourcesTreatsAnAbsentHealthFieldAsHealthy(t *testing.T) {
|
||||
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_a"},{"id":"src_b","health":"stale"}]`)
|
||||
bad, total, err := newPraxisClient(praxis.URL).UnhealthySources(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("UnhealthySources: %v", err)
|
||||
}
|
||||
if total != 2 {
|
||||
t.Errorf("total = %d, want 2", total)
|
||||
}
|
||||
if len(bad) != 1 || bad[0] != "src_b" {
|
||||
t.Errorf("bad = %v, want [src_b]", bad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The two boot paths meet here. run() wires the daemon twice: once at boot
|
||||
// when a key is in the environment, and once inside UnlockFn after a passkey
|
||||
// assertion, minutes or days later. Listing the same wiring in both places is
|
||||
// what let them drift — seven workers started untracked on the unlock path and
|
||||
// two daemonAPI fields were never set there, silently, for as long as anyone
|
||||
// had been cold-starting (V-639).
|
||||
//
|
||||
// So both paths call newDaemonAPI and startBackground and nothing else. A
|
||||
// field or a worker added later reaches both paths or neither.
|
||||
|
||||
// bootDeps is everything the two constructors below read. It is filled from
|
||||
// the same variables on both paths, by depsNow in run().
|
||||
type bootDeps struct {
|
||||
coreFor func() ipc.CoreAPI
|
||||
tl *tickLoop
|
||||
evBus *event.Bus
|
||||
voiceW *voiceWiring
|
||||
st *store.Store
|
||||
factWorker *factEnrichmentWorker
|
||||
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
||||
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
|
||||
}
|
||||
|
||||
// newDaemonAPI builds the real CoreAPI, with every field set. The unlock path
|
||||
// used to leave nexus and getMCPServers nil, so after a cold start
|
||||
// ResolveEntity refused with a nexus block configured and /tools rendered
|
||||
// "not configured" with an mcp block configured. Empty is a wrong answer
|
||||
// there, not a degraded one.
|
||||
func newDaemonAPI(d bootDeps) *daemonAPI {
|
||||
api := &daemonAPI{
|
||||
CoreAPI: d.coreFor(),
|
||||
getTrace: d.tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return d.tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return d.tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(d.evBus),
|
||||
getDecisions: turnDecisionsFn(d.voiceW),
|
||||
seedStore: seedStoreIfAllowed(d.st),
|
||||
nexus: nexusOf(d.voiceW),
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.handler != nil {
|
||||
api.chatFn = d.voiceW.handler.handleText
|
||||
// And the reverse: the handler was wired with the bare store adapter,
|
||||
// which cannot serve the day plan. See upgradeAPI.
|
||||
d.voiceW.handler.upgradeAPI(api)
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.mcp != nil {
|
||||
api.getMCPServers = d.voiceW.mcp.status
|
||||
}
|
||||
return api
|
||||
}
|
||||
|
||||
// namedWorker is one long-running goroutine. The name exists so the set is
|
||||
// assertable from a test and readable in a log; nothing dispatches on it.
|
||||
type namedWorker struct {
|
||||
name string
|
||||
run func(ctx context.Context)
|
||||
}
|
||||
|
||||
// backgroundWorkers lists what this deployment runs. It is pure — it starts
|
||||
// nothing — so a test can compare the set the two paths would start without
|
||||
// standing a daemon up.
|
||||
func backgroundWorkers(d bootDeps) []namedWorker {
|
||||
var ws []namedWorker
|
||||
if d.voiceW != nil && d.voiceW.server != nil {
|
||||
ws = append(ws, namedWorker{"voice", func(context.Context) {
|
||||
if err := d.voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("voice serve: %v", err)
|
||||
}
|
||||
}})
|
||||
}
|
||||
ws = append(ws,
|
||||
namedWorker{"tick", d.tl.run},
|
||||
namedWorker{"fact-enrichment", d.factWorker.run},
|
||||
)
|
||||
if d.evalWorker != nil {
|
||||
ws = append(ws, namedWorker{"memory-eval", d.evalWorker.run})
|
||||
}
|
||||
if d.feedWkr != nil {
|
||||
ws = append(ws, namedWorker{"feed", d.feedWkr.run})
|
||||
}
|
||||
if d.crawlWkr != nil {
|
||||
ws = append(ws, namedWorker{"crawl", d.crawlWkr.run})
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.mcp != nil {
|
||||
ws = append(ws, namedWorker{"mcp", d.voiceW.mcp.run})
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.home != nil {
|
||||
ws = append(ws, namedWorker{"home", d.voiceW.home.run})
|
||||
}
|
||||
return ws
|
||||
}
|
||||
|
||||
// startBackground starts every worker through goWorker, so waitWorkers can
|
||||
// wait for it at shutdown. A worker started as a bare `go func()` is the
|
||||
// shutdown bug documented at the end of run(): run() never returns, the
|
||||
// deferred Close never seals the database, and the ciphertext goes stale.
|
||||
func startBackground(ctx context.Context, wg *sync.WaitGroup, d bootDeps) {
|
||||
for _, w := range backgroundWorkers(d) {
|
||||
goWorker(wg, func() { w.run(ctx) })
|
||||
}
|
||||
if d.voiceW != nil && d.voiceW.server != nil {
|
||||
log.Printf("mavend: voice listening on %s", d.voiceW.server.Addr())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/event"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// fullDeps — a deployment with every optional piece present. Nothing here is
|
||||
// run: newDaemonAPI takes method values and backgroundWorkers is pure, so
|
||||
// zero-value wirings are enough to say what WOULD be started.
|
||||
func fullDeps() bootDeps {
|
||||
h := &reactiveHandler{
|
||||
ecosystem: &ecosystemWiring{nexus: &nexusClient{}},
|
||||
decisions: decision.NewRing(),
|
||||
}
|
||||
return bootDeps{
|
||||
coreFor: func() ipc.CoreAPI { return ipc.UnimplementedCoreAPI{} },
|
||||
tl: &tickLoop{},
|
||||
evBus: event.NewBus(4),
|
||||
st: &store.Store{},
|
||||
factWorker: &factEnrichmentWorker{},
|
||||
evalWorker: &memoryEvalWorker{},
|
||||
feedWkr: &feedWorker{},
|
||||
crawlWkr: &crawlWorker{},
|
||||
voiceW: &voiceWiring{
|
||||
server: &voice.Server{},
|
||||
handler: h,
|
||||
mcp: &mcpWiring{},
|
||||
home: &homeWiring{},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The unlock path used to build its own daemonAPI literal and leave nexus and
|
||||
// getMCPServers nil (V-639). Both paths call newDaemonAPI now, so the drift
|
||||
// that can still happen is a field added to the struct and not to the
|
||||
// constructor. This catches that one, by name.
|
||||
func TestNewDaemonAPISetsEveryField(t *testing.T) {
|
||||
prev := allowSeedOnStart
|
||||
allowSeedOnStart = true
|
||||
defer func() { allowSeedOnStart = prev }()
|
||||
|
||||
api := newDaemonAPI(fullDeps())
|
||||
v := reflect.ValueOf(*api)
|
||||
for i := range v.NumField() {
|
||||
if v.Field(i).IsZero() {
|
||||
t.Errorf("newDaemonAPI left %s unset — a fully wired deployment must fill every field", v.Type().Field(i).Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The handler is wired with the bare store adapter and cannot serve the day
|
||||
// plan until upgradeAPI hands it the real one. The unlocked path did that and
|
||||
// the unlock path did it too; keep it a property of the constructor.
|
||||
func TestNewDaemonAPIUpgradesTheHandler(t *testing.T) {
|
||||
d := fullDeps()
|
||||
api := newDaemonAPI(d)
|
||||
if d.voiceW.handler.api != ipc.CoreAPI(api) {
|
||||
t.Fatal("newDaemonAPI did not hand the handler the API it built")
|
||||
}
|
||||
}
|
||||
|
||||
// Every worker the daemon runs goes through startBackground, so shutdown can
|
||||
// wait for it. The unlock path used to start seven of these as bare
|
||||
// `go func()` under a shadowed WaitGroup.
|
||||
func TestBackgroundWorkersFullSet(t *testing.T) {
|
||||
want := []string{"voice", "tick", "fact-enrichment", "memory-eval", "feed", "crawl", "mcp", "home"}
|
||||
var got []string
|
||||
for _, w := range backgroundWorkers(fullDeps()) {
|
||||
got = append(got, w.name)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("workers = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A default box configures none of the optional blocks. Two workers always run
|
||||
// and the rest stay dark, rather than a nil run being scheduled.
|
||||
func TestBackgroundWorkersFloor(t *testing.T) {
|
||||
d := fullDeps()
|
||||
d.evalWorker, d.feedWkr, d.crawlWkr, d.voiceW = nil, nil, nil, nil
|
||||
want := []string{"tick", "fact-enrichment"}
|
||||
var got []string
|
||||
for _, w := range backgroundWorkers(d) {
|
||||
got = append(got, w.name)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("workers = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestCalendarStepsAsideForTheWorld — the defect (Vikunja #552). Weather was
|
||||
// one instance of a wider class, and V-474 fixed only that instance. Every one
|
||||
// of these answered "на 05.08.2026 ничего нет" on the deployed daemon, and
|
||||
// every one of them has an answer in search, which sits below the calendar.
|
||||
func TestCalendarStepsAsideForTheWorld(t *testing.T) {
|
||||
h, api := contQueryHandler()
|
||||
for _, u := range []string{
|
||||
"во сколько закат сегодня",
|
||||
"какой сегодня курс доллара",
|
||||
"какой сегодня праздник",
|
||||
"что интересного произошло сегодня в мире",
|
||||
} {
|
||||
if reply, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); ok {
|
||||
t.Errorf("the calendar claimed %q with %q", u, reply)
|
||||
}
|
||||
}
|
||||
if api.events != 0 {
|
||||
t.Errorf("CalendarEvents called %d times for world questions, want 0", api.events)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same narrowing: a question about his own day still
|
||||
// reaches the calendar, including the one that names no subject at all.
|
||||
func TestCalendarStillAnswersHisDay(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"что у меня сегодня",
|
||||
"во сколько у меня встреча сегодня",
|
||||
"какие встречи завтра",
|
||||
"что в календаре на завтра",
|
||||
"что сегодня?",
|
||||
} {
|
||||
h, _ := contQueryHandler()
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||
}); !ok {
|
||||
t.Errorf("the calendar passed on %q", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A continuation carries its subject in the turn before it, and the calendar
|
||||
// is the only date-aware source, so the narrowing must not reach it.
|
||||
func TestCalendarStillAnswersAContinuation(t *testing.T) {
|
||||
h, _ := contQueryHandler()
|
||||
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "а завтра?", Continued: true},
|
||||
}); !ok {
|
||||
t.Error("the calendar passed on a continuation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The chat path must answer when llama-server is down (Vikunja #45 step 4).
|
||||
// Both halves of a turn call the model — the router and the replier — and each
|
||||
// has its own floor: the cascade falls to the classifier, the replier falls to
|
||||
// the stub. This wires a client at a closed port so both floors are exercised
|
||||
// by a dial error rather than by a stubbed error value.
|
||||
func TestChatAnswersWithNoLlamaServer(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.recall.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead), nil)
|
||||
h.replier = newLLMReplier(dead, nil)
|
||||
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
for _, utt := range []string{
|
||||
"привет",
|
||||
"запиши что кофе закончился",
|
||||
"что у меня сегодня",
|
||||
} {
|
||||
reply := h.handleText(ctx, "web", utt)
|
||||
if reply == "" {
|
||||
t.Errorf("%q answered with nothing; a dead model must degrade to the stub", utt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// daemonAPI.Chat reports an error only when the voice path was never wired.
|
||||
// A turn that reaches handleText always carries text, which is what keeps
|
||||
// mavweb's /api/chat off its error branch when the model is down.
|
||||
func TestChatAPIErrsOnlyWhenUnwired(t *testing.T) {
|
||||
d := &daemonAPI{}
|
||||
if _, err := d.Chat(context.Background(), "web", "привет"); err == nil {
|
||||
t.Fatal("an unwired daemon must say so")
|
||||
}
|
||||
d.chatFn = func(context.Context, string, string) string { return "" }
|
||||
if _, err := d.Chat(context.Background(), "web", "привет"); err != nil {
|
||||
t.Fatalf("a wired daemon must not error: %v", err)
|
||||
}
|
||||
}
|
||||
+441
-81
@@ -6,6 +6,7 @@ import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
@@ -65,8 +66,28 @@ var clarifyExpiredVariants = []string{
|
||||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random.
|
||||
func clarifyExpiredLine() string {
|
||||
// clarifyExpiredPluralVariants — the same notice when TWO parked requests died
|
||||
// together (Vikunja #561). Since a side query suspends the flow instead of
|
||||
// dropping it, the stack can hold both the flow and the thing he interrupted it
|
||||
// with, and TakeExpired drops the whole stack when the top times out. "Прошлую
|
||||
// просьбу" would then be a lie about the count: he loses two and hears about
|
||||
// one.
|
||||
//
|
||||
// Two phrasings only, against five for the singular. This fires when he walks
|
||||
// off in the middle of an interrupted exchange, which is rarer than walking off
|
||||
// in the middle of a plain one, so it repeats less and needs less variety.
|
||||
var clarifyExpiredPluralVariants = []string{
|
||||
"Прости, я слишком долго ждала и отпустила обе прошлые просьбы. Если они ещё нужны, скажи заново.",
|
||||
"Я не дождалась ответа и убрала обе прошлые просьбы. Повтори, если они всё ещё нужны.",
|
||||
}
|
||||
|
||||
// clarifyExpiredLine picks one of them at random. n is how many requests died;
|
||||
// anything above one gets the plural wording, because the bound is two today and
|
||||
// a third would still be "обе" short of the truth only if MaxStackDepth grew.
|
||||
func clarifyExpiredLine(n int) string {
|
||||
if n > 1 {
|
||||
return clarifyExpiredPluralVariants[rand.Intn(len(clarifyExpiredPluralVariants))]
|
||||
}
|
||||
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
|
||||
}
|
||||
|
||||
@@ -74,23 +95,34 @@ func clarifyExpiredLine() string {
|
||||
// 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
|
||||
_, ok := cutClarifyExpired(s)
|
||||
return ok
|
||||
}
|
||||
|
||||
// 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))
|
||||
rest, ok := cutClarifyExpired(s)
|
||||
if !ok {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// cutClarifyExpired matches either expiry deck as a prefix and returns what
|
||||
// follows it. Both decks, since V-561 added the plural line: a caller asking
|
||||
// "did she say a request timed out" means the fact, not which wording carried
|
||||
// it, and a helper that knew only the singular would read the two-request
|
||||
// notice as ordinary reply text.
|
||||
func cutClarifyExpired(s string) (string, bool) {
|
||||
for _, deck := range [][]string{clarifyExpiredVariants, clarifyExpiredPluralVariants} {
|
||||
for _, v := range deck {
|
||||
if strings.HasPrefix(s, v) {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, v)), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||||
@@ -101,11 +133,12 @@ func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||
if h.clarifyStore == nil {
|
||||
return ""
|
||||
}
|
||||
if !h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now()) {
|
||||
n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now())
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
||||
return clarifyExpiredLine()
|
||||
log.Printf("voice: clarify — %d parked question(s) expired, telling him and routing the words fresh", n)
|
||||
return clarifyExpiredLine(n)
|
||||
}
|
||||
|
||||
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
||||
@@ -121,10 +154,47 @@ func withNotice(notice, reply string) string {
|
||||
return notice + " " + reply
|
||||
}
|
||||
|
||||
// withResumed puts the resumed question on the END of this turn's reply, where
|
||||
// withNotice puts the expiry notice on the front (Vikunja #561).
|
||||
//
|
||||
// The order is the owner's: "в Риме сейчас ..., на какое время поставить
|
||||
// напоминание?" — answer first, then the open question. A question in front of
|
||||
// its own answer would read as ignoring what he asked.
|
||||
//
|
||||
// Two sentences, not one (V-654). This used to fold the answer's full stop into
|
||||
// a comma, on the strength of the owner having written it that way once. Spliced
|
||||
// onto a real answer it reads as one run-on thought — "вот что я нашла: вайфай
|
||||
// пароль лежит в ящике стола, на какое время поставить напоминание?" — and the
|
||||
// question disappears into the tail of a sentence about something else. A reply
|
||||
// with no terminator of its own is given one, so the join never depends on how
|
||||
// the phraser chose to end.
|
||||
//
|
||||
// A resume with no answer in front of it is just the question.
|
||||
func withResumed(reply, resumed string) string {
|
||||
if resumed == "" {
|
||||
return reply
|
||||
}
|
||||
reply = strings.TrimSpace(reply)
|
||||
if reply == "" {
|
||||
return resumed
|
||||
}
|
||||
if !endsSentence(reply) {
|
||||
reply += "."
|
||||
}
|
||||
return reply + " " + resumed
|
||||
}
|
||||
|
||||
// endsSentence reports whether s already closes itself. The ellipsis counts: a
|
||||
// trailing "…" is a deliberate end, and a full stop after it reads as a typo.
|
||||
func endsSentence(s string) bool {
|
||||
r, _ := utf8.DecodeLastRuneInString(s)
|
||||
return strings.ContainsRune(".!?…", r)
|
||||
}
|
||||
|
||||
// missingFor returns the slots a decision still needs, most important first.
|
||||
// Empty ⇒ there is nothing identifiable to ask about.
|
||||
func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
return dialogue.StillMissing(wantedSlots[dec.Intent], toDialogueSlots(dec.Slots))
|
||||
return stillMissingFor(dec.Intent, dec.Utterance, toDialogueSlots(dec.Slots))
|
||||
}
|
||||
|
||||
// clarifyQuestion picks the one question to ask for a clarify decision. Returns
|
||||
@@ -133,18 +203,32 @@ func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
// One question about one thing: if two slots are missing she asks about the
|
||||
// first only. Two questions in one breath is an interrogation. The second gap
|
||||
// is picked up on the turn after the first one is answered (askRemainingGap).
|
||||
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
missing := missingFor(dec)
|
||||
if len(missing) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
q, ok := clarifyQuestionFor(missing[0], 1)
|
||||
q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "")
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return missing[0], q, true
|
||||
}
|
||||
|
||||
// questionFor picks the wording for one gap. Every slot but the reminder's time
|
||||
// reads its deck by attempt; the time asks about whichever of the hour, the half
|
||||
// of the day and the day he has not said, and states the clock while it does
|
||||
// (V-579).
|
||||
//
|
||||
// taken is the acknowledgement of what his last turn added, empty when it added
|
||||
// nothing and empty for a first ask, which has no turn behind it (V-593).
|
||||
func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots, taken string) (string, bool) {
|
||||
if slot != dialogue.SlotTime {
|
||||
return clarifyQuestionFor(slot, attempt)
|
||||
}
|
||||
return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now(), taken)
|
||||
}
|
||||
|
||||
// askClarify parks the request and returns the question to ask instead of the
|
||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||
// the caller falls back to the canned reply.
|
||||
@@ -152,11 +236,16 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
slot, question, ok := clarifyQuestion(dec)
|
||||
slot, question, ok := h.clarifyQuestion(dec)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
// An act she could not resolve is a refusal, not a question (Vikunja #556).
|
||||
if slot == dialogue.SlotFn {
|
||||
log.Printf("voice: clarify — act %q matched no capability; saying so instead of asking", dec.Utterance)
|
||||
return actNotRecognized, true
|
||||
}
|
||||
q := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: toDialogueSlots(dec.Slots),
|
||||
Missing: []dialogue.Slot{slot},
|
||||
@@ -165,19 +254,69 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
TTL: clarifyTTL,
|
||||
Attempts: 1, // this ask
|
||||
MaxAttempts: h.clarifyMaxAttempts,
|
||||
})
|
||||
}
|
||||
// Push, not Put, when this turn suspended a flow (Vikunja #561): the side
|
||||
// query needs clarifying of ITS own, and Put would replace the top of the
|
||||
// stack — which is the very question the side query was allowed to interrupt
|
||||
// rather than kill. Push keeps both.
|
||||
//
|
||||
// Push returns whatever the depth bound forced out, and that one has to be
|
||||
// spoken: MaxStackDepth is a promise that every level she keeps is a level
|
||||
// she can name when it dies. It is glued in front, like every other notice
|
||||
// about something let go.
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt != nil && rt.suspended {
|
||||
if evicted := h.clarifyStore.Push(dialogueIDOf(ctx), q); evicted != nil {
|
||||
log.Printf("voice: clarify — stack full at %d, letting go of the request behind %q", dialogue.MaxStackDepth, evicted.Utterance)
|
||||
rt.dropped = withNotice(rt.dropped, clarifyDropped)
|
||||
}
|
||||
// One question per breath still holds. The side query turned out to need
|
||||
// a question of its own, so THAT is the one she asks; resuming as well
|
||||
// would put two questions in one reply, which is the interrogation
|
||||
// askRemainingGap already refuses to run. The suspended flow keeps its
|
||||
// place underneath and is not lost — if it is never reached it dies on
|
||||
// the TTL, and the expiry notice (now plural-aware) says so.
|
||||
rt.resume = ""
|
||||
log.Printf("voice: clarify — asked about %s for intent=%s, stacked on a suspended flow", slot, dec.Intent)
|
||||
return question, true
|
||||
}
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
|
||||
return question, true
|
||||
}
|
||||
|
||||
// resolveClarifyAnswer reads an utterance as the answer to a parked question.
|
||||
// Returns ("", false) when no live question is parked (or it expired), so the
|
||||
// caller routes the utterance normally as a fresh request. Sibling of
|
||||
// resolveConfirm and checked in the same place.
|
||||
// clarifyCancelled — he called the half-built request off. Said out loud, like
|
||||
// every other way it can end: a silent drop reads as "done". Feminine
|
||||
// self-reference ("отменила"), as everywhere.
|
||||
const clarifyCancelled = "Хорошо, отменила."
|
||||
|
||||
// clarifyDropped — he asked for something ELSE instead, so the parked request
|
||||
// is gone. Glued in front of the answer to what he actually asked, because
|
||||
// nothing may be dropped in silence.
|
||||
//
|
||||
// The answer is parsed with the same extractor the router uses, for the intent
|
||||
// she parked — no second parser. If it still does not fill the gap she asks
|
||||
// again, up to MaxAttempts; after that she says out loud that she did not
|
||||
// Only new_request and cancel reach this since V-561. A side query used to as
|
||||
// well, and the owner rejected it on sight: he asks about the weather in the
|
||||
// middle of setting a reminder, and hearing "прошлую просьбу отпускаю" tells him
|
||||
// a thing he did not ask to lose has been lost. It had not been — there was
|
||||
// simply nowhere to put it. Now there is (ClarifyStore's stack), so a side query
|
||||
// suspends and resumes, and apologising for a drop that did not happen is worse
|
||||
// than saying nothing.
|
||||
const clarifyDropped = "Прошлую просьбу отпускаю."
|
||||
|
||||
// resolveClarifyAnswer reads an utterance against the parked question and
|
||||
// decides what it IS before deciding what to do with it. Returns ("", false)
|
||||
// when the turn is not this resolver's — nothing parked, or the utterance turned
|
||||
// out to be a request of its own — so the caller dispatches it normally.
|
||||
//
|
||||
// The order is the point (Vikunja #560). The utterance is ROUTED first, and the
|
||||
// role is read off that decision: a routed decision that stands on its own is
|
||||
// not an answer, whatever the extractor found inside it. Before this the
|
||||
// extractor decided, so "какая сейчас погода в Риме?" became the time of a
|
||||
// reminder on the strength of the word "сейчас".
|
||||
//
|
||||
// The answer itself is parsed with the same extractor the router uses, for the
|
||||
// intent she parked — no second parser. If it still does not fill the gap she
|
||||
// asks again, up to MaxAttempts; after that she says out loud that she did not
|
||||
// understand. She never drops the request in silence.
|
||||
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
||||
if h.clarifyStore == nil {
|
||||
@@ -185,33 +324,101 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
}
|
||||
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||||
if q == nil {
|
||||
return "", false
|
||||
return "", false // not_applicable: nothing is pending
|
||||
}
|
||||
|
||||
intent := router.Intent(q.Intent)
|
||||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
||||
|
||||
var (
|
||||
routed router.Decision
|
||||
routedOK bool
|
||||
)
|
||||
if needsRoute(text) {
|
||||
routed, routedOK = h.routeForRole(ctx, text)
|
||||
}
|
||||
role := classifyTurnRole(q, text, toDialogueSlots(answer), routed, routedOK)
|
||||
log.Printf("voice: clarify — %q is a %s against %s (routed=%v)", text, role, dialogue.CapabilityFor(q.Intent), routedOK)
|
||||
|
||||
switch role {
|
||||
case roleCancel:
|
||||
h.completeClarifyTop(ctx)
|
||||
return clarifyCancelled, true
|
||||
case roleSideQuery:
|
||||
// He asked something of his own WITHOUT leaving the flow. The question
|
||||
// stays exactly where it is — same slot, same attempt, same parked
|
||||
// utterance — and these words go on to be answered as themselves. The
|
||||
// resumed question is then glued onto the back of that answer, so one
|
||||
// reply carries both acts (Vikunja #561).
|
||||
//
|
||||
// No attempt is spent. He answered the side query, not the parked
|
||||
// question, and charging a retry for a turn that was never an answer is
|
||||
// the V-554 shape.
|
||||
h.noteSuspended(ctx, q)
|
||||
return "", false
|
||||
case roleAside:
|
||||
// He stated something in the middle of the flow. Same machinery as a
|
||||
// side query and for the same reason: the words are answered as
|
||||
// themselves, so the note or the fact is stored, and the question comes
|
||||
// back on the end of the same reply (V-577 shape 2). Storing it in
|
||||
// silence and dropping it in silence are both wrong, and dropping it is
|
||||
// what she did.
|
||||
h.noteSuspended(ctx, q)
|
||||
return "", false
|
||||
case roleNewRequest:
|
||||
// He moved on. A parked question used to swallow whatever came next, so
|
||||
// one act she could not fulfil ate the following three turns (Vikunja
|
||||
// #554) and a world question set a reminder for a time nobody asked for
|
||||
// (#558). Drop the question, say so, and let these words be themselves.
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.noteDropped(ctx)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// He is answering, so the run of step-asides is over (V-654). Reset here
|
||||
// rather than where a gap is FILLED: "позвонить маме" against a question
|
||||
// about the time gives her nothing she asked for and still means he is in
|
||||
// the exchange, and the retry it costs is bound enough on its own. The
|
||||
// counter is for the case the bounds miss — he asked for other things and
|
||||
// never came back.
|
||||
q.Suspends = 0
|
||||
|
||||
merged := q.Answer(text, toDialogueSlots(answer))
|
||||
// Fold a newly answered subject into the raw utterance. Downstream actions
|
||||
// phrase from Utterance, not from the text slot — actionReminder stores it
|
||||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||||
// A fact answers with a key and a value and fills no Text slot at all, so
|
||||
// the fold above leaves the utterance at the bare "запиши" — and that is
|
||||
// what the confirmation now reads back to him (V-592). His raw words are the
|
||||
// only record of what he said, so they are what is folded. Never for a time
|
||||
// question: what he says about when is kept apart in WhenText on purpose,
|
||||
// or the reminder would read the day back at him when it fires.
|
||||
if merged.Text == "" && !asksAboutTime(q.Missing) {
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, text)
|
||||
}
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
|
||||
// One gap filled is not the same as a complete request. askClarify parks
|
||||
// only the first gap, because one question per turn is the rule, but a
|
||||
// reminder wants both a subject and a time. "напомни" with neither used to
|
||||
// ask "О чём напомнить?", accept "позвонить маме", and then hand applyAction
|
||||
// a reminder with no time, which answered "не получилось разобрать время
|
||||
// напоминания." — an error for a request she never finished asking about.
|
||||
// Re-enter the loop instead, one question at a time as before.
|
||||
if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
|
||||
return reply, true
|
||||
// An answer about the time joins everything else he has said about the time,
|
||||
// and the whole of it is re-read as one request (V-579). "завтра" names the
|
||||
// day of an hour she is already holding, and read alone it names no hour at
|
||||
// all, so the parser would have nothing and she would ask for ever.
|
||||
// What he had already said about the time, read BEFORE this answer joins it.
|
||||
// A re-ask that cannot tell the two apart is the one that repeats itself
|
||||
// byte for byte (V-593).
|
||||
var taken string
|
||||
if asksAboutTime(q.Missing) {
|
||||
before := whenKnownOf(whenTextOf(q), q.Slots.HasTime)
|
||||
q.WhenText = strings.TrimSpace(q.WhenText + " " + text)
|
||||
if t, ok := h.readWhen(ctx, intent, q, text); ok {
|
||||
merged.Time, merged.HasTime = t, true
|
||||
}
|
||||
if before.movedForward(whenKnownOf(whenTextOf(q), merged.HasTime)) {
|
||||
taken = whenTakenLine(text)
|
||||
}
|
||||
}
|
||||
if stillOpen(q.Missing, whenTextOf(q), merged) {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text, taken), true
|
||||
}
|
||||
|
||||
// Rebuild the decision as if it had routed cleanly, then run it down the
|
||||
// normal path. Clarify is deliberately false and the intent is unchanged:
|
||||
// filling in an argument never grants authority, so the completed decision
|
||||
@@ -223,7 +430,102 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
Intent: intent,
|
||||
Slots: applyDialogueSlots(answer, merged),
|
||||
}
|
||||
return h.finishClarified(ctx, dec), true
|
||||
// Time answers stay out of dec.Utterance because it is also the reminder
|
||||
// payload. The action schema still needs that evidence, so validate a copy
|
||||
// carrying the full time exchange while executing the clean decision.
|
||||
schemaDec := dec
|
||||
schemaDec.Utterance = whenTextOf(q)
|
||||
|
||||
// One gap filled is not the same as a complete request. askClarify parks
|
||||
// only the first gap, because one question per turn is the rule, but a
|
||||
// reminder wants both a subject and a time. Re-enter the schema one question
|
||||
// at a time. If the shared attempt budget is spent, askRemainingGap visibly
|
||||
// gives up and removes this stack level; it must never turn an incomplete
|
||||
// decision into permission to act (V-717).
|
||||
if reply, handled := h.askRemainingGap(ctx, q, schemaDec); handled {
|
||||
return reply, true
|
||||
}
|
||||
h.completeClarifyTop(ctx)
|
||||
return h.finishClarified(ctx, dec, schemaDec), true
|
||||
}
|
||||
|
||||
// completeClarifyTop finishes only the active question. A nested question can
|
||||
// sit above a flow that was suspended by a side request or repair; deleting the
|
||||
// dialogue id here erased both. If one survives underneath, restart its clock
|
||||
// from the moment it is spoken again and attach its question to this turn.
|
||||
func (h *reactiveHandler) completeClarifyTop(ctx context.Context) {
|
||||
if h.clarifyStore == nil {
|
||||
return
|
||||
}
|
||||
_, resumed := h.clarifyStore.CompleteTop(dialogueIDOf(ctx), h.now())
|
||||
if resumed == nil || len(resumed.Missing) == 0 {
|
||||
return
|
||||
}
|
||||
question, ok := clarifyResumedFor(resumed.Missing[0])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if rt := turnRouteFrom(ctx); rt != nil {
|
||||
rt.resume = question
|
||||
}
|
||||
}
|
||||
|
||||
// noteDropped records that the parked request was let go this turn, so runTurn
|
||||
// can say it in front of whatever these words are answered with. Nothing to
|
||||
// record outside runTurn — a unit test calling one resolver has no turn to glue
|
||||
// a notice onto.
|
||||
func (h *reactiveHandler) noteDropped(ctx context.Context) {
|
||||
if rt := turnRouteFrom(ctx); rt != nil {
|
||||
rt.dropped = clarifyDropped
|
||||
}
|
||||
}
|
||||
|
||||
// noteSuspended keeps the parked question alive across a side query and records
|
||||
// the words that bring it back, so runTurn can put them after this turn's answer
|
||||
// (Vikunja #561).
|
||||
//
|
||||
// Two things happen to the question and neither is an attempt. Its clock is
|
||||
// restarted, because she is about to ask it again and the 90s TTL measures the
|
||||
// pause since she last spoke it — leaving Asked at the original ask would let a
|
||||
// flow he is actively working through die of a wait he did not take. And the
|
||||
// stack is left exactly as it is: the question is already on top, so suspending
|
||||
// it is not a write.
|
||||
//
|
||||
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
|
||||
// says nothing. She must not claim to be holding a question she cannot re-ask.
|
||||
//
|
||||
// Suspension is bounded, since V-654. Neither of the two things above is a
|
||||
// limit: no attempt is spent, and restarting the clock means the TTL cannot
|
||||
// arrive while he keeps talking. So the count is the only thing that ends it,
|
||||
// and past MaxSuspends she lets the request go and says so with the same line
|
||||
// every other drop uses. The rule is unchanged — a question ends by being
|
||||
// answered or by being let go out loud — this only recognises three unrelated
|
||||
// requests in a row as the second of those.
|
||||
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt == nil || len(q.Missing) == 0 {
|
||||
return
|
||||
}
|
||||
question, ok := clarifyResumedFor(q.Missing[0])
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !q.CanResume() {
|
||||
h.completeClarifyTop(ctx)
|
||||
h.noteDropped(ctx)
|
||||
log.Printf("voice: clarify — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides)
|
||||
return
|
||||
}
|
||||
q.Suspends++
|
||||
// Rides is the same event counted without the reset (V-663). Incremented
|
||||
// beside Suspends and never anywhere else, so the two cannot disagree about
|
||||
// what happened, only about how much of it they remember.
|
||||
q.Rides++
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
rt.resume = question
|
||||
rt.suspended = true
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d, ride %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends, q.Rides, dialogue.MaxRides)
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
@@ -242,51 +544,64 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
|
||||
return strings.TrimSpace(utterance) + " " + subject
|
||||
}
|
||||
|
||||
// askRemainingGap re-parks the request when the answer closed one gap and
|
||||
// wantedSlots still names another. Returns ("", false) when the request is
|
||||
// complete, when there is no question for what is left, or when she is out of
|
||||
// attempts — in all three the caller runs the decision as it stands, which for
|
||||
// the out-of-attempts case is the old behaviour and is the right one: she has
|
||||
// already asked enough.
|
||||
// askRemainingGap re-parks the request when the answer closed one gap and the
|
||||
// action schema still names another. Returns ("", false) only when the request
|
||||
// is complete. A remaining gap is always handled here: one next question while
|
||||
// budget remains, otherwise an explicit give-up with no partial action (V-717).
|
||||
//
|
||||
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
||||
// costs a question exactly like a second try at the first one does, so the cap
|
||||
// still bounds how many times she can speak before acting or letting go.
|
||||
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, dec router.Decision) (string, bool) {
|
||||
remaining := missingFor(dec)
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
}
|
||||
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||
// with the re-ask path, so the second gap is worded like a second try.
|
||||
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
|
||||
merged := toDialogueSlots(dec.Slots)
|
||||
question, ok := h.questionFor(remaining[0], q.Attempts+1, dec.Utterance, merged, "")
|
||||
if !ok || !q.CanAsk() {
|
||||
return "", false
|
||||
h.completeClarifyTop(ctx)
|
||||
log.Printf("voice: clarify — gave up with required gap %s still open after %d question(s); no action ran", remaining[0], q.Attempts)
|
||||
return clarifyGaveUp, true
|
||||
}
|
||||
// Suspends is not carried, and by this point it is already zero: the answer
|
||||
// path resets it (V-654). Left off the literal so the zero is stated where
|
||||
// the struct is built, rather than inherited from a field nobody names.
|
||||
//
|
||||
// Rides IS carried, and that is the whole point of it (V-663). This is the
|
||||
// same request under a second question, not a new one, so the turns it has
|
||||
// already ridden still count against it. Dropping the field here is exactly
|
||||
// the re-basing that let one question ride twenty-six replies.
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
Missing: []dialogue.Slot{remaining[0]},
|
||||
Utterance: q.Utterance,
|
||||
WhenText: q.WhenText,
|
||||
Asked: h.now(),
|
||||
TTL: clarifyTTL,
|
||||
Attempts: q.Attempts + 1,
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
Rides: q.Rides,
|
||||
})
|
||||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
|
||||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], dec.Intent, q.Attempts+1)
|
||||
return question, true
|
||||
}
|
||||
|
||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||
// again while she has attempts left, otherwise say she did not understand and
|
||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
// taken is the acknowledgement of what this answer DID give, empty when it gave
|
||||
// nothing (V-593). The give-up line never carries it: it is not another ask.
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text, taken string) string {
|
||||
question := ""
|
||||
if len(q.Missing) > 0 {
|
||||
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
|
||||
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken)
|
||||
}
|
||||
if question == "" || !q.CanAsk() {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.completeClarifyTop(ctx)
|
||||
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||||
return clarifyGaveUp
|
||||
}
|
||||
@@ -300,18 +615,51 @@ func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.Pending
|
||||
return question
|
||||
}
|
||||
|
||||
// finishClarified runs a completed decision through the same steps a freshly
|
||||
// routed one takes: remember the turn, act, then phrase.
|
||||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
||||
// finishClarified completes a decision whose parked gaps were already checked
|
||||
// by resolveClarifyAnswer. It still records the turn for a later correction;
|
||||
// the old path made anything completed through dialogue uncorrectable (V-573).
|
||||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec, schemaDec router.Decision) string {
|
||||
return h.finishRebuilt(ctx, dec, schemaDec, false)
|
||||
}
|
||||
|
||||
// finishRepaired validates a decision rebuilt from an older utterance. Unlike
|
||||
// resolveClarifyAnswer, repair has not passed the current slot gate, so it must
|
||||
// ask about any missing argument before acting (V-573).
|
||||
func (h *reactiveHandler) finishRepaired(ctx context.Context, dec router.Decision) string {
|
||||
return h.finishRebuilt(ctx, dec, dec, true)
|
||||
}
|
||||
|
||||
// finishRebuilt is the execution boundary for decisions reconstructed from
|
||||
// dialogue. schemaDec is the same action with all validation evidence present;
|
||||
// a clarified reminder includes the separately-held time answers there while
|
||||
// dec keeps the clean reminder payload. No rebuilt action crosses this boundary
|
||||
// while missingFor still names a required slot.
|
||||
func (h *reactiveHandler) finishRebuilt(ctx context.Context, dec, schemaDec router.Decision, ask bool) string {
|
||||
missing := missingFor(schemaDec)
|
||||
if ask && (schemaDec.Clarify || len(missing) > 0) {
|
||||
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||||
return reply
|
||||
}
|
||||
if question, asked := h.askClarify(ctx, dec); asked {
|
||||
return question
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
log.Printf("voice: clarify — refusing incomplete rebuilt intent=%s with required gaps %v; no action ran", dec.Intent, missing)
|
||||
return clarifyGaveUp
|
||||
}
|
||||
if h.dialogueSessions != nil {
|
||||
now := h.now()
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||
dec = followUpMerge(prev, dec, now)
|
||||
h.rememberTurn(prev, dec, now)
|
||||
h.rememberTurn(ctx, prev, dec, now)
|
||||
}
|
||||
if !dec.Clarify {
|
||||
h.recordTurn(dec.Utterance, dec.Intent)
|
||||
}
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply == "" {
|
||||
reply = h.replier.Reply(dec)
|
||||
reply = h.replier.Reply(ctx, dec)
|
||||
}
|
||||
if reply == "" {
|
||||
// Belt: an empty reply here would be a silent drop.
|
||||
@@ -320,25 +668,35 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
||||
return reply
|
||||
}
|
||||
|
||||
// maxCarriedHistory — how many turns of PRIOR history (beyond the immediate
|
||||
// last turn) rememberTurn carries forward. The session ends up holding this
|
||||
// many plus the one just-finished turn, so callers describing the total
|
||||
// depth (chatHistory's doc comment, this one) say "up to 4".
|
||||
const maxCarriedHistory = 3
|
||||
|
||||
// rememberTurn stores this turn as the dialogue session the next follow-up
|
||||
// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
|
||||
// one long conversation can't grow the session unboundedly.
|
||||
func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
var history []dialogue.Turn
|
||||
if prev != nil {
|
||||
history = append(history, dialogue.Turn{
|
||||
Intent: prev.Intent,
|
||||
Slots: prev.Slots,
|
||||
Text: prev.Slots.Text,
|
||||
})
|
||||
maxHist := len(prev.History)
|
||||
if maxHist > 3 {
|
||||
maxHist = 3
|
||||
// History is chronological. Keep the newest tail of the older history,
|
||||
// then append the immediate prior turn. The previous implementation put
|
||||
// the newest turn first while the type contract said newest-last, so the
|
||||
// model read a conversation backwards.
|
||||
from := len(prev.History) - maxCarriedHistory
|
||||
if from < 0 {
|
||||
from = 0
|
||||
}
|
||||
history = append(history, prev.History[:maxHist]...)
|
||||
history = append(history, prev.History[from:]...)
|
||||
history = append(history, sessionAsTurn(prev))
|
||||
}
|
||||
conversational := dec.Intent == router.IntentChat || opensConversation(dec.Utterance)
|
||||
if prev != nil && (prev.Conversational || prev.Intent == dialogue.IntentChat) {
|
||||
conversational = true
|
||||
}
|
||||
ttl := time.Duration(0) // use the store default (2 min)
|
||||
if dec.Intent == router.IntentChat {
|
||||
if conversational {
|
||||
ttl = 15 * time.Minute // conversational turns should last longer
|
||||
}
|
||||
// A system or query turn often carries no Text slot at all — a stage-0
|
||||
@@ -359,11 +717,13 @@ func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decisi
|
||||
if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
|
||||
slots.Text = dec.Utterance
|
||||
}
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: slots,
|
||||
Timestamp: now,
|
||||
TTL: ttl,
|
||||
History: history,
|
||||
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{
|
||||
Intent: dialogue.Intent(dec.Intent),
|
||||
Slots: slots,
|
||||
Utterance: dec.Utterance,
|
||||
Conversational: conversational,
|
||||
Timestamp: now,
|
||||
TTL: ttl,
|
||||
History: history,
|
||||
})
|
||||
}
|
||||
|
||||
+332
-45
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser/eval"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -57,18 +58,26 @@ 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: "напомни позвонить маме"}, "напомни позвонить маме"), "Сейчас 09:00. Когда?", true},
|
||||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||||
// 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},
|
||||
// A bare hour is half of a day away from being an answer, and she asks
|
||||
// which half rather than picking one (V-579).
|
||||
{"reminder whose hour could be either half of the day", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "Сейчас 09:00. Это утра или вечера?", true},
|
||||
{"reminder that has all three", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни завтра в 15:00 позвонить маме"), "", false},
|
||||
// The owner's own two, confirmed 2026-08-06: an unambiguous time and a
|
||||
// relative one are both complete and are never asked about.
|
||||
{"an interval names the instant by itself", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни через час позвонить маме"), "", false},
|
||||
{"half an hour is an interval too", clarifyDec(router.IntentReminder, router.Slots{Text: "выключить духовку", HasTime: true}, "напомни через полчаса выключить духовку"), "", 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},
|
||||
}
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
for _, tc := range cases {
|
||||
_, got, asked := clarifyQuestion(tc.dec)
|
||||
_, got, asked := h.clarifyQuestion(tc.dec)
|
||||
if asked != tc.asked || got != tc.want {
|
||||
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
|
||||
}
|
||||
@@ -82,11 +91,13 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "Когда?" {
|
||||
if !asked || question != "Сейчас 09:00. Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00")
|
||||
// The answer names the day as well as the hour. A reminder commits on what,
|
||||
// what time and what day, and a dayless hour is asked about (V-579).
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00")
|
||||
if !handled {
|
||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||||
}
|
||||
@@ -158,11 +169,11 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
}
|
||||
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||
// question he already failed to answer is the worst way to ask it.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
|
||||
want, _ := whenQuestion(whenNoHour, i, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||
}
|
||||
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
|
||||
if first, _ := whenQuestion(whenNoHour, 1, h.now(), ""); reply == first {
|
||||
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
@@ -215,10 +226,11 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
if q == nil {
|
||||
t.Fatal("expected an armed question")
|
||||
}
|
||||
first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now())
|
||||
q.Slots = q.Answer("в 11:00", toDialogueSlots(first))
|
||||
first := h.extractor.Extract(ctx, router.IntentReminder, "сегодня в 11:00", h.now())
|
||||
q.Slots = q.Answer("сегодня в 11:00", toDialogueSlots(first))
|
||||
q.WhenText = "сегодня в 11:00"
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, сегодня в 15:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
@@ -231,34 +243,35 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifiedActOffAllowlistIsStillRefused — clarification fills in an
|
||||
// argument, it never grants authority.
|
||||
func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
||||
// TestActOffAllowlistIsStillRefused — naming a capability is not being granted
|
||||
// one. Since Vikunja #556 an unresolved act no longer parks a question, so this
|
||||
// goes through applyAction, which is the only way an act runs.
|
||||
func TestActOffAllowlistIsStillRefused(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
||||
if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("an act with no fn should be asked about")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
||||
if !handled {
|
||||
t.Fatal("the answer should be consumed")
|
||||
}
|
||||
reply := h.applyAction(ctx, router.Decision{
|
||||
Utterance: "rm " + marker,
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "rm " + marker, HasFn: true},
|
||||
})
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Fatalf("an act that is not on the allowlist must not report success: %q", reply)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("a clarified act off the allowlist ran anyway: %v", err)
|
||||
}
|
||||
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 0 {
|
||||
t.Fatalf("clarify must not enable a tool: tools=%+v err=%v", tools, err)
|
||||
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 1 {
|
||||
t.Fatalf("an act must not enable a tool: tools=%+v err=%v", tools, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifiedDestructiveActStillNeedsConfirm — the confirm gate survives the
|
||||
// clarify path.
|
||||
func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
// TestDestructiveActStillNeedsConfirm — the confirm gate stands on the act path.
|
||||
func TestDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
marker := filepath.Join(t.TempDir(), "destructive-ran")
|
||||
@@ -266,18 +279,16 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
||||
if !handled {
|
||||
t.Fatal("the answer should be consumed")
|
||||
}
|
||||
reply := h.applyAction(ctx, router.Decision{
|
||||
Utterance: "delete_backups",
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "delete_backups", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да") || h.pending == nil {
|
||||
t.Fatalf("a clarified destructive act must still park a confirm: reply=%q pending=%+v", reply, h.pending)
|
||||
t.Fatalf("a destructive act must park a confirm: reply=%q pending=%+v", reply, h.pending)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("a clarified destructive act ran before confirmation: %v", err)
|
||||
t.Fatalf("a destructive act ran before confirmation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +317,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||
h, _, now := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h.recall.embedder = emb
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||
h.router = buildRouter(emb, h.matcher, 0.55, nil, nil)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||
t.Fatal("expected a question")
|
||||
@@ -357,7 +368,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
}
|
||||
// Second gap, second attempt, so it is the second wording of the time
|
||||
// question — the attempt budget is shared between the two paths.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
|
||||
want, _ := whenQuestion(whenNoHour, 2, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||
}
|
||||
@@ -369,7 +380,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
||||
}
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
@@ -383,25 +394,86 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
|
||||
// TestClarifySecondGapRespectsTheAttemptCap — the second gap spends a question
|
||||
// out of the same budget, so it cannot turn a capped exchange into an endless
|
||||
// one. With one attempt allowed she acts on what she has instead of asking.
|
||||
// one. With one attempt allowed she gives up visibly and creates nothing: the
|
||||
// cap is a bound on dialogue, never a path around the action schema (V-717).
|
||||
func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
h.clarifyMaxAttempts = 1
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||
t.Fatal("expected the subject question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||
if !handled {
|
||||
t.Fatal("the answer must be consumed")
|
||||
if !handled || reply != clarifyGaveUp {
|
||||
t.Fatalf("out of attempts she must give up visibly, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if reply == "Когда?" {
|
||||
t.Fatal("out of attempts she must not ask a second question")
|
||||
if isAnyClarifyQuestion(reply) {
|
||||
t.Fatalf("out of attempts she must not ask another question: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Fatal("no question may stay armed past the cap")
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("an incomplete exhausted request created a reminder: reminders=%+v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausting a nested top request removes only that request and makes the
|
||||
// lower flow audible again in the same reply. This is the multi-gap exhaustion
|
||||
// shape, not the ordinary failed-answer path covered in repair_test.go.
|
||||
func TestClarifySecondGapExhaustionResumesLowerFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
older := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.IntentReminder,
|
||||
Slots: dialogue.Slots{Text: "позвонить маме"}, Missing: []dialogue.Slot{dialogue.SlotTime},
|
||||
Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL,
|
||||
Attempts: 1, MaxAttempts: dialogue.DefaultMaxAttempts,
|
||||
}
|
||||
top := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotText},
|
||||
Utterance: "напомни", Asked: h.now(), TTL: clarifyTTL,
|
||||
Attempts: 1, MaxAttempts: 1,
|
||||
}
|
||||
h.clarifyStore.Push(voiceDialogueID, older)
|
||||
h.clarifyStore.Push(voiceDialogueID, top)
|
||||
|
||||
reply := h.runTurn(ctx, "купить хлеб", sourceText)
|
||||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||
want := withResumed(clarifyGaveUp, resumed)
|
||||
if reply != want {
|
||||
t.Fatalf("reply=%q, want visible top give-up followed by resumed lower question %q", reply, want)
|
||||
}
|
||||
if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 1 {
|
||||
t.Fatalf("exhausting the top request left stack depth %d, want 1", depth)
|
||||
}
|
||||
if got := h.clarifyStore.Get(voiceDialogueID, h.now()); got != older {
|
||||
t.Fatalf("resumed flow=%+v, want the older question", got)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("nested exhaustion partially created a reminder: reminders=%+v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The rebuilt-action boundary repeats the schema invariant even though the
|
||||
// normal resolver checked it one branch earlier. A future dialogue caller must
|
||||
// not be able to bypass required slots by calling the completion wrapper.
|
||||
func TestFinishClarifiedRefusesIncompleteAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
dec := router.Decision{
|
||||
Utterance: "напомни позвонить маме",
|
||||
Stage: 2,
|
||||
Intent: router.IntentReminder,
|
||||
Slots: router.Slots{Text: "позвонить маме"},
|
||||
}
|
||||
if reply := h.finishClarified(ctx, dec, dec); reply != clarifyGaveUp {
|
||||
t.Fatalf("incomplete rebuilt action reply=%q, want %q", reply, clarifyGaveUp)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("rebuilt-action guard allowed a partial reminder: reminders=%+v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyProseHoldsThePersona — these lines are hand-written Russian that
|
||||
@@ -419,7 +491,7 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
||||
eval.CheckAddress: true,
|
||||
eval.CheckCringe: true,
|
||||
}
|
||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
||||
lines := append([]string{clarifyGaveUp, clarifyCancelled, clarifyDropped}, clarifyExpiredVariants...)
|
||||
lines = append(lines, clarifyMissedVariants...)
|
||||
for _, variants := range clarifyQuestionVariants {
|
||||
lines = append(lines, variants...)
|
||||
@@ -487,7 +559,7 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||||
at := h.now().Add(2 * time.Hour)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||||
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
||||
router.Slots{Time: at, HasTime: true}, "напомни сегодня в 11 утра"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
@@ -564,3 +636,218 @@ func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||
t.Fatalf("notice = %q, want silence: nothing survived to expire", notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyStepsAsideForItsOwnRequest — Vikunja #554. An act she could not
|
||||
// fulfil parked "Что сделать?", and the three turns after it were scored as
|
||||
// answers to that question: a world question, then "как дела", then the give-up
|
||||
// line. None of them was ever an answer.
|
||||
func TestClarifyStepsAsideForItsOwnRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
// A reminder, not the act this bug was found on: since Vikunja #556 an act
|
||||
// no longer parks anything, so it can no longer eat the turn after it.
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("a reminder with no time should be asked about")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "кто изобрёл телефон"); handled {
|
||||
t.Fatalf("a world question must route as itself, got %q", reply)
|
||||
}
|
||||
// Not eating the turn is `handled == false` above, and that is the whole of
|
||||
// #554. Since V-561 the question also SURVIVES it: a side query suspends the
|
||||
// flow rather than ending it, so the reminder is still there and still on the
|
||||
// attempt it was parked with.
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
t.Fatal("a side query must suspend the parked question, not drop it")
|
||||
}
|
||||
if q.Attempts != 1 {
|
||||
t.Errorf("a turn that was never an answer spent an attempt: %d, want 1", q.Attempts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyStillRetriesOnAnAnswerThatMissed — the other half of #554, and the
|
||||
// reason the test above is narrow. A bare noun answers nothing either, but it
|
||||
// carries no request of its own, so she asks again as before.
|
||||
func TestClarifyStillRetriesOnAnAnswerThatMissed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the time question")
|
||||
}
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "ага")
|
||||
if !handled || reply == "" {
|
||||
t.Fatalf("a missed answer must still be re-asked, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
t.Error("the question must survive a missed answer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands — the guard runs only
|
||||
// where nothing was filled. "во сколько?" is question-shaped and is also how a
|
||||
// time gets said back, so an answer that closes the gap wins whatever its shape.
|
||||
func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the time question")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если сегодня в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("reminder was not created: reminders=%v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnresolvedActSaysItDoesNotKnowTheCommand — Vikunja #556. "Что сделать?"
|
||||
// has no answer he can give, so an act that matched no capability is refused in
|
||||
// one line and nothing is parked. It does not recite what she can do instead.
|
||||
func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
// Enabled tools change nothing here: this act matched none of them.
|
||||
if err := st.EnableTool(ctx, "uptime", []string{"true"}, false, "test", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply, spoken := h.askClarify(ctx, clarifyDec(router.IntentAct, router.Slots{Text: "выключи свет"}, "выключи свет"))
|
||||
if !spoken || reply != actNotRecognized {
|
||||
t.Fatalf("reply = %q spoken=%v, want %q", reply, spoken, actNotRecognized)
|
||||
}
|
||||
if strings.Contains(reply, "uptime") {
|
||||
t.Errorf("reply = %q, want no list of capabilities he did not ask about", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
||||
t.Error("nothing to ask about, so nothing may be parked")
|
||||
}
|
||||
}
|
||||
|
||||
// newRoutingClarifyHandler wires the real cascade (hash embedder, no model) onto
|
||||
// the clarify handler, so a test can drive handleText end to end and see which
|
||||
// gate claimed the turn.
|
||||
func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
|
||||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestIncompleteReminderAsksInsteadOfFailing — Vikunja #557. "напомни позвонить"
|
||||
// is routed confidently and is still half a request. It used to reach applyAction,
|
||||
// fail on the missing time and park nothing, so the "в семь вечера" that followed
|
||||
// was routed as a world question and web-searched.
|
||||
func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newRoutingClarifyHandler(t)
|
||||
|
||||
reply := h.handleText(ctx, "web", "напомни позвонить маме")
|
||||
want, _ := whenQuestion(whenNoHour, 1, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("reply = %q, want the time question %q", reply, want)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
|
||||
t.Fatal("the request must be parked, or the answer has nowhere to land")
|
||||
}
|
||||
if reply := h.handleText(ctx, "web", "сегодня в семь вечера"); strings.Contains(reply, "нашла") {
|
||||
t.Fatalf("the answer to her own question must not be looked up: %q", reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||||
t.Fatalf("the answer did not complete the reminder: reminders=%v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBareCaptureVerbAsksWhatToRecord — the other half of #557. A bare "запиши"
|
||||
// went to the resident model as chat, which agreed to a wording change nobody
|
||||
// asked for. It is a fact with no key, and that gap has a question.
|
||||
func TestBareCaptureVerbAsksWhatToRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
|
||||
reply := h.handleText(ctx, "web", "запиши")
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotKey, 1)
|
||||
if reply != want {
|
||||
t.Fatalf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
|
||||
t.Fatal("the request must be parked so the next utterance completes it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestACompleteTurnStillDoesNotAsk — the gate reads a missing slot, not any
|
||||
// slot, so a request she can act on must never turn into a question. Checked on
|
||||
// the decision rather than through the cascade: what is at stake is the gate's
|
||||
// condition, and driving it through the hash embedder would measure routing.
|
||||
func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
complete := []router.Decision{
|
||||
{Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни завтра в 11 утра позвонить маме"},
|
||||
{Intent: router.IntentFact, Slots: router.Slots{Key: "water", Value: "выпил", HasKey: true}, Utterance: "я выпил воды"},
|
||||
{Intent: router.IntentNote, Slots: router.Slots{Text: "купить хлеб"}, Utterance: "запиши купить хлеб"},
|
||||
{Intent: router.IntentQuery, Slots: router.Slots{Text: "что у меня сегодня"}, Utterance: "что у меня сегодня"},
|
||||
}
|
||||
for _, dec := range complete {
|
||||
if gaps := missingFor(dec); len(gaps) > 0 {
|
||||
t.Errorf("%q reads as incomplete: %v", dec.Utterance, gaps)
|
||||
}
|
||||
if reply, asked := h.askClarify(ctx, dec); asked {
|
||||
t.Errorf("%q was answered with a question: %q", dec.Utterance, reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheResumedQuestionIsItsOwnSentence — V-654. The re-ask used to be spliced
|
||||
// onto the answer with a comma, so a real answer and an unrelated open question
|
||||
// read as one run-on thought and the question vanished into its tail.
|
||||
func TestTheResumedQuestionIsItsOwnSentence(t *testing.T) {
|
||||
const resumed = "На какое время поставить напоминание?"
|
||||
cases := []struct {
|
||||
name string
|
||||
reply string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// The measured line, shortened. Two sentences, and the question keeps
|
||||
// its capital.
|
||||
name: "a statement keeps its full stop",
|
||||
reply: "Вайфай пароль лежит в ящике стола.",
|
||||
want: "Вайфай пароль лежит в ящике стола. " + resumed,
|
||||
},
|
||||
{
|
||||
name: "a reply with no terminator is given one",
|
||||
reply: "Вайфай пароль лежит в ящике стола",
|
||||
want: "Вайфай пароль лежит в ящике стола. " + resumed,
|
||||
},
|
||||
{
|
||||
// She sometimes answers a side query by asking him to say it again.
|
||||
// Two questions, and neither may swallow the other.
|
||||
name: "a question keeps its mark",
|
||||
reply: "Можешь переформулировать?",
|
||||
want: "Можешь переформулировать? " + resumed,
|
||||
},
|
||||
{
|
||||
name: "an ellipsis is already an ending",
|
||||
reply: "Не уверена…",
|
||||
want: "Не уверена… " + resumed,
|
||||
},
|
||||
{
|
||||
name: "a resume with no answer in front of it is just the question",
|
||||
reply: "",
|
||||
want: resumed,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := withResumed(tc.reply, resumed); got != tc.want {
|
||||
t.Errorf("%s: withResumed(%q) = %q, want %q", tc.name, tc.reply, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := withResumed("Готово.", ""); got != "Готово." {
|
||||
t.Errorf("nothing to resume must leave the reply alone, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||
"Что именно отметить?",
|
||||
"Назови, что записать — например, «выпил воды».",
|
||||
},
|
||||
// Not spoken since Vikunja #556: askClarify answers actNotRecognized for a
|
||||
// missing capability rather than asking. Kept because clarifyQuestion still
|
||||
// reports the gap, and a re-ask deck with a hole in it is harder to read.
|
||||
dialogue.SlotFn: {
|
||||
"Что сделать?",
|
||||
"Какое действие выполнить?",
|
||||
@@ -47,6 +50,49 @@ var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||
},
|
||||
}
|
||||
|
||||
// clarifyResumedVariants — the wording for a question coming BACK after a side
|
||||
// query took the turn away from it (Vikunja #561).
|
||||
//
|
||||
// It is not the first question again. "Когда?" works in the same breath as
|
||||
// "напомни позвонить маме", because the thing it is about was just said. After
|
||||
// a turn about the weather in Rome it does not: he has been thinking about
|
||||
// something else, and a bare "Когда?" asks him to remember what she is holding.
|
||||
// So the resumed form names the request — "напоминание", "заметка" — and the
|
||||
// first form stays short.
|
||||
//
|
||||
// One wording per slot, not a rotation and not an attempt ladder. A resume does
|
||||
// not spend an attempt (that is the point of suspending rather than re-asking),
|
||||
// so there is no attempt number to vary on, and this line is heard once per
|
||||
// interruption rather than repeatedly.
|
||||
//
|
||||
// Persona holds: infinitive, so no gender agreement, "ты" nowhere needed, no pet
|
||||
// names.
|
||||
var clarifyResumedVariants = map[dialogue.Slot]string{
|
||||
dialogue.SlotTime: "На какое время поставить напоминание?",
|
||||
dialogue.SlotText: "Так о чём напомнить?",
|
||||
dialogue.SlotKey: "Так что записать?",
|
||||
dialogue.SlotFn: "Так какое действие выполнить?",
|
||||
}
|
||||
|
||||
// clarifyResumedFor gives the resumed wording for a slot. ("", false) when the
|
||||
// slot has none, and the caller then resumes nothing rather than inventing a
|
||||
// question — a flow it cannot re-ask is one it must not claim to be holding.
|
||||
func clarifyResumedFor(slot dialogue.Slot) (string, bool) {
|
||||
q, ok := clarifyResumedVariants[slot]
|
||||
return q, ok
|
||||
}
|
||||
|
||||
// actNotRecognized is what an act she cannot run gets (Vikunja #556).
|
||||
//
|
||||
// The deck used to ask "Что сделать?" instead. That question has no answer he
|
||||
// can give: he already said what he wanted, and nothing he repeats will match a
|
||||
// capability that is not there. So she asked, failed, asked again and gave up —
|
||||
// three turns spent on one refusal. She says it once now, and parks nothing.
|
||||
//
|
||||
// It does not recite the allowlist. A list of names he did not ask about is not
|
||||
// an answer to the thing he did ask about.
|
||||
const actNotRecognized = "Такую команду я не знаю."
|
||||
|
||||
// clarifyQuestionFor picks the wording for this attempt. attempt is 1-based, as
|
||||
// PendingQuestion.Attempts counts it; anything past the list uses the last and
|
||||
// most explicit phrasing rather than wrapping round to the short one, because
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// commandProhibitionReply is deliberately operation-neutral. At this boundary
|
||||
// Maven may know only that the user denied authority, not whether the model
|
||||
// would have called it a reminder, board transition, local tool or Hexis act.
|
||||
const commandProhibitionReply = "хорошо, не буду."
|
||||
|
||||
// resolveCommandProhibition is the first mutation boundary in a turn. It runs
|
||||
// before a parked clarify answer or candidate selection can consume the words,
|
||||
// and before any route/model is consulted. A direct prohibition is complete in
|
||||
// itself: it needs no target lookup and makes no external call.
|
||||
//
|
||||
// A parked clarify request is unrelated state. Preserve it and say the pending
|
||||
// question again, using the same bounded suspend policy as every other side
|
||||
// request. Candidate lists likewise remain untouched; no ordinal was selected.
|
||||
func (h *reactiveHandler) resolveCommandProhibition(ctx context.Context, text string) (string, bool) {
|
||||
if !router.IsCommandProhibition(text) {
|
||||
return "", false
|
||||
}
|
||||
// A later bare "да" must not revive authority the user has just revoked.
|
||||
// Confirmation slots are all mutation authority and are process-local, so
|
||||
// clearing the three under their shared mutex is both conservative and
|
||||
// atomic. Clarify questions and candidate lists are not authority and stay.
|
||||
h.mu.Lock()
|
||||
h.pending = nil
|
||||
h.pendingHexis = nil
|
||||
h.pendingRoutine = nil
|
||||
h.mu.Unlock()
|
||||
if h.clarifyStore != nil {
|
||||
if q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now()); q != nil {
|
||||
h.noteSuspended(ctx, q)
|
||||
}
|
||||
}
|
||||
return commandProhibitionReply, true
|
||||
}
|
||||
|
||||
// refusesCommand is the defense-in-depth form for execution entry points which
|
||||
// can also be called with a reconstructed or test decision outside runTurn.
|
||||
// The sentinel cannot be renamed into an enabled function, and the original
|
||||
// utterance remains the authority even when a model rewrites Slots.Text.
|
||||
func refusesCommand(dec router.Decision) bool {
|
||||
return dec.Slots.Fn == router.ProhibitedActFn || router.IsCommandProhibition(dec.Utterance)
|
||||
}
|
||||
+125
-34
@@ -3,9 +3,14 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -19,6 +24,14 @@ type pendingHexisExec struct {
|
||||
entityID string
|
||||
displayName string
|
||||
expiry time.Time
|
||||
|
||||
// correlationID — the id the proposing turn minted for this action. A
|
||||
// confirm arrives on a later turn with a context of its own, so without
|
||||
// carrying it here the execution recorded a fresh id and no causation at
|
||||
// all, and the resolve, the discovery and the thing they authorised sat in
|
||||
// the trace as unrelated calls. The contract mints one id per action, and
|
||||
// the action began when she asked.
|
||||
correlationID string
|
||||
}
|
||||
|
||||
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
|
||||
@@ -56,10 +69,18 @@ func (h *reactiveHandler) park(fn string, args []string, phrase string) {
|
||||
// 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.
|
||||
// parked act expired), so the caller routes the utterance normally.
|
||||
//
|
||||
// An utterance that is not clearly yes or no is not an answer at all, so it is
|
||||
// handed straight back and the pending stays parked until it expires (V-567).
|
||||
// This resolver runs before routing and holds the most dangerous trigger on the
|
||||
// box; it may only claim a turn it is certain about.
|
||||
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
|
||||
verdict := classifyConfirm(text)
|
||||
if verdict == confirmUnknown {
|
||||
return "", false
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
@@ -67,16 +88,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri
|
||||
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) {
|
||||
// The slot is already cleared by claim().
|
||||
switch verdict {
|
||||
case confirmYes:
|
||||
return r.yes(), true
|
||||
case confirmNo:
|
||||
return r.no(), true
|
||||
default:
|
||||
return "", false
|
||||
return r.no(), true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
@@ -118,13 +135,13 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
//
|
||||
// Acceptance itself is recorded by /routines, and the tick
|
||||
// loop nudges on the interval from there (Vikunja #366).
|
||||
return "поняла — подтверди на странице рутин, и начну напоминать."
|
||||
return phraser.C(phraser.ConfirmRoutineAuthed, nil)
|
||||
},
|
||||
no: func() string {
|
||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||||
log.Printf("voice: dismiss proposed routine: %v", err)
|
||||
}
|
||||
return "хорошо, не буду."
|
||||
return phraser.C(phraser.ConfirmRoutineNo, nil)
|
||||
},
|
||||
},
|
||||
// Hexis execution confirm. Bound to the exact capability + target that
|
||||
@@ -135,9 +152,13 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
return hx != nil && !h.now().After(hx.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
execCtx := ctx
|
||||
if hx.correlationID != "" {
|
||||
execCtx = withCorrelationID(execCtx, hx.correlationID)
|
||||
}
|
||||
return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
},
|
||||
no: func() string { return "отменила." },
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
// Tool confirm.
|
||||
{
|
||||
@@ -150,16 +171,16 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
if err != nil {
|
||||
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
||||
if out != "" {
|
||||
return "не получилось выполнить команду: " + firstLine(out)
|
||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
return "не получилось выполнить команду."
|
||||
return phraser.A(phraser.ActFail, nil)
|
||||
}
|
||||
if out != "" {
|
||||
return "готово: " + firstLine(out)
|
||||
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
return "готово."
|
||||
return phraser.A(phraser.ActDone, nil)
|
||||
},
|
||||
no: func() string { return "отменила." },
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -170,17 +191,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
|
||||
name := firstWord(stripWake(dec.Utterance))
|
||||
if name == "" {
|
||||
return "не разобрала команду — попробуй иначе."
|
||||
return phraser.C(phraser.ProposeNoVerb, nil)
|
||||
}
|
||||
vars := map[string]string{"name": name}
|
||||
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 + "» нет в списке разрешённых."
|
||||
return phraser.C(phraser.ProposeFailed, vars)
|
||||
}
|
||||
if newly {
|
||||
return "команды «" + name + "» нет в списке. Предложила её добавить — включи через клиент."
|
||||
return phraser.C(phraser.ProposeNew, vars)
|
||||
}
|
||||
return "команды «" + name + "» пока нет в списке — она уже предложена, включи через клиент."
|
||||
return phraser.C(phraser.ProposeAlready, vars)
|
||||
}
|
||||
|
||||
// confirmVerdict — the parse of a y/n confirm answer.
|
||||
@@ -192,23 +214,92 @@ const (
|
||||
confirmNo
|
||||
)
|
||||
|
||||
// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the
|
||||
// stems so inflections/fillers ("да, давай", "нет, отмени") still land.
|
||||
// confirmWords are the two closed sets, tokenized once and ordered
|
||||
// longest-first so "не надо" is read before "нет" could claim any of it.
|
||||
var (
|
||||
confirmYesPhrases = confirmPhrases(lexicon.ConfirmYes())
|
||||
confirmNoPhrases = confirmPhrases(lexicon.ConfirmNo())
|
||||
)
|
||||
|
||||
// confirmPhrases splits each lexicon member into tokens and sorts the result
|
||||
// longest-first, so a walk that tries them in order matches the longest member
|
||||
// that fits.
|
||||
func confirmPhrases(words []string) [][]string {
|
||||
out := make([][]string, 0, len(words))
|
||||
for _, w := range words {
|
||||
if toks := confirmTokens(w); len(toks) > 0 {
|
||||
out = append(out, toks)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
|
||||
return out
|
||||
}
|
||||
|
||||
// confirmTokens splits an utterance into lowercase word tokens. Punctuation and
|
||||
// spacing are separators; an apostrophe is not, because "don't" is one word.
|
||||
func confirmTokens(text string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
if r == '\'' || r == '’' {
|
||||
return false
|
||||
}
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// classifyConfirm reads a short ru/en yes-or-no answer to a parked confirm.
|
||||
//
|
||||
// The whole utterance must consist of confirmation words and filler, matched as
|
||||
// whole tokens against the closed lexicon sets. Anything else is
|
||||
// confirmUnknown, which leaves the confirm parked and routes the turn — see
|
||||
// resolveConfirm. Both halves of that are the fix for V-567: this used to be a
|
||||
// substring test over bare stems, so "погода", "дальше", "надо" and "давление"
|
||||
// all read as "да", and "покажи" and "около" read as "ок". A parked destructive
|
||||
// act fired on a question about the weather.
|
||||
//
|
||||
// Requiring the WHOLE utterance is the second half. A leading confirm word does
|
||||
// not make a sentence an answer: "давай посмотрим погоду" opens a request, and
|
||||
// the only safe reading of a sentence that carries its own subject is that he
|
||||
// moved on. Guessing wrong here executes something; guessing wrong the other way
|
||||
// asks again.
|
||||
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) {
|
||||
tokens := confirmTokens(text)
|
||||
if len(tokens) == 0 {
|
||||
return confirmUnknown
|
||||
}
|
||||
verdict := confirmUnknown
|
||||
for i := 0; i < len(tokens); {
|
||||
// Negatives first: "не надо" and "не хочу" open with a token that is
|
||||
// not itself an answer, and a yes hit must never shadow them.
|
||||
if n := matchConfirm(confirmNoPhrases, tokens[i:]); n > 0 {
|
||||
return confirmNo
|
||||
}
|
||||
if n := matchConfirm(confirmYesPhrases, tokens[i:]); n > 0 {
|
||||
verdict, i = confirmYes, i+n
|
||||
continue
|
||||
}
|
||||
if lexicon.IsFillerParticle(tokens[i]) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
// A word that is neither an answer nor filler carries a subject of its
|
||||
// own, so this utterance is not an answer to her question.
|
||||
return confirmUnknown
|
||||
}
|
||||
for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} {
|
||||
if strings.Contains(t, yes) {
|
||||
return confirmYes
|
||||
return verdict
|
||||
}
|
||||
|
||||
// matchConfirm reports the length of the longest phrase matching at the head of
|
||||
// tokens, or 0.
|
||||
func matchConfirm(phrases [][]string, tokens []string) int {
|
||||
for _, p := range phrases {
|
||||
if len(p) > len(tokens) {
|
||||
continue
|
||||
}
|
||||
if slices.Equal(p, tokens[:len(p)]) {
|
||||
return len(p)
|
||||
}
|
||||
}
|
||||
return confirmUnknown
|
||||
return 0
|
||||
}
|
||||
|
||||
// actPhrase renders "fn arg1 arg2" for the confirm prompt.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// TestClassifyConfirmRejectsSubstrings — V-567. The old matcher tested bare
|
||||
// stems with strings.Contains, so every word below answered a question she had
|
||||
// asked about something else: "погода", "дальше", "надо" and "давление" carry
|
||||
// "да"; "покажи", "около" and "окно" carry "ок". A parked destructive act fired
|
||||
// on a question about the weather.
|
||||
func TestClassifyConfirmRejectsSubstrings(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"погода",
|
||||
"какая погода",
|
||||
"что дальше",
|
||||
"надо ещё",
|
||||
"покажи заметки",
|
||||
"около окна",
|
||||
"давление",
|
||||
"давай посмотрим погоду",
|
||||
"не забудь купить хлеб",
|
||||
"окно открыто",
|
||||
"стоит ли брать зонт",
|
||||
"",
|
||||
} {
|
||||
if got := classifyConfirm(text); got != confirmUnknown {
|
||||
t.Errorf("classifyConfirm(%q) = %v, want confirmUnknown", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyConfirmAcceptsAnswers keeps every genuine answer the substring
|
||||
// matcher accepted, and pins the pair the fix could most easily get wrong:
|
||||
// "надо" is not an answer and "не надо" is the opposite of one.
|
||||
func TestClassifyConfirmAcceptsAnswers(t *testing.T) {
|
||||
yes := []string{"да", "Да!", "ага", "давай", "да, давай", "конечно", "подтверждаю", "ну да", "yes", "yeah", "ok", "okay", "confirm"}
|
||||
no := []string{"нет", "Нет.", "не надо", "не нужно", "не сейчас", "отмена", "отмени", "стоп", "нет, отмени", "no", "nope", "cancel", "stop", "don't"}
|
||||
|
||||
for _, text := range yes {
|
||||
if got := classifyConfirm(text); got != confirmYes {
|
||||
t.Errorf("classifyConfirm(%q) = %v, want confirmYes", text, got)
|
||||
}
|
||||
}
|
||||
for _, text := range no {
|
||||
if got := classifyConfirm(text); got != confirmNo {
|
||||
t.Errorf("classifyConfirm(%q) = %v, want confirmNo", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnrelatedTurnLeavesConfirmParked — the whole point of V-567. An utterance
|
||||
// that is not an answer must not execute the parked act, must not consume the
|
||||
// turn, and must not disarm the confirm either: the answer he has not given yet
|
||||
// is still answerable until it expires.
|
||||
func TestUnrelatedTurnLeavesConfirmParked(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
dataStore: st,
|
||||
now: func() time.Time { return now },
|
||||
tools: tool.NewExecutor(api, time.Second),
|
||||
}
|
||||
|
||||
marker := filepath.Join(t.TempDir(), "destructive-tool-ran")
|
||||
if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.park("delete_backups", nil, "delete_backups")
|
||||
|
||||
if reply, handled := h.resolveConfirm(ctx, "какая погода"); handled {
|
||||
t.Fatalf("the weather question was consumed as a confirm: %q", reply)
|
||||
}
|
||||
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||
t.Fatalf("the parked destructive command ran on an unrelated turn: %v", err)
|
||||
}
|
||||
if h.pending == nil {
|
||||
t.Fatal("the confirm was disarmed by a turn that did not answer it")
|
||||
}
|
||||
|
||||
// It is still answerable, and answering it still runs the act.
|
||||
reply, handled := h.resolveConfirm(ctx, "да")
|
||||
if !handled || !strings.Contains(reply, "готово") {
|
||||
t.Fatalf("the still-parked confirm did not resolve: handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if _, err := os.Stat(marker); err != nil {
|
||||
t.Fatalf("confirmed destructive command did not run: %v", err)
|
||||
}
|
||||
if h.pending != nil {
|
||||
t.Fatal("the confirm stayed parked after being answered")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -149,12 +150,13 @@ func TestRememberTurnRefreshesTheTopic(t *testing.T) {
|
||||
now: func() time.Time { return contNow },
|
||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||||
}
|
||||
h.rememberTurn(nil, router.Decision{
|
||||
ctx := context.Background()
|
||||
h.rememberTurn(ctx, nil, router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
||||
}, contNow)
|
||||
// The second turn arrives with the first turn's Text already merged in.
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, contNow)
|
||||
h.rememberTurn(prev, router.Decision{
|
||||
h.rememberTurn(ctx, prev, router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "какие у меня планы",
|
||||
Slots: router.Slots{Text: "во сколько у меня встреча"},
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestRememberTurnKeepsIntentIndependentTranscriptInSpeakingOrder(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 1, 0, 0, 0, time.UTC)
|
||||
h := &reactiveHandler{
|
||||
now: func() time.Time { return now },
|
||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||
}
|
||||
ctx := context.Background()
|
||||
turns := []router.Decision{
|
||||
{Intent: router.IntentFact, Utterance: "я купил новый монитор", Slots: router.Slots{Key: "purchase", Value: "новый монитор", HasKey: true}},
|
||||
{Intent: router.IntentQuery, Utterance: "а он большой?", Slots: router.Slots{Text: "normalized query"}},
|
||||
{Intent: router.IntentChat, Utterance: "кажется, я переплатил", Slots: router.Slots{Text: "normalized chat"}},
|
||||
{Intent: router.IntentQuery, Utterance: "стоит его вернуть?", Slots: router.Slots{Text: "normalized return query"}},
|
||||
}
|
||||
for i, dec := range turns {
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
||||
h.rememberTurn(ctx, prev, dec, now.Add(time.Duration(i)*time.Second))
|
||||
}
|
||||
|
||||
got := h.dialogueSessions.Get(voiceDialogueID, now.Add(4*time.Second))
|
||||
if got == nil {
|
||||
t.Fatal("no dialogue session")
|
||||
}
|
||||
if got.Utterance != turns[3].Utterance {
|
||||
t.Fatalf("current utterance = %q, want %q", got.Utterance, turns[3].Utterance)
|
||||
}
|
||||
want := []string{turns[0].Utterance, turns[1].Utterance, turns[2].Utterance}
|
||||
if len(got.History) != len(want) {
|
||||
t.Fatalf("history = %+v, want %d prior turns", got.History, len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got.History[i].Text != want[i] {
|
||||
t.Errorf("history[%d] = %q, want %q", i, got.History[i].Text, want[i])
|
||||
}
|
||||
}
|
||||
|
||||
// actionChat runs after rememberTurn. It must receive only prior turns;
|
||||
// handing over the current turn here would duplicate the model's user input.
|
||||
history := h.chatHistory(ctx)
|
||||
if len(history) != len(want) {
|
||||
t.Fatalf("chat history = %+v, want exactly the prior turns", history)
|
||||
}
|
||||
for _, turn := range history {
|
||||
if turn.Text == got.Utterance {
|
||||
t.Fatalf("current utterance was duplicated into chat history: %+v", history)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsTurnReadsLegacySlotText(t *testing.T) {
|
||||
legacy := &dialogue.Session{
|
||||
Intent: dialogue.IntentQuery,
|
||||
Slots: dialogue.Slots{Text: "старый сохранённый вопрос"},
|
||||
}
|
||||
if got := sessionAsTurn(legacy).Text; got != legacy.Slots.Text {
|
||||
t.Fatalf("legacy turn text = %q, want %q", got, legacy.Slots.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitConversationOpenerKeepsCrossIntentSessionAlive(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 1, 0, 0, 0, time.UTC)
|
||||
h := &reactiveHandler{
|
||||
now: func() time.Time { return now },
|
||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||||
}
|
||||
ctx := context.Background()
|
||||
h.rememberTurn(ctx, nil, router.Decision{
|
||||
Intent: router.IntentFact, Utterance: "давай поболтаем: я купил новый монитор",
|
||||
Slots: router.Slots{Key: "purchase", Value: "новый монитор", HasKey: true},
|
||||
}, now)
|
||||
|
||||
later := now.Add(10 * time.Minute)
|
||||
prev := h.dialogueSessions.Get(voiceDialogueID, later)
|
||||
if prev == nil {
|
||||
t.Fatal("explicit conversation expired at the ordinary two-minute TTL")
|
||||
}
|
||||
if !prev.Conversational || prev.TTL != 15*time.Minute {
|
||||
t.Fatalf("conversation state = %+v, want conversational 15m session", prev)
|
||||
}
|
||||
got := followUpMerge(prev, router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "а он большой?",
|
||||
}, later)
|
||||
if got.Intent != router.IntentChat {
|
||||
t.Fatalf("anaphoric follow-up intent = %s, want chat", got.Intent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationOpenerDoesNotMatchAnotherDavaiCommand(t *testing.T) {
|
||||
if opensConversation("давай запишем новый монитор") {
|
||||
t.Fatal("an ordinary cooperative command opened a conversation")
|
||||
}
|
||||
for _, text := range []string{
|
||||
"давай поговорим: я купил монитор",
|
||||
"давайте пообщаемся",
|
||||
"let's talk: I bought a monitor",
|
||||
} {
|
||||
if !opensConversation(text) {
|
||||
t.Errorf("%q did not open a conversation", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,13 @@ func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, erro
|
||||
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
|
||||
case errors.Is(err, webfetch.ErrStatus):
|
||||
// Carry the code across the seam. The crawler needs to tell a 5xx
|
||||
// from a 404 to decide what a failed robots.txt means, and it must
|
||||
// not learn that by reading this sentence.
|
||||
var se *webfetch.StatusError
|
||||
if errors.As(err, &se) {
|
||||
return nil, &crawl.StatusError{Code: se.Code}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -111,6 +113,88 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" rebuilds the plan off the wire and renders it here, and the
|
||||
// instants on it carry the zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store. Read raw, the recital named
|
||||
// the store's clock instead of his (V-614). The asking clock is three hours off
|
||||
// whatever this machine runs in, so the assertion holds under TZ=UTC too.
|
||||
func TestQueryDayPlanRestOfDayReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: &planAPI{plan: ipc.DayPlan{
|
||||
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
Items: []ipc.DayPlanItem{{At: stored, Text: "позвонить маме", Kind: "reminder"}},
|
||||
}},
|
||||
now: func() time.Time { return time.Date(2026, 8, 3, 9, 0, 0, 0, away) },
|
||||
}
|
||||
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 want := stored.In(away).Format("15:04"); !strings.Contains(reply, want) {
|
||||
t.Errorf("the reminder is not read in his clock (%s): %q", want, reply)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(reply, bad) {
|
||||
t.Errorf("the reminder is read in the store's zone (%s): %q", bad, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes
|
||||
// nothing, because the whole day is still ahead. She read 43 entries aloud as
|
||||
// one sentence. The zone is three hours off UTC so the test also fails under
|
||||
// TZ=UTC if the rendering ever slips zones.
|
||||
func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) {
|
||||
zone := time.FixedZone("MSK", 3*60*60)
|
||||
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone)
|
||||
plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"}
|
||||
for i := 0; i < 43; i++ {
|
||||
plan.Items = append(plan.Items, ipc.DayPlanItem{
|
||||
At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward
|
||||
Text: fmt.Sprintf("пункт %d", i),
|
||||
Kind: "event",
|
||||
})
|
||||
}
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
|
||||
}}
|
||||
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 n := strings.Count(reply, "пункт "); n != morning.NextSpoken {
|
||||
t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") {
|
||||
t.Errorf("the next thing is not first: %q", reply)
|
||||
}
|
||||
// The rest is counted, not silently dropped.
|
||||
if !strings.Contains(reply, "и ещё 40 дел до конца дня.") {
|
||||
t.Errorf("the sentence hides that the day goes on: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "что у меня сегодня?" is the whole day and is not narrowed. It carries no
|
||||
// plan word, so the plan source declines it and the calendar listing answers —
|
||||
// asserted here beside the cap so the two questions cannot drift together.
|
||||
func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) {
|
||||
if router.IsDayPlanQuery("что у меня сегодня?") {
|
||||
t.Error("the plan source claims the whole-day question")
|
||||
}
|
||||
if !router.IsDayPlanQuery("что дальше?") {
|
||||
t.Error("the plan source stopped claiming the rest-of-day question")
|
||||
}
|
||||
if router.IsRestOfDayQuery("какие планы на сегодня?") {
|
||||
t.Error("the whole-day plan question got narrowed to the rest of the day")
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// mavend/decisiontrace.go — the daemon's half of the per-turn decision record.
|
||||
//
|
||||
// V-564. The router says what the cascade did (internal/router/decisiontrace.go);
|
||||
// this file covers the two claimant sets that live in the daemon: the stateful
|
||||
// resolvers that run BEFORE routing and pre-empt it unconditionally, and the
|
||||
// query source chain that runs after. Those two are where the arbitration is
|
||||
// least visible, because both are a hardcoded order of functions that each
|
||||
// answer "is this mine?" alone and none of which answers "is this more mine
|
||||
// than yours?" (V-558).
|
||||
//
|
||||
// Recording changes no route. Every helper here is a no-op on a context with no
|
||||
// record, which is what every test that does not ask for one gets.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// preRouteLadder — the resolvers runTurn offers the utterance to before the
|
||||
// router sees it, in the order they get their say. Kept here as a roster rather
|
||||
// than derived from the code, so a resolver that returns early and skips the
|
||||
// rest still leaves the rest NAMED in the record: a claimant that never looked
|
||||
// and one that looked and passed are the distinction the ordering hides, and
|
||||
// they are the difference between a bug in the ladder and a bug in a resolver.
|
||||
//
|
||||
// Adding a step to runTurn means adding its name here. Nothing enforces that,
|
||||
// and nothing should: a missing name costs one line of the record, while a
|
||||
// check that walks the ladder would have to run the ladder.
|
||||
var preRouteLadder = []string{
|
||||
"confirm", "repair", "repair-negative", "command-prohibition", "clarify-answer", "quiet-toggle",
|
||||
"snooze", "ack", "reminder-cancel", "ordinal",
|
||||
}
|
||||
|
||||
// notePreRoute records one rung of that ladder and passes its verdict through
|
||||
// unchanged, so the call site stays the single `if handled` it already was.
|
||||
func notePreRoute(ctx context.Context, name string, handled bool) bool {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return handled
|
||||
}
|
||||
if handled {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Won,
|
||||
Reason: "it pre-empted routing, so the router never saw this turn",
|
||||
})
|
||||
return handled
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Declined,
|
||||
Reason: "nothing of its own was pending",
|
||||
})
|
||||
return handled
|
||||
}
|
||||
|
||||
// noteTerminal records whoever actually produced the reply, but only if the
|
||||
// turn is still unclaimed. A route decides the intent; it does not answer, and
|
||||
// on a thinned route or a plain act nothing downstream keeps a scoreboard. So
|
||||
// the record would otherwise close with an empty winner, which reads as a lost
|
||||
// turn instead of an asked question.
|
||||
func noteTerminal(ctx context.Context, claimant string, intent router.Intent, reason string) {
|
||||
decision.From(ctx).NoteIfUnclaimed(decision.Claim{
|
||||
Stage: decision.StageAction, Claimant: claimant,
|
||||
Intent: string(intent), Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// noteMerge records the follow-up merge, which is the one claimant that edits
|
||||
// the winning decision instead of taking the turn from it. It is compared on
|
||||
// the four slots the merge can fill, because a Decision holds a slice and is
|
||||
// not comparable.
|
||||
func noteMerge(ctx context.Context, before, after router.Decision) {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
changed := before.Slots.HasTime != after.Slots.HasTime ||
|
||||
before.Slots.HasKey != after.Slots.HasKey ||
|
||||
before.Slots.HasFn != after.Slots.HasFn ||
|
||||
before.Slots.Text != after.Slots.Text ||
|
||||
before.Intent != after.Intent
|
||||
if !changed {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageMerge, Claimant: "follow-up-merge", Outcome: decision.Declined,
|
||||
Reason: "no slot of this turn was left for a previous one to fill",
|
||||
})
|
||||
return
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageMerge, Claimant: "follow-up-merge", Intent: string(after.Intent),
|
||||
Outcome: decision.Merged, Reason: "filled this turn's gaps from the previous turn",
|
||||
})
|
||||
}
|
||||
|
||||
// turnDecisionsFn — the reader mavweb gets, or nil when voice was never wired.
|
||||
// Same shape as intakeEventsFn: the daemon holds the ring, the IPC layer only
|
||||
// converts it.
|
||||
func turnDecisionsFn(w *voiceWiring) func(int) []ipc.TurnDecision {
|
||||
if w == nil || w.handler == nil || w.handler.decisions == nil {
|
||||
return nil
|
||||
}
|
||||
ring := w.handler.decisions
|
||||
return func(n int) []ipc.TurnDecision {
|
||||
recs := ring.Recent(n)
|
||||
out := make([]ipc.TurnDecision, 0, len(recs))
|
||||
for _, rec := range recs {
|
||||
claims := make([]ipc.TurnClaim, 0, len(rec.Claims))
|
||||
for _, c := range rec.Claims {
|
||||
claims = append(claims, ipc.TurnClaim{
|
||||
Stage: c.Stage, Claimant: c.Claimant, Intent: c.Intent,
|
||||
Score: c.Score, HasScore: c.HasScore,
|
||||
Outcome: c.Outcome, Reason: c.Reason,
|
||||
})
|
||||
}
|
||||
out = append(out, ipc.TurnDecision{
|
||||
Ts: rec.Ts, Utterance: rec.Utterance, Winner: rec.Winner, Claims: claims,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// querySourceNames — the query chain's roster, in chain order.
|
||||
func querySourceNames() []string {
|
||||
names := make([]string, len(querySources))
|
||||
for i, src := range querySources {
|
||||
names[i] = src.name
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
// traceHandler — a handler with the decision ring wired, the same shape the
|
||||
// daemon builds in wireVoice.
|
||||
func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Now()
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
return &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
decisions: ring,
|
||||
}
|
||||
}
|
||||
|
||||
// findClaim — the first claim for a claimant, or nil.
|
||||
func findClaim(rec *decision.Record, claimant string) *decision.Claim {
|
||||
for i := range rec.Claims {
|
||||
if rec.Claims[i].Claimant == claimant {
|
||||
return &rec.Claims[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestTurnRecordNamesWinnerAndLosers — the point of V-564. A turn a stage-0
|
||||
// grammar claims must leave a record naming that grammar as the winner, naming
|
||||
// a pre-route resolver that declined, and naming the routing engines that were
|
||||
// never reached at all. The last of those is the fact the hardcoded ordering
|
||||
// hides: "classifier" absent from the record and "classifier" never asked read
|
||||
// the same to a human, and only one of them is the truth.
|
||||
func TestTurnRecordNamesWinnerAndLosers(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
|
||||
reply := h.handleText(context.Background(), "web", "сколько сейчас времени")
|
||||
if reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
|
||||
recs := ring.Recent(5)
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("want 1 record, got %d", len(recs))
|
||||
}
|
||||
rec := recs[0]
|
||||
if rec.Utterance != "сколько сейчас времени" {
|
||||
t.Errorf("utterance = %q", rec.Utterance)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Winner, "stage0:") {
|
||||
t.Errorf("want a stage-0 grammar as the winner, got %q", rec.Winner)
|
||||
}
|
||||
|
||||
// A loser that examined the turn: the confirm resolver ran first and had
|
||||
// nothing pending.
|
||||
confirm := findClaim(rec, "confirm")
|
||||
if confirm == nil || confirm.Outcome != decision.Declined {
|
||||
t.Errorf("confirm claim = %+v, want a decline", confirm)
|
||||
}
|
||||
|
||||
// A loser that never looked: stage 0 answered, so neither routing engine
|
||||
// was reached.
|
||||
for _, name := range []string{"llm-router", "classifier"} {
|
||||
c := findClaim(rec, name)
|
||||
if c != nil && c.Outcome == decision.Won {
|
||||
t.Errorf("%s cannot have won a stage-0 turn: %+v", name, c)
|
||||
}
|
||||
}
|
||||
|
||||
// And every rung of the ladder below the winner is named, not omitted.
|
||||
for _, name := range preRouteLadder {
|
||||
if findClaim(rec, name) == nil {
|
||||
t.Errorf("ladder rung %q is missing from the record", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordingDoesNotChangeTheReply — instrumentation, so a turn with the ring
|
||||
// wired and the same turn without it must answer identically. If this ever
|
||||
// fails, a claim site is doing more than noting.
|
||||
func TestRecordingDoesNotChangeTheReply(t *testing.T) {
|
||||
for _, utt := range []string{
|
||||
"сколько сейчас времени",
|
||||
"запиши что я пил воду",
|
||||
"что у меня сегодня",
|
||||
} {
|
||||
withRing := traceHandler(t, decision.NewRing()).handleText(context.Background(), "web", utt)
|
||||
without := traceHandler(t, nil).handleText(context.Background(), "web", utt)
|
||||
if withRing != without {
|
||||
t.Errorf("%q: recorded reply %q != unrecorded %q", utt, withRing, without)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryChainRecordsWhoWasNeverAsked — a query source below the claimant is
|
||||
// never consulted, and the record must say so rather than leave it out. This is
|
||||
// the arm that would have explained the Rome misroute in one read.
|
||||
func TestQueryChainRecordsWhoWasNeverAsked(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.handleText(context.Background(), "web", "что у меня сегодня")
|
||||
|
||||
rec := ring.Recent(1)[0]
|
||||
var asked, never int
|
||||
for _, c := range rec.Claims {
|
||||
if c.Stage != decision.StageQuery {
|
||||
continue
|
||||
}
|
||||
if c.Outcome == decision.NeverAsked {
|
||||
never++
|
||||
} else {
|
||||
asked++
|
||||
}
|
||||
}
|
||||
if asked == 0 {
|
||||
t.Fatal("no query source reported at all")
|
||||
}
|
||||
if never == 0 {
|
||||
t.Fatal("no query source was recorded as never asked; the chain cannot have run to the end")
|
||||
}
|
||||
if got := len(querySourceNames()); asked+never != got {
|
||||
t.Errorf("record covers %d of %d query sources", asked+never, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnDecisionsFnConvertsTheRing — the IPC read path. Nil when voice was
|
||||
// never wired, because a box with no turns is an empty page and not an error.
|
||||
func TestTurnDecisionsFnConvertsTheRing(t *testing.T) {
|
||||
if fn := turnDecisionsFn(nil); fn != nil {
|
||||
t.Error("no wiring should mean no reader")
|
||||
}
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.handleText(context.Background(), "web", "сколько сейчас времени")
|
||||
|
||||
fn := turnDecisionsFn(&voiceWiring{handler: h})
|
||||
if fn == nil {
|
||||
t.Fatal("wired handler produced no reader")
|
||||
}
|
||||
out := fn(10)
|
||||
if len(out) != 1 || out[0].Winner == "" || len(out[0].Claims) == 0 {
|
||||
t.Fatalf("conversion lost the record: %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,799 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// Dialogue contract tests (V-563, child of V-558).
|
||||
//
|
||||
// Every other clarify test is single-shot: one ask, one answer, one assertion.
|
||||
// Three bugs of the same family shipped in two days that way — V-554 (a parked
|
||||
// question ate the three turns after it), V-557 (a confidently routed but
|
||||
// incomplete reminder parked nothing, so the answer was web-searched) and the
|
||||
// Rome case in V-558 (a side question was eaten as the time answer). None of
|
||||
// them is visible in one turn. The dialogue path is a state machine, so it can
|
||||
// be enumerated instead: whole traces, each with a per-turn expectation and an
|
||||
// expected END state — what was written to the store, and what is still parked.
|
||||
//
|
||||
// Two rules for the rows below.
|
||||
//
|
||||
// Where today's behaviour is correct, it is asserted. Where it is WRONG, the row
|
||||
// carries the CORRECT expectation and is skipped with the Vikunja id that will
|
||||
// unskip it. A weakened expectation would be worse than no row: it would pin the
|
||||
// bug as the contract.
|
||||
//
|
||||
// Everything runs on the offline floor — hash embedder, no llama-server, no
|
||||
// ONNX, StubDateTimeParser. That has one consequence worth knowing before
|
||||
// reading a fire time here: the stub reads "в 11:00" and "через час" and does
|
||||
// not read "на 9" or "на завтра", so a trace that needs those is noted where it
|
||||
// sits.
|
||||
|
||||
// claim — which claimant consumed an utterance. Not asserted: it is derived from
|
||||
// the log lines the daemon already emits and printed on every failure, because
|
||||
// "the reply differed" does not distinguish a wrong claimant from wrong copy,
|
||||
// and that distinction is the whole point of V-558.
|
||||
type claim struct {
|
||||
utterance string
|
||||
steps []string
|
||||
}
|
||||
|
||||
func (c claim) String() string { return c.utterance + " ⇒ " + strings.Join(c.steps, " → ") }
|
||||
|
||||
// claimMarkers — log fragment to claimant name, in the order runTurn checks
|
||||
// them. The fragments are the daemon's own words (clarify.go, repair.go,
|
||||
// voice.go); a rename there shows up here as an "unclaimed" step rather than a
|
||||
// silent mislabel.
|
||||
var claimMarkers = []struct{ fragment, name string }{
|
||||
{"parked question expired", "clarify:expired"},
|
||||
{"is its own request", "clarify:stepped-aside"},
|
||||
{"gave up on", "clarify:gave-up"},
|
||||
{"did not fill", "clarify:re-ask"},
|
||||
{"one gap filled", "clarify:ask-second-gap"},
|
||||
{"asked about", "clarify:ask"},
|
||||
{"repair —", "repair"},
|
||||
{"route result: intent=", "route"},
|
||||
}
|
||||
|
||||
// claimsOf reads the turn's log output and names the claimants that touched it.
|
||||
func claimsOf(utterance, logged string) claim {
|
||||
c := claim{utterance: utterance}
|
||||
for _, line := range strings.Split(logged, "\n") {
|
||||
for _, m := range claimMarkers {
|
||||
if strings.Contains(line, m.fragment) {
|
||||
name := m.name
|
||||
if m.name == "route" {
|
||||
name = "route:" + intentInLine(line)
|
||||
}
|
||||
c.steps = append(c.steps, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(c.steps) == 0 {
|
||||
c.steps = []string{"unclaimed"}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func intentInLine(line string) string {
|
||||
_, rest, ok := strings.Cut(line, "intent=")
|
||||
if !ok {
|
||||
return "?"
|
||||
}
|
||||
intent, _, _ := strings.Cut(rest, " ")
|
||||
return intent
|
||||
}
|
||||
|
||||
// parkedWant — the question that must be armed after a turn. Attempt matters:
|
||||
// a claimant that spends a retry on an utterance that was never an answer is
|
||||
// exactly the V-554 shape, and the count is the only place it shows.
|
||||
type parkedWant struct {
|
||||
slot dialogue.Slot
|
||||
attempt int
|
||||
// carries — a substring the parked utterance must still hold, so a re-park
|
||||
// that lost the answered subject fails here rather than three turns later.
|
||||
carries string
|
||||
}
|
||||
|
||||
// turn — one utterance and everything that must be true right after it.
|
||||
type turn struct {
|
||||
say string
|
||||
// wait — the clock moves this far BEFORE the utterance. The only way to
|
||||
// reach the TTL without sleeping.
|
||||
wait time.Duration
|
||||
// question — the reply must be exactly this clarify question, worded for
|
||||
// this attempt. Zero slot ⇒ not checked.
|
||||
question dialogue.Slot
|
||||
attempt int
|
||||
// gap — which part of the time she is asking about, for a SlotTime question
|
||||
// (V-579). Zero value is the missing hour, which is what she asks first.
|
||||
gap whenGap
|
||||
// took — the words of the PREVIOUS turn that this ask must acknowledge
|
||||
// before asking again (V-593). Empty ⇒ the ask carries no acknowledgement,
|
||||
// which is right for a first ask and for an answer that moved nothing.
|
||||
took string
|
||||
// differs — this reply must not be byte-identical to the one before it. Set
|
||||
// on a re-ask whose turn moved the request forward (V-593).
|
||||
differs bool
|
||||
contains []string
|
||||
notContain []string
|
||||
// noQuestion — the reply must not be any clarify question. Used where the
|
||||
// correct behaviour is known but her wording for it is not written yet: a
|
||||
// cancel must not be answered with another question, whatever it does say.
|
||||
noQuestion bool
|
||||
expired bool // the reply must open with the TTL notice
|
||||
// parked — what is armed after the turn. nil ⇒ nothing may be armed.
|
||||
parked *parkedWant
|
||||
}
|
||||
|
||||
// endState — what the store holds once the trace is over. Counts and
|
||||
// substrings, not rows: a trace is about who claimed what, and a payload
|
||||
// substring is enough to catch a request landing under the wrong words.
|
||||
type endState struct {
|
||||
reminders []reminderWant
|
||||
factKeys []string
|
||||
notes int
|
||||
tasks []string
|
||||
}
|
||||
|
||||
type reminderWant struct {
|
||||
payload string // substring of the stored payload
|
||||
fireAt string // "2006-01-02 15:04" in UTC, "" ⇒ not checked
|
||||
}
|
||||
|
||||
// trace — a named conversation, its turns, and the end state.
|
||||
type trace struct {
|
||||
name string
|
||||
skip string // non-empty ⇒ t.Skip: today's behaviour is wrong, this names the fix
|
||||
turns []turn
|
||||
end endState
|
||||
}
|
||||
|
||||
// newDialogueHandler — the offline floor with the real cascade and a movable
|
||||
// clock: newClarifyHandler's wiring (stub date parser, real fact parser, tool
|
||||
// matcher) plus the router newRoutingClarifyHandler builds, and the `now`
|
||||
// pointer so a turn can carry a wait.
|
||||
func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
|
||||
t.Helper()
|
||||
h, st, now := newClarifyHandler(t)
|
||||
// A minute no trace ever says, so "fires at the current clock" is a defect
|
||||
// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
|
||||
// landing on it, and at 09:00 the row that answers "на 9" would trip that.
|
||||
*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
|
||||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
|
||||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||||
return h, st, now
|
||||
}
|
||||
|
||||
// wantedQuestion builds the question a turn must be answered with, from the
|
||||
// same code the daemon asks through. A time question is built from the gap,
|
||||
// because she names the clock and asks about the part he left out (V-579).
|
||||
func wantedQuestion(tn turn, now time.Time) (string, bool) {
|
||||
if tn.question == dialogue.SlotTime {
|
||||
gap := tn.gap
|
||||
if gap == whenComplete {
|
||||
gap = whenNoHour
|
||||
}
|
||||
return whenQuestion(gap, tn.attempt, now, whenTakenLine(tn.took))
|
||||
}
|
||||
return clarifyQuestionFor(tn.question, tn.attempt)
|
||||
}
|
||||
|
||||
// runTrace drives one trace through handleText and checks every turn, then the
|
||||
// end state. Every failure carries the decision trace so far, so a wrong
|
||||
// claimant reads differently from wrong copy.
|
||||
func runTrace(t *testing.T, tr trace) {
|
||||
t.Helper()
|
||||
// MAVEN_DIALOGUE_NO_SKIP=1 runs the rows that fail today. That is how
|
||||
// whoever lands V-560, V-561 or V-562 sees their row go green before
|
||||
// deleting its skip, and it is also the check that a skip is still earned:
|
||||
// a row that passes with the skip in place is a fix nobody noticed.
|
||||
if tr.skip != "" && os.Getenv("MAVEN_DIALOGUE_NO_SKIP") == "" {
|
||||
t.Skip(tr.skip)
|
||||
}
|
||||
ctx := context.Background()
|
||||
h, st, now := newDialogueHandler(t)
|
||||
const conversation = "web"
|
||||
id := dialogueIDFor(sourceText, conversation)
|
||||
|
||||
var claims []claim
|
||||
var previous string
|
||||
fail := func(turnIdx int, format string, args ...any) {
|
||||
t.Helper()
|
||||
lines := make([]string, 0, len(claims))
|
||||
for _, c := range claims {
|
||||
lines = append(lines, " "+c.String())
|
||||
}
|
||||
t.Fatalf("turn %d: "+format+"\n who claimed what:\n%s",
|
||||
append([]any{turnIdx}, append(args, strings.Join(lines, "\n"))...)...)
|
||||
}
|
||||
|
||||
for i, tn := range tr.turns {
|
||||
if tn.wait > 0 {
|
||||
*now = now.Add(tn.wait)
|
||||
}
|
||||
var logged bytes.Buffer
|
||||
prev := log.Writer()
|
||||
log.SetOutput(&logged)
|
||||
reply := h.handleText(ctx, conversation, tn.say)
|
||||
log.SetOutput(prev)
|
||||
claims = append(claims, claimsOf(tn.say, logged.String()))
|
||||
|
||||
body := reply
|
||||
if tn.expired {
|
||||
if !isClarifyExpired(reply) {
|
||||
fail(i, "reply %q must open with the expiry notice", reply)
|
||||
}
|
||||
body = trimClarifyExpired(reply)
|
||||
// The notice is glued in front of this turn's reply, and both halves
|
||||
// have to survive: the words he just said are routed fresh, and
|
||||
// answering only "I let the old one go" drops them.
|
||||
if body == "" {
|
||||
fail(i, "the notice was the whole reply; the fresh words were never answered")
|
||||
}
|
||||
} else if isClarifyExpired(reply) {
|
||||
fail(i, "reply %q announced an expiry nothing asked for", reply)
|
||||
}
|
||||
if tn.question != "" {
|
||||
want, ok := wantedQuestion(tn, h.now())
|
||||
if !ok {
|
||||
fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt)
|
||||
}
|
||||
if body != want {
|
||||
fail(i, "reply %q, want the %s question worded for attempt %d, %q", body, tn.question, tn.attempt, want)
|
||||
}
|
||||
}
|
||||
if tn.noQuestion && isAnyClarifyQuestion(body) {
|
||||
fail(i, "reply %q is another question; this turn is not something to ask about", body)
|
||||
}
|
||||
for _, want := range tn.contains {
|
||||
if !strings.Contains(body, want) {
|
||||
fail(i, "reply %q does not carry %q", body, want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range tn.notContain {
|
||||
if strings.Contains(body, unwanted) {
|
||||
fail(i, "reply %q carries %q and must not", body, unwanted)
|
||||
}
|
||||
}
|
||||
if tn.differs && reply == previous {
|
||||
fail(i, "reply %q is byte-identical to the one before it, and his turn between them answered part of the gap", reply)
|
||||
}
|
||||
previous = reply
|
||||
checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked)
|
||||
}
|
||||
checkEnd(t, ctx, st, h, tr.end, claims)
|
||||
}
|
||||
|
||||
// isAnyClarifyQuestion — is this reply one of her clarify questions, at any
|
||||
// attempt wording? Reads the templates rather than a list of its own.
|
||||
func isAnyClarifyQuestion(reply string) bool {
|
||||
for _, variants := range clarifyQuestionVariants {
|
||||
for _, v := range variants {
|
||||
// HasSuffix, not equality: a question about the time opens with the
|
||||
// clock she is reasoning from (V-579).
|
||||
if strings.HasSuffix(reply, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// The two questions with no deck behind them, asked when the hour is said
|
||||
// and its half of the day or its day is not.
|
||||
return strings.HasSuffix(reply, "утра или вечера?") || strings.HasSuffix(reply, "В какой день?")
|
||||
}
|
||||
|
||||
func checkParked(t *testing.T, fail func(int, string, ...any), i int, got *dialogue.PendingQuestion, want *parkedWant) {
|
||||
t.Helper()
|
||||
if want == nil {
|
||||
if got != nil {
|
||||
fail(i, "a question about %v is still armed and nothing should be: %+v", got.Missing, got.Slots)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got == nil {
|
||||
fail(i, "nothing is armed, want a question about %s (attempt %d)", want.slot, want.attempt)
|
||||
return
|
||||
}
|
||||
if len(got.Missing) != 1 || got.Missing[0] != want.slot {
|
||||
fail(i, "armed question is about %v, want %s", got.Missing, want.slot)
|
||||
}
|
||||
if got.Attempts != want.attempt {
|
||||
fail(i, "armed question is on attempt %d, want %d — a retry spent on something that was never an answer is the V-554 shape", got.Attempts, want.attempt)
|
||||
}
|
||||
if want.carries != "" && !strings.Contains(got.Utterance, want.carries) {
|
||||
fail(i, "the parked request no longer carries %q: %q", want.carries, got.Utterance)
|
||||
}
|
||||
}
|
||||
|
||||
func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHandler, want endState, claims []claim) {
|
||||
t.Helper()
|
||||
lines := make([]string, 0, len(claims))
|
||||
for _, c := range claims {
|
||||
lines = append(lines, " "+c.String())
|
||||
}
|
||||
trace := "\n who claimed what:\n" + strings.Join(lines, "\n")
|
||||
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(14*24*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("DueReminders: %v", err)
|
||||
}
|
||||
if len(reminders) != len(want.reminders) {
|
||||
t.Fatalf("end state: %d reminder(s), want %d: %+v%s", len(reminders), len(want.reminders), reminders, trace)
|
||||
}
|
||||
// No trace may leave a reminder at the current clock, whatever else it
|
||||
// asserts (V-577, V-579). Twice on the box a sentence naming a day and no
|
||||
// hour was completed from time.Now(): "что у меня сегодня?" became 01:28 and
|
||||
// "на завтра" became 01:38. Neither minute was ever spoken, and a row that
|
||||
// only checked the payload would have passed both.
|
||||
for _, r := range reminders {
|
||||
if r.FireTs.In(h.now().Location()).Format("15:04") == h.now().Format("15:04") {
|
||||
t.Fatalf("end state: reminder %q fires at %s, which is the clock — a time slot naming no hour is asked about, never filled from now()%s",
|
||||
r.Payload, r.FireTs.Format("15:04"), trace)
|
||||
}
|
||||
}
|
||||
for i, w := range want.reminders {
|
||||
if !strings.Contains(reminders[i].Payload, w.payload) {
|
||||
t.Fatalf("end state: reminder %d payload %q does not carry %q%s", i, reminders[i].Payload, w.payload, trace)
|
||||
}
|
||||
if w.fireAt != "" {
|
||||
if got := reminders[i].FireTs.UTC().Format("2006-01-02 15:04"); got != w.fireAt {
|
||||
t.Fatalf("end state: reminder %d fires at %s, want %s%s", i, got, w.fireAt, trace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
facts, err := st.RecentFacts(ctx, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentFacts: %v", err)
|
||||
}
|
||||
if len(facts) != len(want.factKeys) {
|
||||
t.Fatalf("end state: %d fact(s), want %d: %+v%s", len(facts), len(want.factKeys), facts, trace)
|
||||
}
|
||||
for i, key := range want.factKeys {
|
||||
if facts[i].Key != key {
|
||||
t.Fatalf("end state: fact %d is %q, want %q%s", i, facts[i].Key, key, trace)
|
||||
}
|
||||
}
|
||||
|
||||
notes, err := st.RecentNotes(ctx, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != want.notes {
|
||||
t.Fatalf("end state: %d note(s), want %d%s", len(notes), want.notes, trace)
|
||||
}
|
||||
|
||||
tasks, err := st.ListTasks(ctx, store.TaskOpen)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTasks: %v", err)
|
||||
}
|
||||
if len(tasks) != len(want.tasks) {
|
||||
t.Fatalf("end state: %d open task(s), want %d: %+v%s", len(tasks), len(want.tasks), tasks, trace)
|
||||
}
|
||||
for i, text := range want.tasks {
|
||||
if !strings.Contains(tasks[i].Text, text) {
|
||||
t.Fatalf("end state: task %d is %q, want it to carry %q%s", i, tasks[i].Text, text, trace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogueTraces(t *testing.T) {
|
||||
for _, tr := range dialogueTraces() {
|
||||
tr := tr
|
||||
t.Run(tr.name, func(t *testing.T) { runTrace(t, tr) })
|
||||
}
|
||||
}
|
||||
|
||||
// dialogueTraces — the fixture. Order is the order the shapes were found, not a
|
||||
// dependency: each trace builds its own handler and store.
|
||||
func dialogueTraces() []trace {
|
||||
return []trace{
|
||||
// The plain two-turn shape, and the one every other row is a deviation
|
||||
// from: she asks for the time, he gives it, the reminder lands with the
|
||||
// subject he said in the FIRST turn.
|
||||
{
|
||||
// Three turns since V-579, not two. An hour with no day named is
|
||||
// not an answer she can act on: 11:00 today has passed as often as
|
||||
// not, and picking one for him is the invention the whole rule is
|
||||
// against. So she says the clock she is reasoning from and asks
|
||||
// which day.
|
||||
name: "reminder completed over three turns",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||
},
|
||||
// The same shape on the fact path, where the answer carries both halves
|
||||
// of what was missing — the key and the value — in one breath.
|
||||
{
|
||||
name: "fact completed over two turns",
|
||||
turns: []turn{
|
||||
{say: "запиши", question: dialogue.SlotKey, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}},
|
||||
// His words back, not the key the parser filed them under
|
||||
// (V-592). "water" is machine vocabulary and he never said it.
|
||||
{say: "пил воду", contains: []string{"пил воду"}},
|
||||
},
|
||||
end: endState{factKeys: []string{"water"}},
|
||||
},
|
||||
// An answer past the TTL is a new request, not an answer (V-385). She
|
||||
// says the old one is gone and routes the words fresh. A bare time on
|
||||
// its own carries no request, so the fresh routing lands on the canned
|
||||
// reply — the point of the row is that NOTHING is created: a reminder
|
||||
// here would fire with the subject of a request she had already let go.
|
||||
{
|
||||
name: "answer arrives after the TTL",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "в 11:00", wait: clarifyTTL + time.Second, expired: true},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// Three questions is the budget, and running out is SPOKEN: a mute
|
||||
// give-up reads as "done" and he would wait for a reminder that was
|
||||
// never set. The wording changes with the attempt (V-457).
|
||||
{
|
||||
name: "three unclear answers then the give-up line",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 2,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 3,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
{say: "ну не знаю", contains: []string{clarifyGaveUp}, noQuestion: true},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// A correction points at the previous ACTED turn (repair.go): she redoes
|
||||
// it under the intent he names and says so out loud, because a
|
||||
// correction he cannot see is indistinguishable from one that was
|
||||
// dropped. The task she filed first stays filed — repair redoes, it does
|
||||
// not retract, and V-455 decided that deliberately.
|
||||
//
|
||||
// The corrected-to intent has to differ from the one she used, or repair
|
||||
// declines: teaching the classifier the label it already produced is
|
||||
// worse than doing nothing.
|
||||
{
|
||||
name: "correction of the previous turn",
|
||||
turns: []turn{
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"}},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
// He walks away from his own request: a question is parked, the next
|
||||
// utterance is an unrelated request of its own, and nothing follows.
|
||||
// V-554's fix is what makes this row pass — the question steps aside
|
||||
// rather than scoring "добавь в задачи" as the time. The reminder is
|
||||
// dropped in silence and that is the decision: if he meant it he says it
|
||||
// again, and a question left armed eats the turn after next.
|
||||
{
|
||||
name: "abandoned flow: parked, then an unrelated request",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "спасибо"},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
|
||||
// V-577 shape 1, the worst of the nine claimants measured on 2026-08-06.
|
||||
// Every token of "что у меня сегодня?" is frame — an interrogative, a
|
||||
// preposition, a particle and a day word — so the role classifier never
|
||||
// looked at the route, the parked reminder read "сегодня" as its time,
|
||||
// and the hour came from the clock. He got a reminder he never asked for
|
||||
// at a minute he never said, and his question was answered nowhere.
|
||||
//
|
||||
// Two claims: the calendar answers, and nothing is written. The flow
|
||||
// survives underneath, because a question of his own is not a request to
|
||||
// abandon the one he was making.
|
||||
{
|
||||
name: "an agenda question mid-flow is answered, not eaten",
|
||||
turns: []turn{
|
||||
{say: "напомни забрать посылку", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
|
||||
{say: "что у меня сегодня?", contains: []string{"31.07.2026"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-577 shape 2. He states something in the middle of the flow. It is
|
||||
// neither a slot value nor a cancel, and it was scored as a failed
|
||||
// answer and dropped in silence: alone the same sentence is stored.
|
||||
// Silence is the one option that is wrong, so it is stored, no retry is
|
||||
// spent, and the question comes back on the end of the same reply.
|
||||
//
|
||||
// The words are a fact and not the owner's note, because the fact parser
|
||||
// is deterministic and the offline floor marks every classifier route
|
||||
// Clarify. The row below carries his own sentence and needs the model.
|
||||
//
|
||||
// What this floor can prove is the arbitration: no retry is spent, the
|
||||
// flow survives on the same attempt, and the words are answered as
|
||||
// themselves with the question coming back after them. Whether the fact
|
||||
// is then WRITTEN is the routing engine's business — the hash embedder
|
||||
// is unsure of every sentence it sees, and an unsure fact has never been
|
||||
// stored.
|
||||
{
|
||||
name: "a fact stated mid-flow steps aside without spending a retry",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
// Nothing she says about it may be a word he did not say
|
||||
// (V-592). On the box this sentence came back as "Проверила, что
|
||||
// ты выпел стакан воды": a non-word for the verb, a glass copied
|
||||
// out of the example in ReplySystemPrompt, and a claim to have
|
||||
// checked something. The store held key=water value="drank"
|
||||
// throughout, so all of it was generated from two tokens.
|
||||
//
|
||||
// The positive half of the contract — the confirmation IS his
|
||||
// sentence — is asserted by "fact completed over two turns"
|
||||
// above. It cannot be asserted here: the hash embedder marks
|
||||
// this route Clarify, and an unsure fact is answered with the
|
||||
// canned line rather than a confirmation of anything.
|
||||
{say: "я выпил воды", contains: []string{"напоминание?"},
|
||||
notContain: []string{"стакан", "выпел", "Проверила", "water"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-593: two asks about the same half of the day, with a turn between
|
||||
// them that answered the DAY. Asking again is right and asking in the
|
||||
// same bytes is not — from his side it is indistinguishable from not
|
||||
// having been heard, which is what the whole V-558 family is about.
|
||||
//
|
||||
// The clock still opens every ask (the owner's rule, V-579); the
|
||||
// acknowledgement goes after it and before the question.
|
||||
{
|
||||
name: "a re-ask names what the answer before it gave her",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
contains: []string{"Сейчас "},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра",
|
||||
contains: []string{"Сейчас ", "завтра"}, differs: true,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-579 turn 3: the preposition decided whether the hour was read. "в 9"
|
||||
// set the reminder and "на 9" was not read at all, on the same build and
|
||||
// with the same cardinal.
|
||||
{
|
||||
// It is read, and being read is not the same as being enough: nine is
|
||||
// either half of the day, so she asks which and then which day
|
||||
// (V-579). Both answers are frame words and neither carries an hour
|
||||
// of its own, so this row is also the proof that an answer is read
|
||||
// against the whole request rather than alone.
|
||||
name: "на 9 answers the time question like в 9",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, took: "утра",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
{say: "завтра", contains: []string{"09:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
|
||||
},
|
||||
// The owner's own four, ruled 2026-08-06 (V-579). A reminder commits
|
||||
// when what, what time and what day are all answered, and every ask
|
||||
// states the clock she is reasoning from.
|
||||
{
|
||||
name: "his first example: a bare 3 is asked about",
|
||||
turns: []turn{
|
||||
{say: "напомни завтра в 3 заказать цветы",
|
||||
question: dialogue.SlotTime, attempt: 1, gap: whenAmbiguousHour,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "цветы"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
{
|
||||
// The hour is unambiguous and the day is still missing, so she asks.
|
||||
// Today being a valid reading is not the same as him saying it.
|
||||
name: "his second example: nine in the evening of which day",
|
||||
turns: []turn{
|
||||
{say: "напомни в 9 вечера разгрузить стиралку",
|
||||
question: dialogue.SlotTime, attempt: 1, gap: whenNoDay,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "стиралку"}},
|
||||
{say: "завтра", contains: []string{"21:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "стиралку", fireAt: "2026-08-01 21:00"}}},
|
||||
},
|
||||
{
|
||||
// All three answered in one breath, so she does not ask at all.
|
||||
name: "his third example: a full time commits",
|
||||
turns: []turn{
|
||||
{say: "напомни завтра в 15:00 заказать цветы", notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "цветы", fireAt: "2026-08-01 15:00"}}},
|
||||
},
|
||||
{
|
||||
// An interval is one instant, so it answers the hour and the day
|
||||
// together. Confirmed by the owner: "через час is fine as is".
|
||||
name: "an interval commits without a question",
|
||||
turns: []turn{
|
||||
{say: "напомни через час позвонить маме", notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "маме", fireAt: "2026-07-31 10:17"}}},
|
||||
},
|
||||
// V-579 turn 4: he named a day and no hour, and got the day at the
|
||||
// current minute. She has to ask instead, and the global check in
|
||||
// checkEnd refuses the invented minute for every row at once.
|
||||
{
|
||||
name: "a day with no hour is asked about, not taken from the clock",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на завтра", question: dialogue.SlotTime, attempt: 2,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
|
||||
// ---- formerly failing interleavings; kept as permanent contracts ----
|
||||
|
||||
// The owner's own sentence from V-577 shape 2, in his words. It needs
|
||||
// an engine that can route it: the hash embedder marks it note with
|
||||
// Clarify set, and a route she is not sure of is not evidence that he
|
||||
// stated anything. The row above is the same contract in words the
|
||||
// floor's deterministic fact parser reads.
|
||||
{
|
||||
name: "a note stated mid-flow is stored, not dropped",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
{say: "у меня новый ноутбук",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
},
|
||||
end: endState{notes: 1},
|
||||
},
|
||||
|
||||
// The owner's target transcript, V-561. He asks for a reminder, she asks
|
||||
// when, he asks something else entirely, and then comes back to her
|
||||
// question. On the box this created a reminder at 00:12 and never
|
||||
// answered Rome; on the offline floor the side question is recognised as
|
||||
// its own request and the flow is dropped instead, so the wrong reminder
|
||||
// is not made and the right one is not either.
|
||||
//
|
||||
// Both are the same defect: there is no suspend and resume. The correct
|
||||
// shape is the middle turn answered on its own and the parked question
|
||||
// still standing, on the same attempt — a side query is not a failed
|
||||
// answer and must not spend a retry.
|
||||
//
|
||||
// The skip came off with V-579. What held it was the parser, not the
|
||||
// arbitration: neither the stub nor the production one read "на 9",
|
||||
// because only "в" framed a spoken hour, and "на завтра" was completed
|
||||
// from the clock.
|
||||
//
|
||||
// Turn 3 now closes the flow, where the transcript has one more exchange
|
||||
// in it. That is the 12-hour question — the owner's turn 4 answers "на
|
||||
// 9" with "сейчас 15:23, на 9 сегодня вечером?" — and it is a decision of
|
||||
// its own, not one to invent here. Nine o'clock is read as nine and, at
|
||||
// 09:17, as tomorrow's, which is where the transcript ends up anyway.
|
||||
// Turn 4 then has nothing to answer and must not write anything.
|
||||
{
|
||||
name: "the owner's transcript from V-561",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "какая сейчас погода в Риме?",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
// His words, unchanged. What changed under V-579 is that "на 9"
|
||||
// is a question and not a commit: nine could be either half of
|
||||
// the day, so she says the clock she is reading from and asks.
|
||||
// "на завтра." then answers the day and leaves the half open, so
|
||||
// she asks that one again.
|
||||
// Each ask names what the turn before it gave her (V-593). The
|
||||
// two asks about the half of the day are the same question and
|
||||
// must not be the same sentence: he answered between them, and a
|
||||
// reply with no trace of that reads as not having been heard.
|
||||
{say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "а, да, прости - на 9.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// The same shape said in words StubDateTimeParser reads. GREEN since
|
||||
// V-561. Same three claims: Rome is answered, the question survives the
|
||||
// side query on the same attempt, and the answer after it completes the
|
||||
// reminder he actually asked for.
|
||||
//
|
||||
// It sits under the "fail today" header because the row above it still
|
||||
// does. Do not re-skip it to tidy that up: this is the owner's
|
||||
// acceptance test in the only words the offline floor can read.
|
||||
{
|
||||
name: "nested question: a parked question, then one of his own",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "какая сейчас погода в Риме?",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||
},
|
||||
// A cancel is one of the five turn roles V-560 names, and today it is
|
||||
// none of them: "неважно" fills no slot and carries no request of its
|
||||
// own, so it reads as a failed answer and spends a retry. Two turns
|
||||
// later she is still asking about a reminder he called off.
|
||||
//
|
||||
// The row asserts what is knowable — nothing armed, nothing written, and
|
||||
// not another question — rather than her wording for it, which is not
|
||||
// written yet and is not this task's to invent.
|
||||
{
|
||||
name: "cancel: a parked question, then never mind",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "неважно", noQuestion: true},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// Order in runTurn is the whole arbitration (V-558), and this is what it
|
||||
// costs: the clarify answer is checked at step 3 and the repair marker at
|
||||
// step 4d, so while a question is parked no correction can be made. She
|
||||
// scores "нет, это была заметка" as a bad time answer and asks again.
|
||||
{
|
||||
name: "correction while a question is parked",
|
||||
turns: []turn{
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
// Stage 0 has extracted the hour since V-572. The day remains genuinely
|
||||
// absent, and V-579 deliberately refuses to invent it even when 11:00 is
|
||||
// still ahead on today's clock. This stale skipped row used to expect a
|
||||
// commit and contradicted every neighbouring time-contract row.
|
||||
{
|
||||
name: "a stage-zero reminder keeps its hour and asks for the missing day",
|
||||
turns: []turn{
|
||||
{say: "напомни в 11:00 позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
gap: whenNoDay, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// The same gap on the repair path. A correction redoes the request
|
||||
// through finishClarified, which goes straight to applyAction — it never
|
||||
// passes the clarify gate — so a redo that lands short answers with the
|
||||
// parse error V-557 removed from the routing path: "не поняла, на когда
|
||||
// напомнить." She should ask, exactly as she does for a fresh reminder
|
||||
// with no time.
|
||||
{
|
||||
name: "a correction that lands short asks rather than failing",
|
||||
turns: []turn{
|
||||
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
|
||||
{say: "нет, это было напоминание", contains: []string{"поняла, это напоминание"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
},
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The list she read at the mic is not the list a browser is looking at
|
||||
// (Vikunja #45 step 3). The clarify store was keyed per reach in #466; the
|
||||
// dialogue session was still one slot for the box, so "второй" typed on the web
|
||||
// closed the second task she had recited out loud.
|
||||
func TestCandidatesDoNotCrossReaches(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, voiceCtx, ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
if reply, handled := h.resolveCandidate(webCtx, "первую сделал", sourceText); handled {
|
||||
t.Fatalf("a web turn picked from the list she read aloud: %q", reply)
|
||||
}
|
||||
live, err := st.ListTasks(context.Background(), "live")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
if len(live) != 2 {
|
||||
t.Fatalf("%d tasks live, want 2 — the web turn moved one", len(live))
|
||||
}
|
||||
// The reach that was offered the list still owns it.
|
||||
if _, handled := h.resolveCandidate(voiceCtx, "первую сделал", sourceVoice); !handled {
|
||||
t.Fatal("the mic lost its own list")
|
||||
}
|
||||
}
|
||||
|
||||
// A selection writes a fact, so the fact must name the reach the words arrived
|
||||
// on. It said "tap:voice" for a typed turn.
|
||||
func TestCandidateProvenanceFollowsTheReach(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
ids := seedTasks(t, st, "купить хлеб")
|
||||
putCandidates(h, ctx, ids, "купить хлеб")
|
||||
|
||||
if _, handled := h.resolveCandidate(ctx, "первую сделал", sourceText); !handled {
|
||||
t.Fatal("the pick was not acted on")
|
||||
}
|
||||
done, err := st.ListTasks(context.Background(), "done")
|
||||
if err != nil {
|
||||
t.Fatalf("list tasks: %v", err)
|
||||
}
|
||||
if len(done) != 1 {
|
||||
t.Fatalf("%d tasks done, want 1", len(done))
|
||||
}
|
||||
if by := done[0].ResolvedBy; by != string(sourceText) {
|
||||
t.Errorf("resolved_by = %q, want %q", by, sourceText)
|
||||
}
|
||||
}
|
||||
|
||||
// Anaphora is per reach too: an ellipsis typed on the web must not continue the
|
||||
// question he asked at the mic. Both surfaces stay usable at once, which is the
|
||||
// case a single-owner box actually hits — a phone open while he talks.
|
||||
func TestAnaphoraDoesNotCrossReaches(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
voiceCtx := withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||
webCtx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
now := h.now()
|
||||
|
||||
h.rememberTurn(voiceCtx, nil, router.Decision{
|
||||
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
||||
}, now)
|
||||
|
||||
if sess := h.dialogueSessions.Get(dialogueIDOf(webCtx), now); sess != nil {
|
||||
t.Fatalf("the web reach inherited the mic's turn: %+v", sess)
|
||||
}
|
||||
sess := h.dialogueSessions.Get(dialogueIDOf(voiceCtx), now)
|
||||
if sess == nil || !strings.Contains(sess.Slots.Text, "встреча") {
|
||||
t.Fatalf("the mic lost its own turn: %+v", sess)
|
||||
}
|
||||
}
|
||||
+167
-2
@@ -2,13 +2,26 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/delivery"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
type nudgeCountingPhraser struct {
|
||||
phraser.Phraser
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *nudgeCountingPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (delivery.PhrasedNudge, error) {
|
||||
p.calls++
|
||||
return p.Phraser.PhraseNudge(ctx, c)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -38,7 +51,7 @@ func TestSuppressedCareDigestsAcrossQuietHours(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
|
||||
entries, err := st.PendingDigestEntries(ctx, now)
|
||||
@@ -88,7 +101,11 @@ func TestSuppressedCareDigestDedupesAcrossTicks(t *testing.T) {
|
||||
now := refNow()
|
||||
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
quiet.Facts = breakCandidateFacts(now, 1)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
for i := 0; i < 3; i++ {
|
||||
quiet.Now = now.Add(time.Duration(i) * time.Minute)
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now.Add(time.Duration(i)*time.Minute))
|
||||
}
|
||||
|
||||
@@ -99,6 +116,154 @@ func TestSuppressedCareDigestDedupesAcrossTicks(t *testing.T) {
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("3 suppressions of the same nudge must collapse to 1 pending entry, got %d", len(entries))
|
||||
}
|
||||
if counting.calls != 1 {
|
||||
t.Fatalf("3 suppressed ticks phrased %d times, want exactly 1", counting.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressedCareDigestAcrossRealTicksDoesOnePhraseCall(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
markPresent(t, st, ctx, now)
|
||||
if _, err := st.SetValue(ctx, store.KindSelf, "break", "tap:test", "done", now.Add(-2*time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.SetValue(ctx, store.KindConfig, "quiet_hours", "promote", true, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
for i := 0; i < 3; i++ {
|
||||
tl.tick(ctx, now.Add(time.Duration(i)*30*time.Second))
|
||||
}
|
||||
|
||||
if counting.calls != 1 {
|
||||
t.Fatalf("3 complete suppressed ticks phrased %d times, want exactly 1", counting.calls)
|
||||
}
|
||||
entries, err := st.PendingDigestEntries(ctx, now.Add(time.Minute))
|
||||
if err != nil || len(entries) != 1 {
|
||||
t.Fatalf("complete ticks should retain one durable entry: entries=%+v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuppressedCareDigestDedupeSurvivesRestart proves V-687 at its actual
|
||||
// boundary: a fresh tickLoop has no memory of the first call, yet durable
|
||||
// candidate identity still prevents a second PhraseNudge.
|
||||
func TestSuppressedCareDigestDedupeSurvivesRestart(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "digest-restart.db")
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 9)}
|
||||
|
||||
firstStore, err := store.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := newTestTickLoop(t, firstStore, &fakeSink{}, nil)
|
||||
firstPhraser := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
first.phraser = firstPhraser
|
||||
first.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
if firstPhraser.calls != 1 {
|
||||
t.Fatalf("first loop phrase calls = %d, want 1", firstPhraser.calls)
|
||||
}
|
||||
if err := firstStore.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
secondStore, err := store.Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = secondStore.Close() })
|
||||
second := newTestTickLoop(t, secondStore, &fakeSink{}, nil)
|
||||
secondPhraser := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
second.phraser = secondPhraser
|
||||
quiet.Now = now.Add(time.Minute)
|
||||
second.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, quiet.Now)
|
||||
if secondPhraser.calls != 0 {
|
||||
t.Fatalf("same candidate after restart phrased %d times, want 0", secondPhraser.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressedCareDigestRephrasesWhenMeaningChanges(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
quiet.Facts = breakCandidateFacts(now.Add(time.Minute), 2)
|
||||
quiet.Now = now.Add(time.Minute)
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, quiet.Now)
|
||||
|
||||
if counting.calls != 2 {
|
||||
t.Fatalf("two semantic occurrences phrased %d times, want 2", counting.calls)
|
||||
}
|
||||
entries, err := st.PendingDigestEntries(ctx, quiet.Now)
|
||||
if err != nil || len(entries) != 2 {
|
||||
t.Fatalf("changed meaning should create a second entry: entries=%+v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressedCareDigestRephrasesAfterExpiry(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
tl := newTestTickLoop(t, st, &fakeSink{}, nil)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
// Deliberately do not run the expiry sweep. The pre-phrase lookup and
|
||||
// enqueue path must agree that this occurrence is no longer live.
|
||||
afterExpiry := now.Add(digestExpiry + time.Minute)
|
||||
quiet.Now = afterExpiry
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, afterExpiry)
|
||||
|
||||
if counting.calls != 2 {
|
||||
t.Fatalf("expired occurrence phrased %d times total, want 2", counting.calls)
|
||||
}
|
||||
entries, err := st.PendingDigestEntries(ctx, afterExpiry)
|
||||
if err != nil || len(entries) != 1 || !entries[0].CreatedTs.Equal(afterExpiry) {
|
||||
t.Fatalf("expired row was not replaced by one fresh row: entries=%+v err=%v", entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressedCareDigestRephrasesAfterDrain(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
sink := &fakeSink{}
|
||||
tl := newTestTickLoop(t, st, sink, nil)
|
||||
counting := &nudgeCountingPhraser{Phraser: phraser.NewStub()}
|
||||
tl.phraser = counting
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
clearAt := now.Add(time.Minute)
|
||||
tl.maybeDrainDigest(ctx, loop.State{Now: clearAt, Presence: store.Present}, clearAt)
|
||||
quiet.Now = clearAt.Add(time.Minute)
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, quiet.Now)
|
||||
|
||||
if counting.calls != 2 {
|
||||
t.Fatalf("same occurrence after drain phrased %d times, want 2", counting.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func breakCandidateFacts(now time.Time, occurrenceID int64) map[string]store.Fact {
|
||||
return map[string]store.Fact{
|
||||
"break": {
|
||||
ID: occurrenceID, Ts: now.Add(-2 * time.Hour), Kind: store.KindSelf,
|
||||
Key: "break", Value: "done", Source: "tap:test", Confidence: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuppressedCareDigestExpiresRatherThanDeliveringLate — an entry that
|
||||
@@ -111,7 +276,7 @@ func TestSuppressedCareDigestExpiresRatherThanDeliveringLate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := refNow()
|
||||
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present}
|
||||
quiet := loop.State{Now: now, QuietHours: true, Presence: store.Present, Facts: breakCandidateFacts(now, 1)}
|
||||
tl.enqueueSuppressedDigest(ctx, breakTrace("quiet_hours"), quiet, now)
|
||||
|
||||
// well past digestExpiry (24h) before the suppression ever clears.
|
||||
|
||||
+217
-107
@@ -12,6 +12,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
@@ -42,28 +43,100 @@ const ecosystemAPIVersion = "v1"
|
||||
// anonymous HTTP client.
|
||||
const mavenRequester = "maven"
|
||||
|
||||
// setEcosystemHeaders stamps the version, requester, auth and correlation
|
||||
// headers common to every outgoing ecosystem request. token may be empty,
|
||||
// which means the transport itself is trusted (loopback or unix socket).
|
||||
// ecosystemHTTP is the JSON transport every ecosystem client shares: one base
|
||||
// URL, one bearer token, and the header set the contract requires on each
|
||||
// request. Nexus and Praxis differ only in the service name and the version
|
||||
// header, so both embed this rather than repeating build, send and classify.
|
||||
type ecosystemHTTP struct {
|
||||
service string // "nexus", "praxis" — the name errors and traces carry
|
||||
versionHeader string
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newEcosystemHTTP(service, versionHeader, baseURL string) ecosystemHTTP {
|
||||
return ecosystemHTTP{
|
||||
service: service,
|
||||
versionHeader: versionHeader,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// setHeaders stamps the version, requester, auth and correlation headers common
|
||||
// to every outgoing ecosystem request. The token may be empty, which means the
|
||||
// transport itself is trusted (loopback or unix socket).
|
||||
//
|
||||
// The correlation ID is read from the context and never minted here. Minting
|
||||
// one per request sent the far side an ID that existed nowhere on this side,
|
||||
// and gave a single multi-hop action as many unrelated IDs as it made calls.
|
||||
// Callers that start an action assign the ID once (handleHexisAct,
|
||||
// The correlation ID is read from the request's own context and never minted
|
||||
// here. Minting one per request sent the far side an ID that existed nowhere on
|
||||
// this side, and gave a single multi-hop action as many unrelated IDs as it
|
||||
// made calls. Callers that start an action assign the ID once (handleHexisAct,
|
||||
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
|
||||
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
|
||||
func (t *ecosystemHTTP) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set(t.versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Requested-By", mavenRequester)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if t.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+t.token)
|
||||
}
|
||||
if id := correlationIDFromCtx(ctx); id != "" {
|
||||
if id := correlationIDFromCtx(req.Context()); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
}
|
||||
|
||||
// call sends one request and decodes the JSON answer into out, which may be nil
|
||||
// when the body carries nothing worth reading. op is the logical operation name
|
||||
// for errors and traces: the path carries the query string, and after entity
|
||||
// scoping that means an entity id in every log line built from the error, next
|
||||
// to a trace that redacts far less than that.
|
||||
//
|
||||
// Every failure is an *ecosystemError, including the transport and decode ones.
|
||||
// Some of these paths mutate remote state, and the question worth answering
|
||||
// afterwards is whether the call never left or was refused.
|
||||
func (t *ecosystemHTTP) call(ctx context.Context, method, op, path string, payload, out any) error {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, body)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
t.setHeaders(req)
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError(t.service, op, resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out.
|
||||
func (t *ecosystemHTTP) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
return t.call(ctx, http.MethodGet, op, path, nil, out)
|
||||
}
|
||||
|
||||
// postJSON posts a JSON payload and decodes the JSON answer into out.
|
||||
func (t *ecosystemHTTP) postJSON(ctx context.Context, op, path string, payload, out any) error {
|
||||
return t.call(ctx, http.MethodPost, op, path, payload, out)
|
||||
}
|
||||
|
||||
// ecosystemError is the typed failure every ecosystem client returns, so
|
||||
// callers can tell a transport failure from a refusal from a contract
|
||||
// mismatch without matching on message text. The distinction matters:
|
||||
@@ -107,17 +180,60 @@ func httpError(service, op string, status int) *ecosystemError {
|
||||
}
|
||||
}
|
||||
|
||||
// hexisStatusTexts maps the http.StatusText spelling back to its code, for the
|
||||
// failure statuses a Hexis call can plausibly answer with. It is the inverse of
|
||||
// what the vendored client threw away.
|
||||
var hexisStatusTexts = func() map[string]int {
|
||||
codes := []int{
|
||||
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
|
||||
http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotAcceptable,
|
||||
http.StatusRequestTimeout, http.StatusConflict, http.StatusGone,
|
||||
http.StatusUnprocessableEntity, http.StatusUpgradeRequired,
|
||||
http.StatusTooManyRequests, http.StatusInternalServerError,
|
||||
http.StatusNotImplemented, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout,
|
||||
}
|
||||
m := make(map[string]int, len(codes))
|
||||
for _, c := range codes {
|
||||
m[http.StatusText(c)] = c
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// hexisError re-wraps an error from the vendored Hexis client as an
|
||||
// *ecosystemError, so a Hexis failure classifies the same way a Nexus or Praxis
|
||||
// one does and ecosystemGap can tell a refused credential from an outage.
|
||||
//
|
||||
// This is a boundary adapter and it is not the fix anyone would choose. The
|
||||
// Hexis client lives in another repository and returns
|
||||
// fmt.Errorf("%s: %s", http.StatusText(status), body) for every status at or
|
||||
// above 400, so the status text is the only signal that survives — the correct
|
||||
// fix is a typed error carrying the code, and Maven cannot land it unilaterally
|
||||
// (Vikunja #587, docs/plans/20-two-artifacts-and-neither-is-spring.md). Parsing
|
||||
// here is bounded: the message's first colon-delimited segment is the status
|
||||
// text verbatim, no status text contains a colon, and anything unrecognised —
|
||||
// "do request: ...", "create request: ..." — is a transport failure and is left
|
||||
// at status 0, which is exactly what Unreachable() means.
|
||||
func hexisError(op string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
return err
|
||||
}
|
||||
head, _, _ := strings.Cut(err.Error(), ": ")
|
||||
return &ecosystemError{
|
||||
Service: "hexis", Op: op, Status: hexisStatusTexts[head], Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
type nexusClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
ecosystemHTTP
|
||||
}
|
||||
|
||||
func newNexusClient(url string) *nexusClient {
|
||||
return &nexusClient{
|
||||
baseURL: url,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
return &nexusClient{newEcosystemHTTP("nexus", "X-Nexus-Version", url)}
|
||||
}
|
||||
|
||||
// withToken sets the bearer token sent on every request. Returns the client so
|
||||
@@ -176,62 +292,26 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
body["types"] = types
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/resolve", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, httpError("nexus", "resolve", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result nexusResolveResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
|
||||
if err := c.postJSON(ctx, "resolve", "/api/v1/resolve", body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *nexusClient) Health(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError("nexus", "health", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
return c.getJSON(ctx, "health", "/health", nil)
|
||||
}
|
||||
|
||||
// praxisClient talks to the Praxis HTTP tools API. Maven must not open Praxis's
|
||||
// SQLite store directly (ecosystem invariant: no component reads another's DB),
|
||||
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
|
||||
type praxisClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
ecosystemHTTP
|
||||
}
|
||||
|
||||
func newPraxisClient(url string) *praxisClient {
|
||||
return &praxisClient{
|
||||
baseURL: url,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
return &praxisClient{newEcosystemHTTP("praxis", "X-Praxis-Version", url)}
|
||||
}
|
||||
|
||||
func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
@@ -239,42 +319,85 @@ func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out. op is the logical
|
||||
// operation name for errors and traces: the path carries the query string, and
|
||||
// after entity scoping that means an entity id in every log line built from the
|
||||
// error, next to a trace that redacts far less than that.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
// praxisAttention — an attention response in either of the two shapes Praxis
|
||||
// may send (Vikunja #540).
|
||||
//
|
||||
// ECOSYSTEM-SPEC §2.6 says the response carries `degraded: [source_ids]` when a
|
||||
// source is failed or stale, and that Maven is required to say so rather than
|
||||
// report all-clear. The deployed Praxis answers with a bare JSON array and no
|
||||
// envelope at all, so both are decoded here: an array is the items, an object is
|
||||
// the spec envelope. This lands the Maven half without waiting on the server,
|
||||
// and the sources read below is what makes the hedge work meanwhile.
|
||||
type praxisAttention struct {
|
||||
Items []map[string]any
|
||||
Degraded []string
|
||||
}
|
||||
|
||||
func (a *praxisAttention) UnmarshalJSON(data []byte) error {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||
return json.Unmarshal(trimmed, &a.Items)
|
||||
}
|
||||
var env struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
Degraded []string `json:"degraded"`
|
||||
}
|
||||
if err := json.Unmarshal(trimmed, &env); err != nil {
|
||||
return err
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
a.Items, a.Degraded = env.Items, env.Degraded
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) (praxisAttention, error) {
|
||||
var out praxisAttention
|
||||
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// praxisSource — one polled source, as much of it as the hedge needs. The tools
|
||||
// API does not expose sources, so this decodes the plain `/api/v1/sources` rows.
|
||||
type praxisSource struct {
|
||||
ID string `json:"id"`
|
||||
SourceID string `json:"source_id"`
|
||||
Health string `json:"health"`
|
||||
}
|
||||
|
||||
func (s praxisSource) name() string {
|
||||
if s.SourceID != "" {
|
||||
return s.SourceID
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
// UnhealthySources reports which sources cannot be trusted to have reported,
|
||||
// and how many sources Praxis has at all (Vikunja #540).
|
||||
//
|
||||
// Only read when the attention list came back empty, which is the one turn where
|
||||
// an all-clear is at stake. A source whose health field is absent counts as
|
||||
// healthy: a Praxis that never reports health would otherwise make every quiet
|
||||
// turn a hedge, and an unreported field is not evidence of a fault. Everything it
|
||||
// does report other than "ok" — failed, stale, degraded, unknown — counts as
|
||||
// cannot-tell, because none of them mean the source has spoken.
|
||||
func (c *praxisClient) UnhealthySources(ctx context.Context) (bad []string, total int, err error) {
|
||||
var out []praxisSource
|
||||
if err := c.getJSON(ctx, "sources", "/api/v1/sources", &out); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for _, s := range out {
|
||||
if s.Health != "" && s.Health != "ok" {
|
||||
bad = append(bad, s.name())
|
||||
}
|
||||
}
|
||||
return bad, len(out), nil
|
||||
}
|
||||
|
||||
// ListAttentionForEntity is ListAttention scoped to a single canonical Nexus
|
||||
// entity, so callers already holding a resolved entity_id (e.g. after
|
||||
// resolveEntityReference) can ask "what needs attention for this entity"
|
||||
// instead of filtering the unscoped list client-side.
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
|
||||
var out []map[string]any
|
||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) (praxisAttention, error) {
|
||||
var out praxisAttention
|
||||
err := c.getJSON(ctx, "attention_for_entity",
|
||||
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
||||
return out, err
|
||||
@@ -309,31 +432,14 @@ type praxisItem struct {
|
||||
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
|
||||
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
|
||||
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
return c.postItem(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
}
|
||||
|
||||
// postJSON posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
// Every failure is a *ecosystemError, including the transport and decode ones:
|
||||
// these are the paths that mutate remote state, and the question worth
|
||||
// answering afterwards is whether the call never left or was refused.
|
||||
func (c *praxisClient) postJSON(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
// postItem posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
func (c *praxisClient) postItem(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
if err := c.postJSON(ctx, op, path, payload, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -358,7 +464,7 @@ func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem,
|
||||
}
|
||||
|
||||
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
|
||||
return c.postJSON(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
return c.postItem(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
}
|
||||
|
||||
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
@@ -473,6 +579,7 @@ func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID str
|
||||
}
|
||||
caps, err := w.hexis.Capabilities(ctx, entityID)
|
||||
if err != nil {
|
||||
err = hexisError("capabilities", err)
|
||||
log.Printf("ecosystem: hexis capabilities error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -500,7 +607,10 @@ func (w *ecosystemWiring) executeCapability(ctx context.Context, capabilityID, t
|
||||
|
||||
exec, err := w.hexis.Execute(ctx, req)
|
||||
if err != nil {
|
||||
return correlationID, fmt.Errorf("execute: %w", err)
|
||||
// A classified dependency failure. The two returns below are NOT: an
|
||||
// execution that ran and failed is the command failing, not Hexis
|
||||
// degrading, and it keeps its plain error so the caller says so.
|
||||
return correlationID, hexisError("execute", err)
|
||||
}
|
||||
if exec.Status == "succeeded" {
|
||||
return correlationID, nil
|
||||
|
||||
+232
-53
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,13 +22,25 @@ import (
|
||||
// already knows which one it was talking to — it records the same name in the
|
||||
// trace (Vikunja #521).
|
||||
const (
|
||||
serviceNexus = "Nexus"
|
||||
serviceHexis = "Hexis"
|
||||
serviceNexus = "Nexus"
|
||||
servicePraxis = "Praxis"
|
||||
serviceHexis = "Hexis"
|
||||
)
|
||||
|
||||
// serviceVars — the one-key map the eco_down and eco_denied lines take.
|
||||
func serviceVars(name string) map[string]string { return map[string]string{"name": name} }
|
||||
|
||||
// ecosystemGap names the service that failed. A rejected credential gets its
|
||||
// own line, because a wrong token looks exactly like an outage to him and
|
||||
// "try again" is advice that will never work. Every degrade path reads through
|
||||
// here, so all of them name the service and none of them guesses instead.
|
||||
func ecosystemGap(service string, err error) string {
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(service))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(service))
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -105,6 +118,13 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||
}
|
||||
px := h.ecosystem.praxis
|
||||
dec, ok := h.resolveSurfacedPosition(dec)
|
||||
if !ok {
|
||||
// A demonstrative with no digest behind it. "я это сделал" is a sentence
|
||||
// about his day, so the rest of the cascade gets it back rather than
|
||||
// hearing "какой пункт?" for something that was never about a пункт.
|
||||
return ""
|
||||
}
|
||||
for _, capability := range praxisCapabilities {
|
||||
for _, alias := range capability.aliases() {
|
||||
if alias == dec.Slots.Fn {
|
||||
@@ -119,10 +139,16 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
// 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
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
// failure is the first half of the reply when the Praxis call errors: which
|
||||
// operation did not happen. ecosystemGap supplies the second half, which
|
||||
// names Praxis and splits a refused token from an outage — those two used to
|
||||
// produce the identical sentence and neither said "Praxis" (Vikunja #588).
|
||||
// The verb is kept alongside the service name because the trace is the only
|
||||
// other place it exists, and he is not reading the trace.
|
||||
failure string
|
||||
success string
|
||||
call func(ctx context.Context, px *praxisClient, id string) error
|
||||
}
|
||||
@@ -139,7 +165,7 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
|
||||
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
|
||||
return a.failure
|
||||
return a.failure + " " + ecosystemGap(servicePraxis, err)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
|
||||
return a.success
|
||||
@@ -154,18 +180,23 @@ func (listAttentionCapability) aliases() []string {
|
||||
|
||||
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||
started := h.now()
|
||||
items, err := px.ListAttention(ctx, 20)
|
||||
att, err := px.ListAttention(ctx, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention: %v", err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||
started, traceErrorFields(err))
|
||||
return phraser.A(phraser.AttentionFail, nil)
|
||||
}
|
||||
items := att.Items
|
||||
if len(items) == 0 {
|
||||
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, started); hedge != "" {
|
||||
return hedge
|
||||
}
|
||||
return phraser.A(phraser.AttentionNone, nil)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
|
||||
@@ -186,15 +217,13 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
}
|
||||
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)
|
||||
}
|
||||
// Recorded in the order she says them, and only for items she could
|
||||
// say: an item skipped above has no position in what he heard (#516).
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
}
|
||||
}
|
||||
h.rememberSurfaced(spoken)
|
||||
if len(parts) == 0 {
|
||||
// Praxis returned items and not one of them could be said. "ничего не
|
||||
// требует внимания" is the honest answer; the list line would render as
|
||||
@@ -281,12 +310,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
// trace gets. A trace that stores a rune count next to a log line
|
||||
// storing the runes is not redacted at all.
|
||||
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
@@ -299,14 +323,14 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
}
|
||||
|
||||
queried := h.now()
|
||||
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
att, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
items, scoped := scopedToEntity(items, entityID)
|
||||
items, scoped := scopedToEntity(att.Items, entityID)
|
||||
if !scoped {
|
||||
// A Praxis old enough to ignore an unknown query parameter answers the
|
||||
// scoped question with the unscoped list. Reading that back as "по
|
||||
@@ -322,28 +346,54 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
})
|
||||
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, title)
|
||||
// Same surfaced != acknowledged rule as the unscoped digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
}
|
||||
}
|
||||
// The scoped digest is a list she read out, so it replaces the positional
|
||||
// memory exactly as the unscoped one does. It used to surface these items
|
||||
// and remember none of them, which left the previous digest live: "отметь
|
||||
// второй как сделанное" then indexed into a list he had not just heard and
|
||||
// transitioned somebody else's item (docs/ecosystem.md — a wrong guess here
|
||||
// transitions the wrong item).
|
||||
h.rememberSurfaced(spoken)
|
||||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||
parts = append(parts, known)
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
// The scoped list is as exposed to a silent source as the unscoped one,
|
||||
// and a per-entity all-clear is the more convincing of the two (#540).
|
||||
if hedge := h.attentionCannotTell(ctx, px, att.Degraded, queried); hedge != "" {
|
||||
return hedge
|
||||
}
|
||||
return phraser.A(phraser.AttentionNoneEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||
}
|
||||
|
||||
// surfaceSpoken marks an item she just read out as surfaced. Speaking an item
|
||||
// surfaces it, it does not acknowledge it (ECOSYSTEM-SPEC.md §2.3: surfaced !=
|
||||
// acknowledged), so this calls Surface and nothing else. Best effort: a failed
|
||||
// surface call must not block delivering the digest. Returns the item id, or ""
|
||||
// when the item carried none.
|
||||
func surfaceSpoken(ctx context.Context, px *praxisClient, item map[string]any) string {
|
||||
id, _ := item["id"].(string)
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
// about, and reports whether the response can be trusted as scoped at all. An
|
||||
// item without an entity_id is kept only when at least one sibling carries the
|
||||
@@ -452,6 +502,14 @@ func traceStatusForError(err error) string {
|
||||
return traceFailed
|
||||
}
|
||||
|
||||
// nexusResolveFailed records a resolve that failed and returns the named gap.
|
||||
// The subject is his words, so the trace keeps a rune count and not the runes.
|
||||
func (h *reactiveHandler) nexusResolveFailed(ctx context.Context, subject string, started time.Time, err error) string {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
return ecosystemGap(serviceNexus, err)
|
||||
}
|
||||
|
||||
// redactSubject reduces a user utterance to something safe to persist in a
|
||||
// trace: its length only. Traces are diagnostics, and his words are not
|
||||
// diagnostics — the correlation ID is what ties a trace to the turn.
|
||||
@@ -510,26 +568,31 @@ func unauthorizedEcosystemError(err error) bool {
|
||||
return errors.As(err, &ee) && ee.Unauthorized()
|
||||
}
|
||||
|
||||
// isEcosystemError reports a failure that belongs to the service rather than to
|
||||
// what was asked of it: a call that never landed, or one the far side refused.
|
||||
// It separates "Hexis is down" from "the restart failed".
|
||||
func isEcosystemError(err error) bool {
|
||||
var ee *ecosystemError
|
||||
return errors.As(err, &ee)
|
||||
}
|
||||
|
||||
// traceErrorFields describes an ecosystemError for a trace without leaking the
|
||||
// payload: the HTTP status and the failure class, nothing else.
|
||||
func traceErrorFields(err error) map[string]any {
|
||||
fields := map[string]any{}
|
||||
fields := map[string]any{"class": "error"}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
default:
|
||||
fields["class"] = "error"
|
||||
}
|
||||
if !errors.As(err, &ee) {
|
||||
return fields
|
||||
}
|
||||
fields["class"] = "error"
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -595,6 +658,12 @@ func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []st
|
||||
// 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 {
|
||||
// This method is intentionally callable outside runTurn by ecosystem
|
||||
// harnesses. Refuse before correlation ids, Nexus resolution or capability
|
||||
// discovery so the no-op sentinel can never leak into Hexis as a verb.
|
||||
if refusesCommand(dec) {
|
||||
return commandProhibitionReply
|
||||
}
|
||||
if h.ecosystem == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -614,16 +683,11 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
res := h.resolveEntityCandidates(ctx, entityReferences(dec))
|
||||
subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||
}
|
||||
// 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 phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
@@ -646,10 +710,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
|
||||
return ecosystemGap(serviceHexis, err)
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
@@ -723,6 +784,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
entityID: entityID,
|
||||
displayName: displayName,
|
||||
expiry: h.now().Add(confirmTTL),
|
||||
// This action's id, so the execution the confirm authorises is
|
||||
// joined to the resolve and the discovery that proposed it.
|
||||
correlationID: correlationIDFromCtx(ctx),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
@@ -747,6 +811,14 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
// Hexis never answering, or answering "no", is a gap in Hexis and is
|
||||
// named as one — a refused token said "не получилось выполнить команду"
|
||||
// here and sent him to debug a capability that was never reached
|
||||
// (Vikunja #587). An execution that genuinely ran and failed is not an
|
||||
// ecosystemError and keeps the command-level line.
|
||||
if isEcosystemError(err) {
|
||||
return ecosystemGap(serviceHexis, err)
|
||||
}
|
||||
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
// One record per hop: the second write this used to make said the same
|
||||
@@ -775,11 +847,118 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
// resolution stops on ambiguity and a mutating capability still goes through
|
||||
// the spoken confirm in handleHexisAct.
|
||||
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
|
||||
// A thinned model act reaches this hook before actionAct. Negative authority
|
||||
// must therefore stop here as well, before even a read to Nexus/Hexis.
|
||||
if refusesCommand(dec) {
|
||||
return commandProhibitionReply
|
||||
}
|
||||
if h.ecosystem == nil || h.ecosystem.hexis == nil {
|
||||
return ""
|
||||
}
|
||||
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
|
||||
if dec.Intent != router.IntentAct || dec.Slots.HasFn || !router.ActHasEntityTarget(dec) {
|
||||
return ""
|
||||
}
|
||||
return h.handleHexisAct(ctx, dec)
|
||||
}
|
||||
|
||||
// attentionCannotTell returns the hedge to say instead of an all-clear, or ""
|
||||
// when an empty attention list really does mean nothing needs looking at
|
||||
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||
//
|
||||
// "Nothing needs attention" and "I cannot currently tell" are different answers
|
||||
// and only one of them was ever said. The spec's mechanism is a `degraded` array
|
||||
// on the attention response, which the deployed Praxis does not send, so the
|
||||
// source health read is the half that works today. It costs one HTTP call and
|
||||
// only on the empty-list turn, which is the only turn where an all-clear is at
|
||||
// stake.
|
||||
//
|
||||
// A failed sources read is deliberately NOT a hedge. The attention call itself
|
||||
// succeeded, and not being able to ask about health is not evidence of a fault —
|
||||
// hedging on it would turn one flaky endpoint into a permanently uncertain
|
||||
// assistant.
|
||||
func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisClient, degraded []string, started time.Time) string {
|
||||
if len(degraded) > 0 {
|
||||
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||
"degraded": strings.Join(degraded, ","), "source": "response",
|
||||
})
|
||||
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(degraded, ", ")})
|
||||
}
|
||||
bad, total, err := px.UnhealthySources(ctx)
|
||||
if err != nil {
|
||||
log.Printf("ecosystem: praxis sources: %v", err)
|
||||
return ""
|
||||
}
|
||||
if total == 0 {
|
||||
// A Praxis that polls nothing knows nothing, so its silence is not an
|
||||
// all-clear either. This is the state the box is in as of 2026-08-05:
|
||||
// /api/v1/sources answers with an empty array.
|
||||
h.recordPraxisTrace(ctx, "attention_no_sources", started, map[string]any{"sources": 0})
|
||||
return phraser.A(phraser.AttentionNoSources, nil)
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||
"degraded": strings.Join(bad, ","), "sources": total, "source": "health",
|
||||
})
|
||||
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(bad, ", ")})
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// rememberSurfaced records the item ids she just read out, replacing whatever the
|
||||
// previous digest left. Called with the ids in speaking order (Vikunja #516).
|
||||
func (h *reactiveHandler) rememberSurfaced(ids []string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.surfacedItems = ids
|
||||
}
|
||||
|
||||
// resolveSurfacedPosition turns a positional item reference into a Praxis item
|
||||
// id, using the list she last read out.
|
||||
//
|
||||
// The router names a position and not an id, because only the daemon has the
|
||||
// list: PraxisGrammars fills the value slot with "2", "last" or "this". An id is
|
||||
// left alone, since "item_ab12" is already one.
|
||||
//
|
||||
// The second return says whether the turn is still Praxis's. A position that
|
||||
// names nothing keeps the turn and clears the slot, so the capability answers its
|
||||
// own "какой пункт?" — he said "второй пункт" and deserves to hear that there is
|
||||
// no second one. A demonstrative that resolves to nothing gives the turn BACK,
|
||||
// because "я это сделал" was probably never about a пункт at all. "это" also
|
||||
// needs the list to hold exactly one item: pointing at one of five is a guess,
|
||||
// and a wrong guess here transitions the wrong item.
|
||||
func (h *reactiveHandler) resolveSurfacedPosition(dec router.Decision) (router.Decision, bool) {
|
||||
ref := dec.Slots.Value
|
||||
if ref == "" || strings.HasPrefix(ref, "item") {
|
||||
return dec, true
|
||||
}
|
||||
h.mu.Lock()
|
||||
ids := h.surfacedItems
|
||||
h.mu.Unlock()
|
||||
|
||||
idx := -1
|
||||
switch {
|
||||
case ref == "this":
|
||||
if len(ids) != 1 {
|
||||
log.Printf("ecosystem: praxis \"это\" has no single item (%d surfaced)", len(ids))
|
||||
return dec, false
|
||||
}
|
||||
idx = 0
|
||||
case ref == "last":
|
||||
idx = len(ids) - 1
|
||||
default:
|
||||
n, err := strconv.Atoi(ref)
|
||||
if err != nil || n < 1 {
|
||||
// Neither a position nor an id: leave it for the capability to
|
||||
// reject rather than silently rewriting what he said.
|
||||
return dec, true
|
||||
}
|
||||
idx = n - 1
|
||||
}
|
||||
if idx < 0 || idx >= len(ids) {
|
||||
log.Printf("ecosystem: praxis position %q has no item (%d surfaced)", ref, len(ids))
|
||||
dec.Slots.Value = ""
|
||||
return dec, true
|
||||
}
|
||||
dec.Slots.Value = ids[idx]
|
||||
return dec, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A refused credential and an outage are different answers, and on the Hexis
|
||||
// path only one of them used to be said. These tests pin the difference at both
|
||||
// Hexis sites: the discovery hop and the execute hop (Vikunja #587). The Praxis
|
||||
// half of the same defect is in praxis_gap_test.go.
|
||||
//
|
||||
// unreachableURL is a port nothing listens on, which is what "the service is
|
||||
// down" looks like from inside a call: the connection is refused, no HTTP
|
||||
// answer is ever produced, and ecosystemError.Unreachable() is true.
|
||||
const unreachableURL = "http://127.0.0.1:1"
|
||||
|
||||
func denied(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDenied, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
func down(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDown, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
// hexisGapHandler wires a handler whose Nexus resolves cleanly and whose Hexis
|
||||
// is the caller's to break. hexisURL is taken separately so a test can point it
|
||||
// at a dead port.
|
||||
func hexisGapHandler(t *testing.T, nexusURL, hexisURL string) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
dataStore: st,
|
||||
now: func() time.Time { return now },
|
||||
ecosystem: stubEcosystem(nexusURL, hexisURL),
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscovery401IsDeniedNotDown — the discovery hop.
|
||||
//
|
||||
// The vendored Hexis client returns a plain fmt.Errorf for every status at or
|
||||
// above 400, so errors.As for *ecosystemError never matched and every failure
|
||||
// fell through to the outage line. "Hexis is down" for a rejected token sends
|
||||
// him to inspect a service that is running fine.
|
||||
func TestHexisDiscovery401IsDeniedNotDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
hexis.SetFault(401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis discovery: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
if !strings.Contains(reply, serviceHexis) {
|
||||
t.Errorf("reply does not name Hexis: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscoveryOutageIsDownNotDenied — the other half of the same fork.
|
||||
// Without this the fix could pass by calling everything a refused credential.
|
||||
func TestHexisDiscoveryOutageIsDownNotDenied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
h := hexisGapHandler(t, nexus.URL, unreachableURL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("connection refused from hexis: got %q, want the outage line naming Hexis", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecute401IsDeniedNotCommandFailure — the execute hop, which did not
|
||||
// consult ecosystemGap at all and named neither the service nor the cause.
|
||||
func TestHexisExecute401IsDeniedNotCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
// Discovery stays healthy; only the execute endpoint refuses. A blanket
|
||||
// fault would never reach the site under test.
|
||||
hexis.SetRouteFault("/api/v1/execute", 401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis execute: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecuteOutageIsDown — same site, the other classification.
|
||||
//
|
||||
// Discovery and execution share one base URL, so the outage has to be scoped to
|
||||
// the execute endpoint rather than to the server: it answers capabilities
|
||||
// normally and drops the connection on execute, which is what the client sees
|
||||
// when the far side dies mid-call. That produces no HTTP status at all, which is
|
||||
// what Unreachable() means.
|
||||
func TestHexisExecuteOutageIsDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/execute" {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Errorf("hijack: %v", err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(caps))
|
||||
}))
|
||||
t.Cleanup(hexis.Close)
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("dropped connection on hexis execute: got %q, want the outage line", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecutionFailedStaysCommandFailure — the boundary of the fix. Hexis
|
||||
// answering 200 with a failed execution is the command failing, not Hexis
|
||||
// degrading, and it must keep the command-level line rather than accusing a
|
||||
// healthy service of being down.
|
||||
func TestHexisExecutionFailedStaysCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecutionFailed("exec_1", "unit refused to start"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if down(serviceHexis, reply) || denied(serviceHexis, reply) {
|
||||
t.Fatalf("a failed execution must not be reported as an ecosystem gap, got %q", reply)
|
||||
}
|
||||
if !phraser.IsA(phraser.ActFailEntity, map[string]string{"name": muzickIndexer}, reply) {
|
||||
t.Fatalf("want the command-failure line, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -115,3 +115,83 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
|
||||
// she read out, so a positional follow-up must land on one of ITS items. It
|
||||
// surfaced them and remembered none, which left the previous digest live and
|
||||
// sent "отметь второй" at somebody else's item.
|
||||
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
scoped := fixturePraxisAttentionScoped("ent_muzick",
|
||||
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
|
||||
praxis := newFakePraxis(t, scoped)
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
// A digest from an earlier turn, still the positional memory.
|
||||
h.rememberSurfaced([]string{"item_stale"})
|
||||
|
||||
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
|
||||
})
|
||||
if !strings.Contains(reply, "indexer wedged") {
|
||||
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
surfaced := append([]string(nil), h.surfacedItems...)
|
||||
h.mu.Unlock()
|
||||
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
|
||||
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
|
||||
}
|
||||
|
||||
// The follow-up resolves against what he just heard, not the stale list.
|
||||
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
|
||||
t.Fatal("positional follow-up should have been claimed by praxis")
|
||||
}
|
||||
var body string
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
|
||||
body = string(r.Body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "item_scoped_1") {
|
||||
t.Fatalf("resolve must transition the item she read out, posted %q", body)
|
||||
}
|
||||
if strings.Contains(body, "item_stale") {
|
||||
t.Fatal("resolve transitioned an item from a previous digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
|
||||
// later turn with a context of its own. The contract mints one id per action,
|
||||
// so the execution it authorises must still be joinable to the resolve and the
|
||||
// discovery that proposed it — it recorded a fresh id and no causation at all.
|
||||
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
resolve := findTrace(t, h, "nexus", "resolve")
|
||||
if resolve == nil || resolve.CorrelationID == "" {
|
||||
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
|
||||
}
|
||||
|
||||
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
|
||||
t.Fatal("confirm should have been claimed")
|
||||
}
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec == nil {
|
||||
t.Fatal("expected a hexis execute trace")
|
||||
}
|
||||
if exec.CausationID != resolve.CorrelationID {
|
||||
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
|
||||
exec.CausationID, resolve.CorrelationID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,48 @@ func TestClarifyStillAsksWithoutHexis(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A verb is not an entity. Before the reach gate, an exact local matcher hit
|
||||
// with no arguments still sent the raw verb to Nexus and could discover a
|
||||
// similarly named entity through Hexis. The local tool lane may handle or
|
||||
// reject it, but the ecosystem must not be consulted without a target.
|
||||
func TestBareMatchedActNeverReachesNexus(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_power", "Power", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.ecosystem = ecoHandler(t, nexus, nil, hexis).ecosystem
|
||||
|
||||
reply := h.actionAct(context.Background(), router.Decision{
|
||||
Utterance: "выключи",
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "выключи", HasFn: true, Text: "выключи"},
|
||||
})
|
||||
if len(nexus.Requests()) != 0 {
|
||||
t.Fatalf("bare verb reached Nexus: %+v", nexus.Requests())
|
||||
}
|
||||
if reply == "" {
|
||||
t.Fatal("bare act disappeared instead of staying in Maven's local lane")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnresolvedActNeverReachesNexusBeforeClarify(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_it", "It", "service"))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
dec := router.Decision{
|
||||
Utterance: "сделай это",
|
||||
Intent: router.IntentAct,
|
||||
Stage: 3,
|
||||
Clarify: true,
|
||||
Slots: router.Slots{Text: "сделай это"},
|
||||
}
|
||||
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
|
||||
t.Fatalf("unresolved act was answered by Hexis: %q", reply)
|
||||
}
|
||||
if len(nexus.Requests()) != 0 {
|
||||
t.Fatalf("unresolved act reached Nexus: %+v", nexus.Requests())
|
||||
}
|
||||
}
|
||||
|
||||
// nexusInOrder serves one resolve answer per call, in order, so a test can say
|
||||
// what Nexus knows about the first name and what it knows about the second. The
|
||||
// last body repeats once the list runs out.
|
||||
|
||||
@@ -106,7 +106,12 @@ func TestSystemSafetyScenarios(t *testing.T) {
|
||||
hexis := newFakeHexis(t, fixtureHexisCapabilities(map[string]any{"id": "restart", "name": "restart", "read_only": false}), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h, _ := newSafetyHandler(t)
|
||||
h.ecosystem = stubEcosystem(nexus.URL, hexis.URL)
|
||||
reply := h.applyAction(ctx, router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "indexer"}})
|
||||
// A matched function carries its entity target in Args. Text may be
|
||||
// model phrasing, but Args is the production matcher contract and the
|
||||
// ecosystem reach gate deliberately requires that evidence.
|
||||
reply := h.applyAction(ctx, router.Decision{Intent: router.IntentAct, Slots: router.Slots{
|
||||
Fn: "restart", HasFn: true, Args: []string{"indexer"}, Text: "indexer",
|
||||
}})
|
||||
if !strings.Contains(reply, "Indexer A") || !strings.Contains(reply, "Indexer B") {
|
||||
t.Fatalf("ambiguous entity must prompt for clarification, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
|
||||
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
matcher := tool.NewMatcher(api)
|
||||
rtr := buildRouter(emb, matcher, 0.55, nil)
|
||||
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
|
||||
@@ -37,8 +37,8 @@ type factEnrichmentWorker struct {
|
||||
nextTry map[int64]time.Time // fact id → earliest retry
|
||||
}
|
||||
|
||||
// enrichmentScanLimit bounds how deep a single tick (or status report) walks
|
||||
// the pending queue looking for facts whose backoff has elapsed. The queue is
|
||||
// enrichmentScanLimit bounds how deep a single tick walks the pending queue
|
||||
// looking for facts whose backoff has elapsed. The queue is
|
||||
// ordered by id, so without a scan the oldest facts hold every batch slot
|
||||
// whether or not they are eligible, and one permanently failing fact stalls
|
||||
// every younger one behind it.
|
||||
@@ -75,8 +75,8 @@ func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval tim
|
||||
// has been down all day must be visible as a backlog, not as facts that
|
||||
// silently never got tagged.
|
||||
//
|
||||
// All three numbers describe the same set of rows, the first
|
||||
// enrichmentScanLimit pending facts. Counting Pending over a thousand rows
|
||||
// All three numbers describe the same set of rows, whatever is still pending
|
||||
// out of the first enrichmentScanLimit facts. Counting Pending over a thousand rows
|
||||
// while counting InBackoff over the twenty that reached the head of a batch
|
||||
// described two different populations under one struct.
|
||||
type enrichmentStatus struct {
|
||||
@@ -86,13 +86,22 @@ type enrichmentStatus struct {
|
||||
Scanned int // rows the other three counts were taken over
|
||||
}
|
||||
|
||||
// status reads the queue and counts over it. For a caller with no batch in
|
||||
// hand — anything asking the worker how it is doing from outside the tick.
|
||||
func (w *factEnrichmentWorker) status(ctx context.Context) enrichmentStatus {
|
||||
var st enrichmentStatus
|
||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||
if err != nil {
|
||||
log.Printf("factenrichment: status: %v", err)
|
||||
return st
|
||||
return enrichmentStatus{}
|
||||
}
|
||||
return w.statusOf(pending)
|
||||
}
|
||||
|
||||
// statusOf counts over a batch the caller already has. The batch is the query
|
||||
// the tick already ran, so reporting the backlog costs no second read of the
|
||||
// scan limit — up to a thousand rows, on a database that serialises them.
|
||||
func (w *factEnrichmentWorker) statusOf(pending []store.Fact) enrichmentStatus {
|
||||
var st enrichmentStatus
|
||||
st.Pending = len(pending)
|
||||
st.Scanned = len(pending)
|
||||
w.mu.Lock()
|
||||
@@ -144,17 +153,24 @@ func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
||||
}
|
||||
w.forgetDeparted(pending)
|
||||
skipped, failed, attempted := 0, 0, 0
|
||||
// A resolved fact leaves the pending queue, so the batch in hand overstates
|
||||
// the backlog by however many succeeded. Drop them here rather than
|
||||
// re-reading the queue to find out.
|
||||
remaining := make([]store.Fact, 0, len(pending))
|
||||
for _, f := range pending {
|
||||
if attempted >= w.batch {
|
||||
break
|
||||
remaining = append(remaining, f)
|
||||
continue
|
||||
}
|
||||
if !w.due(f.ID) {
|
||||
skipped++
|
||||
remaining = append(remaining, f)
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
if !w.resolveOne(ctx, f) {
|
||||
failed++
|
||||
remaining = append(remaining, f)
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
@@ -164,7 +180,7 @@ func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
||||
// Report the backlog every tick, not only when something failed: the
|
||||
// stalled state worth seeing is the one where nothing failed because
|
||||
// nothing was attempted.
|
||||
if st := w.status(ctx); st.Pending > 0 {
|
||||
if st := w.statusOf(remaining); st.Pending > 0 {
|
||||
log.Printf("factenrichment: %d facts pending entity resolution, %d in backoff, worst attempt %d (scanned %d)",
|
||||
st.Pending, st.InBackoff, st.MaxAttempts, st.Scanned)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -20,7 +23,7 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
@@ -33,6 +36,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
|
||||
func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
searchH, seen := searchHandler(t,
|
||||
`{"answers":["Актуальная версия Go — 1.25."],"results":[]}`,
|
||||
http.StatusOK)
|
||||
h.search = searchH.search
|
||||
|
||||
reply := h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
@@ -50,11 +57,14 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("the question was indexed for recall: %+v", hits)
|
||||
}
|
||||
// It went down the query chain instead. Nothing is configured to answer a
|
||||
// world question in this harness, so "не знаю." is the honest outcome —
|
||||
// what matters is that the turn was answered, not stored.
|
||||
if reply == "" {
|
||||
t.Fatal("the turn was neither stored nor answered")
|
||||
// It went down the world query chain instead. This asserts the actual
|
||||
// destination, not merely that the write was refused: the regression was
|
||||
// the personal boundary claiming this question before search.
|
||||
if !strings.Contains(reply, "1.25") {
|
||||
t.Fatalf("reply = %q, want live world evidence", reply)
|
||||
}
|
||||
if !strings.Contains(*seen, "q="+url.QueryEscape("какая последняя версия языка Go?")) {
|
||||
t.Fatalf("search query = %q; world source was not reached verbatim", *seen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,3 +177,69 @@ func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
||||
t.Fatalf("an explicit capture was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A second tap of the same key supersedes the first, so recall must hold one
|
||||
// vector and it must be the new value (Vikunja #493). Before this the id
|
||||
// carried a timestamp, both rows stayed, and the superseded value went on
|
||||
// competing for the turn.
|
||||
func TestActionFact_ARetapSupersedesTheOldVector(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
||||
})
|
||||
// A later tap of the same key. The clock moves, so the old id and the new
|
||||
// one differ — which is exactly what used to leave two rows behind.
|
||||
h.now = func() time.Time { return time.Now().Add(time.Hour) }
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"500мл"`},
|
||||
})
|
||||
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("want one vector for the key, got %d: %+v", len(hits), hits)
|
||||
}
|
||||
if got := hits[0].Meta["text"]; got != "water — 500мл" {
|
||||
t.Errorf("indexed text = %q, want the current value", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Another key is not this key. A prefix delete that widened would take the
|
||||
// whole index with it.
|
||||
func TestActionFact_ARetapLeavesOtherKeysAlone(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я обедал",
|
||||
Slots: router.Slots{Key: "meal", HasKey: true, Value: `"суп"`},
|
||||
})
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"250мл"`},
|
||||
})
|
||||
|
||||
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "обед"), 5)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, hit := range hits {
|
||||
if hit.Meta["text"] == "meal — суп" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("writing water dropped the meal vector: %+v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +178,14 @@ func (fs *fakeServer) Requests() []capturedRequest {
|
||||
return out
|
||||
}
|
||||
|
||||
// ResetRequests drops the captured requests, so a test can assert about one
|
||||
// turn without subtracting the setup turn's calls.
|
||||
func (fs *fakeServer) ResetRequests() {
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
fs.requests = nil
|
||||
}
|
||||
|
||||
func jsonHandler(status int, body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -330,7 +338,17 @@ func newFakeNexus(t *testing.T, resolveBody string) *fakeServer {
|
||||
// Maven's praxisClient calls. Every route returns its fixed body until a
|
||||
// fault is injected via SetFault.
|
||||
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
|
||||
// One healthy source by default: an empty attention list only means
|
||||
// all-clear when something is actually polling (Vikunja #540), and the
|
||||
// other tests here are about attention rather than about source health.
|
||||
return newFakePraxisWithSources(t, attentionBody, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||
}
|
||||
|
||||
// newFakePraxisWithSources is newFakePraxis with the /api/v1/sources body
|
||||
// under the test's control, for the degraded and no-sources hedges.
|
||||
func newFakePraxisWithSources(t *testing.T, attentionBody, sourcesBody string) *fakeServer {
|
||||
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||
"GET /api/v1/sources": jsonHandler(http.StatusOK, sourcesBody),
|
||||
"GET /api/v1/tools/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
|
||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||
|
||||
+103
-11
@@ -5,13 +5,15 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// voiceDialogueID — the dialogue-session key for the microphone, and the
|
||||
// clarify key for it too. This is a single-user box (ponytail), so one slot
|
||||
// suffices; a second speaker would need per-speaker ids, which waits on
|
||||
// voice-print attribution (see PROGRESS multi-user deferral).
|
||||
// voiceDialogueID — the dialogue-session and clarify key for the microphone.
|
||||
// This is a single-user box (ponytail), so one slot per reach suffices; a
|
||||
// second speaker would need per-speaker ids, which waits on voice-print
|
||||
// attribution (see PROGRESS multi-user deferral).
|
||||
const voiceDialogueID = "voice"
|
||||
|
||||
// textDialogueID — the clarify key for a text turn that named no conversation.
|
||||
@@ -94,14 +96,18 @@ func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
||||
// anaphoraResolver is a shared instance for pronoun detection.
|
||||
var anaphoraResolver router.AnaphoraResolver
|
||||
|
||||
// followUpMerge fills the current turn's missing slots from a prior
|
||||
// non-expired session — the multi-turn seam. It handles three cases:
|
||||
// followUpMerge carries the current conversation across a prior non-expired
|
||||
// session — the multi-turn seam. It handles four cases:
|
||||
//
|
||||
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior).
|
||||
// 2. Cross-intent anaphora: if the current utterance contains a pronoun
|
||||
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior),
|
||||
// except a reminder time the current sentence named and the parser missed.
|
||||
// 2. An anaphoric query becomes chat. A question whose subject lives in this
|
||||
// conversation is answered from its transcript, not sent through unrelated
|
||||
// note, web and encyclopedia sources as a context-free lookup.
|
||||
// 3. Cross-intent anaphora: if the current utterance contains a pronoun
|
||||
// ("это" / "он" / "она" etc.) AND the prior session has a key, inherit
|
||||
// the key for fact-lookup queries and reminder creation.
|
||||
// 3. Query after Fact: a query that references the prior fact's subject
|
||||
// 4. Query after Fact: a query that references the prior fact's subject
|
||||
// inherits the key so the handler can do a fact-by-key lookup.
|
||||
//
|
||||
// A clarify turn resolves nothing, so it never inherits. InheritSlots only
|
||||
@@ -111,16 +117,56 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r
|
||||
return dec
|
||||
}
|
||||
|
||||
ref, isAnaphoric := anaphoraResolver.Resolve(dec.Utterance)
|
||||
if dec.Intent == router.IntentQuery && isAnaphoric && ref != "mine" && sessionHasContext(prev) {
|
||||
// The router correctly identified a question. What it cannot know from
|
||||
// one utterance is that its subject is in the live dialogue. Chat is the
|
||||
// only action path that receives that dialogue, so preserve the route's
|
||||
// slots but answer it there. Clear query-only provenance: no query source
|
||||
// was selected and an anchored destination must not survive an intent
|
||||
// change the daemon made from state the router could not see.
|
||||
dec.Intent = router.IntentChat
|
||||
dec.Source = router.SourceUnknown
|
||||
dec.SourceAnchored = false
|
||||
// Keep a structured referent when the prior route had one. The chat
|
||||
// phraser primarily reads the transcript, but the session must not lose
|
||||
// the fact identity merely because one follow-up crossed an intent.
|
||||
if !dec.Slots.HasKey && prev.Slots.HasKey {
|
||||
dec.Slots.Key = prev.Slots.Key
|
||||
dec.Slots.HasKey = true
|
||||
}
|
||||
if dec.Slots.Value == "" {
|
||||
dec.Slots.Value = prev.Slots.Value
|
||||
}
|
||||
if !dec.Slots.HasTime && prev.Slots.HasTime {
|
||||
dec.Slots.Time = prev.Slots.Time
|
||||
dec.Slots.HasTime = true
|
||||
}
|
||||
}
|
||||
|
||||
// Case 1: same-intent inheritance (existing).
|
||||
if prev.Intent == dialogue.Intent(dec.Intent) {
|
||||
// A reminder that named an hour nobody could read must not borrow the
|
||||
// last one's. Two reminders in a row and the second landed at the
|
||||
// first's time, confirmed as if it had been read from the sentence:
|
||||
// "напомни без четверти восемь выходить" fired at 07:30 (V-543). The
|
||||
// hour is also what fills before the action's own fallback parse can
|
||||
// run, so inheriting it hid a time that did parse.
|
||||
//
|
||||
// Inheriting is still right when the sentence names no time at all,
|
||||
// which is the follow-up this seam exists for.
|
||||
blockTime := dec.Intent == router.IntentReminder &&
|
||||
!dec.Slots.HasTime && router.MentionsTime(dec.Utterance)
|
||||
merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots))
|
||||
dec.Slots = applyDialogueSlots(dec.Slots, merged)
|
||||
if blockTime {
|
||||
dec.Slots.Time, dec.Slots.HasTime = time.Time{}, false
|
||||
}
|
||||
return dec
|
||||
}
|
||||
|
||||
// Cases 2 & 3: cross-intent anaphora + query-after-fact.
|
||||
// Cases 3 & 4: cross-intent anaphora + query-after-fact.
|
||||
// A query after a fact may reference the fact's subject by pronoun.
|
||||
_, isAnaphoric := anaphoraResolver.Resolve(dec.Utterance)
|
||||
if !isAnaphoric && !dec.Slots.HasKey {
|
||||
// No anaphora and no explicit key — this is a truly new topic.
|
||||
return dec
|
||||
@@ -146,3 +192,49 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r
|
||||
|
||||
return dec
|
||||
}
|
||||
|
||||
// sessionHasContext distinguishes a live transcript from a session that only
|
||||
// carries timing/candidate bookkeeping. The raw utterance is the primary
|
||||
// source. The slot fallback keeps sessions persisted by older binaries useful
|
||||
// after an upgrade: those blobs have no Utterance field, but may still carry
|
||||
// the exact turn in Text or a structured fact key/value.
|
||||
func sessionHasContext(s *dialogue.Session) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
return s.Utterance != "" || s.Slots.Text != "" || s.Slots.HasKey || s.Slots.Value != ""
|
||||
}
|
||||
|
||||
// opensConversation recognises an explicit cooperative opener without making
|
||||
// it a competing route. The substantive clause after the colon may still be a
|
||||
// fact worth storing; this function only chooses the session's lifetime.
|
||||
//
|
||||
// The marker is grammatical and closed (Russian давай/давайте, English let's),
|
||||
// and the action vocabulary lives in lexicon rather than a substring pattern.
|
||||
// A bare chat route needs none of this — rememberTurn marks it conversational
|
||||
// from its intent. This catches the compound shape whose fact clause otherwise
|
||||
// hides the opener from the single-intent router.
|
||||
func opensConversation(text string) bool {
|
||||
tokens := quietTokens(text)
|
||||
if len(tokens) < 2 {
|
||||
return false
|
||||
}
|
||||
from := 1
|
||||
switch {
|
||||
case tokens[0] == "давай" || tokens[0] == "давайте":
|
||||
case tokens[0] == "lets":
|
||||
case len(tokens) >= 3 && tokens[0] == "let" && tokens[1] == "s":
|
||||
from = 2
|
||||
default:
|
||||
return false
|
||||
}
|
||||
verbs := lexicon.ConversationVerbs()
|
||||
for _, token := range tokens[from:] {
|
||||
for _, verb := range verbs {
|
||||
if token == verb || morph.SameWord(token, verb) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -35,6 +35,42 @@ func TestFollowUpMerge(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
// V-543, measured on the box: four reminders in a row all landed at the
|
||||
// first one's hour, each confirmed as if it had been read from the sentence.
|
||||
// A sentence that names a time and fails to parse must ask, not borrow.
|
||||
t.Run("a named time that did not parse is not inherited", func(t *testing.T) {
|
||||
for _, utt := range []string{
|
||||
"напомни без четверти восемь выходить",
|
||||
"напомни в половине первого пообедать",
|
||||
"напомни завтра принять лекарство",
|
||||
"remind me at noon to stretch",
|
||||
} {
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentReminder,
|
||||
Utterance: utt,
|
||||
Slots: router.Slots{Text: utt},
|
||||
}
|
||||
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
||||
if got.Slots.HasTime {
|
||||
t.Errorf("%q borrowed the previous hour %v", utt, got.Slots.Time)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The follow-up this seam exists for still works: the sentence names no
|
||||
// time, so the previous one is the only one it could mean.
|
||||
t.Run("a follow-up naming no time still inherits", func(t *testing.T) {
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentReminder,
|
||||
Utterance: "и ещё полить цветы",
|
||||
Slots: router.Slots{Text: "полить цветы"},
|
||||
}
|
||||
got := followUpMerge(prev, cur, base.Add(30*time.Second))
|
||||
if !got.Slots.HasTime || !got.Slots.Time.Equal(fireAt) {
|
||||
t.Errorf("time not inherited: HasTime=%v Time=%v", got.Slots.HasTime, got.Slots.Time)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("current slot wins over prior (gaps only)", func(t *testing.T) {
|
||||
own := base.Add(48 * time.Hour)
|
||||
cur := router.Decision{
|
||||
@@ -102,6 +138,7 @@ func TestFollowUpMerge(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentFact,
|
||||
Slots: dialogue.Slots{Key: "water", HasKey: true},
|
||||
Utterance: "я выпил воду",
|
||||
Timestamp: base,
|
||||
TTL: 2 * time.Minute,
|
||||
}
|
||||
@@ -116,6 +153,72 @@ func TestFollowUpMerge(t *testing.T) {
|
||||
if got.Slots.Key != "water" {
|
||||
t.Errorf("query after fact: got key=%q, want water", got.Slots.Key)
|
||||
}
|
||||
if got.Intent != router.IntentChat {
|
||||
t.Errorf("anaphoric query intent = %s, want chat with dialogue context", got.Intent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anaphoric query after unkeyed query uses raw dialogue context", func(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentQuery,
|
||||
Slots: dialogue.Slots{Text: "кто изобрёл телефон?"},
|
||||
Utterance: "кто изобрёл телефон?",
|
||||
Timestamp: base,
|
||||
TTL: 2 * time.Minute,
|
||||
}
|
||||
cur := router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: "а когда он это сделал?",
|
||||
Source: router.SourceWorld,
|
||||
SourceAnchored: true,
|
||||
}
|
||||
got := followUpMerge(prior, cur, base.Add(30*time.Second))
|
||||
if got.Intent != router.IntentChat {
|
||||
t.Fatalf("intent = %s, want chat", got.Intent)
|
||||
}
|
||||
if got.Source != router.SourceUnknown || got.SourceAnchored {
|
||||
t.Errorf("query-only source survived contextual chat: source=%s anchored=%v", got.Source, got.SourceAnchored)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anaphora without a usable prior session stays routed", func(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentQuery,
|
||||
Timestamp: base,
|
||||
TTL: 2 * time.Minute,
|
||||
}
|
||||
cur := router.Decision{Intent: router.IntentQuery, Utterance: "что это?"}
|
||||
got := followUpMerge(prior, cur, base.Add(30*time.Second))
|
||||
if got.Intent != router.IntentQuery {
|
||||
t.Errorf("empty session changed intent to %s", got.Intent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("possessive determiner does not turn an explicit query into chat", func(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentChat, Utterance: "привет",
|
||||
Timestamp: base, TTL: 2 * time.Minute,
|
||||
}
|
||||
cur := router.Decision{Intent: router.IntentQuery, Utterance: "где мой телефон?"}
|
||||
got := followUpMerge(prior, cur, base.Add(30*time.Second))
|
||||
if got.Intent != router.IntentQuery {
|
||||
t.Errorf("explicit possessive query changed intent to %s", got.Intent)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("anaphoric act is never widened into chat", func(t *testing.T) {
|
||||
prior := &dialogue.Session{
|
||||
Intent: dialogue.IntentChat, Utterance: "сервер homesrv",
|
||||
Timestamp: base, TTL: 2 * time.Minute,
|
||||
}
|
||||
cur := router.Decision{Intent: router.IntentAct, Utterance: "выключи его"}
|
||||
got := followUpMerge(prior, cur, base.Add(30*time.Second))
|
||||
if got.Intent != router.IntentAct {
|
||||
t.Errorf("act intent changed to %s", got.Intent)
|
||||
}
|
||||
if got.Slots.HasFn {
|
||||
t.Error("anaphora invented an executable function")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("query after fact without anaphora does not inherit", func(t *testing.T) {
|
||||
|
||||
+40
-7
@@ -54,30 +54,56 @@ var historyMarkersEn = [][2]string{
|
||||
// answers a topic far better than a list of the last five facts does.
|
||||
var historyRecall = []string{" про ", " об ", " о ", " about "}
|
||||
|
||||
// historySide — whose turn the question asks about. The rows read are the same
|
||||
// either way, because a tapped fact is one act seen from two sides, but the
|
||||
// sentence is not: answering "что ты записала сегодня?" with "ты говорил…"
|
||||
// hands the question back instead of answering it (Vikunja #456).
|
||||
type historySide int
|
||||
|
||||
const (
|
||||
historyAskedHim historySide = iota // "что я тебе говорил"
|
||||
historyAskedHer // "что ты записала сегодня"
|
||||
)
|
||||
|
||||
// isHistoryQuery reports whether he is asking what he told her.
|
||||
func isHistoryQuery(u string) bool {
|
||||
_, ok := historyAsks(u)
|
||||
return ok
|
||||
}
|
||||
|
||||
// historyAsks reports whether this is a history question, and whose turn it is
|
||||
// about.
|
||||
func historyAsks(u string) (historySide, bool) {
|
||||
s := " " + strings.ToLower(strings.TrimSpace(u)) + " "
|
||||
if s == " " {
|
||||
return false
|
||||
return historyAskedHim, false
|
||||
}
|
||||
for _, r := range historyRecall {
|
||||
if strings.Contains(s, r) {
|
||||
return false
|
||||
return historyAskedHim, false
|
||||
}
|
||||
}
|
||||
for _, pair := range historyMarkersEn {
|
||||
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||
return true
|
||||
if strings.Contains(pair[0], "you") {
|
||||
return historyAskedHer, true
|
||||
}
|
||||
return historyAskedHim, true
|
||||
}
|
||||
}
|
||||
toks := historyTokens(s)
|
||||
if !hasAny(toks, "что", "чего") {
|
||||
return false
|
||||
return historyAskedHim, false
|
||||
}
|
||||
// His side is tested first: "отмечать" is on both verb lists, so "что я
|
||||
// отметил" must not read as a question about her.
|
||||
if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) {
|
||||
return true
|
||||
return historyAskedHim, true
|
||||
}
|
||||
return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs)
|
||||
if hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) {
|
||||
return historyAskedHer, true
|
||||
}
|
||||
return historyAskedHim, false
|
||||
}
|
||||
|
||||
// historyTokens splits an utterance into bare words. The punctuation goes
|
||||
@@ -142,7 +168,8 @@ const historyWindow = 24 * time.Hour
|
||||
// pass: the notes pass would otherwise answer this from whatever note happens
|
||||
// to be nearest, which reads as an answer and is not one.
|
||||
func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !isHistoryQuery(t.dec.Utterance) {
|
||||
side, ok := historyAsks(t.dec.Utterance)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
facts, err := h.api.RecentFacts(ctx, historyScan)
|
||||
@@ -164,8 +191,14 @@ func (h *reactiveHandler) queryHistory(ctx context.Context, t *queryTurn) (strin
|
||||
if len(said) == 0 {
|
||||
// Claim the turn rather than fall through. "ничего не говорил" is the
|
||||
// true answer, and recall would answer it with an old note instead.
|
||||
if side == historyAskedHer {
|
||||
return "за последние сутки я ничего с твоих слов не записывала.", true
|
||||
}
|
||||
return "за последние сутки ты мне ничего такого не говорил.", true
|
||||
}
|
||||
if side == historyAskedHer {
|
||||
return "я записала: " + strings.Join(said, "; "), true
|
||||
}
|
||||
return "ты говорил: " + strings.Join(said, "; "), true
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,33 @@ func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The rows are the same either way, because a tapped fact is one act seen from
|
||||
// two sides. The sentence is not: "что ты записала" answered with "ты говорил"
|
||||
// hands the question back (Vikunja #456).
|
||||
func TestHistoryAnswersTheSideItWasAsked(t *testing.T) {
|
||||
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||
h, _ := historyHandler(now, ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)})
|
||||
|
||||
his, ok := askHistory(h, "что я тебе говорил?")
|
||||
if !ok || !strings.HasPrefix(his, "ты говорил") {
|
||||
t.Errorf("reply = %q, ok = %v, want his side", his, ok)
|
||||
}
|
||||
hers, ok := askHistory(h, "что ты записала сегодня?")
|
||||
if !ok || !strings.HasPrefix(hers, "я записала") {
|
||||
t.Errorf("reply = %q, ok = %v, want her side", hers, ok)
|
||||
}
|
||||
// "отмечать" is on both verb lists, so his subject has to win.
|
||||
if side, ok := historyAsks("что я отметил?"); !ok || side != historyAskedHim {
|
||||
t.Errorf("historyAsks(что я отметил) = %v, %v", side, ok)
|
||||
}
|
||||
|
||||
empty, _ := historyHandler(now)
|
||||
none, ok := askHistory(empty, "что ты записала сегодня?")
|
||||
if !ok || !strings.Contains(none, "не записывала") {
|
||||
t.Errorf("empty reply = %q, ok = %v, want her side", none, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing said is an answer of its own. Falling through would hand the question
|
||||
// to recall, which answers it with an old note.
|
||||
func TestHistorySaysWhenThereIsNothing(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasCyrillic(t *testing.T) {
|
||||
for _, s := range []string{"что такое фотосинтез", "кто такой Elon Musk", "фотосинтез"} {
|
||||
if !hasCyrillic(s) {
|
||||
t.Errorf("hasCyrillic(%q) = false; it is a Russian question", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{"what is photosynthesis", "", "3:2"} {
|
||||
if hasCyrillic(s) {
|
||||
t.Errorf("hasCyrillic(%q) = true; there is no Cyrillic in it", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The book choice and the rewrite decision are the same decision: a Russian
|
||||
// book reads his question as he asked it, an English one needs it translated
|
||||
// into keywords first (V-508).
|
||||
func TestKiwixBookChoice(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
wiring kiwixWiring
|
||||
utterance string
|
||||
wantBook string
|
||||
wantVerb bool
|
||||
}{
|
||||
{"a russian question reads the russian book verbatim",
|
||||
kiwixWiring{book: "en", bookRU: "ru"}, "что такое фотосинтез", "ru", true},
|
||||
{"an english question reads the english book",
|
||||
kiwixWiring{book: "en", bookRU: "ru"}, "what is photosynthesis", "en", false},
|
||||
{"no russian book configured leaves every question on the english one",
|
||||
kiwixWiring{book: "en"}, "что такое фотосинтез", "en", false},
|
||||
} {
|
||||
book, verbatim := tc.wiring.book, false
|
||||
if tc.wiring.bookRU != "" && hasCyrillic(tc.utterance) {
|
||||
book, verbatim = tc.wiring.bookRU, true
|
||||
}
|
||||
if book != tc.wantBook || verbatim != tc.wantVerb {
|
||||
t.Errorf("%s: book=%q verbatim=%v, want %q/%v", tc.name, book, verbatim, tc.wantBook, tc.wantVerb)
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -19,8 +19,12 @@ type kiwixWiring struct {
|
||||
client *kiwix.Client
|
||||
rewriter *kiwix.Rewriter // nil ⇒ the question is searched verbatim
|
||||
book string
|
||||
max int
|
||||
runes int
|
||||
// bookRU — searched instead of book when the question is Cyrillic, and
|
||||
// searched verbatim because it is in his language already (V-508). Empty ⇒
|
||||
// every question goes to book.
|
||||
bookRU string
|
||||
max int
|
||||
runes int
|
||||
}
|
||||
|
||||
// wireKiwix builds the ZIM reader from the `kiwix` block, or returns nil when
|
||||
@@ -38,9 +42,13 @@ func wireKiwix(cfg *config.Config, c *llm.Client) *kiwixWiring {
|
||||
w := &kiwixWiring{
|
||||
client: kiwix.New(kc.URL),
|
||||
book: kc.Book,
|
||||
bookRU: kc.BookRU,
|
||||
max: kc.MaxResults,
|
||||
runes: kc.SnippetRunes,
|
||||
}
|
||||
if kc.BookRU != "" {
|
||||
log.Printf("voice: kiwix: russian questions read %q verbatim", kc.BookRU)
|
||||
}
|
||||
switch {
|
||||
case !kc.RewriteEnabled():
|
||||
log.Printf("voice: kiwix at %s (book %q, query rewriting off by config)", kc.URL, kc.Book)
|
||||
|
||||
+196
-286
@@ -68,6 +68,11 @@ import (
|
||||
"github.com/kami/maven/internal/webauthn"
|
||||
)
|
||||
|
||||
// stepUpTTL is how long one passkey assertion keeps the session stepped up.
|
||||
// Long enough for the unlock call that follows it, short enough that a walked
|
||||
// away laptop does not stay authorized.
|
||||
const stepUpTTL = 5 * time.Minute
|
||||
|
||||
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||
|
||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||
@@ -128,6 +133,8 @@ func run(args []string) error {
|
||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
||||
allowSeed := flag.Bool("allow-seed", false, "enable the backdated seed_event write path (QA only: it lets a caller place a fact in the past and mint a routine the tick loop will then act on; off means the method has nothing to write with)")
|
||||
wipe := flag.Bool("wipe", false, "print every table and its row count, then exit without serving; add -confirm-wipe to delete all of it")
|
||||
confirmWipe := flag.Bool("confirm-wipe", false, "with -wipe, actually remove every piece of personal data (facts, notes, vectors, events, tasks, sessions, traces, voiceprints). config, models, passkeys and the encryption key are files and survive")
|
||||
flag.CommandLine.Parse(args)
|
||||
reembedOnStart = *reembed
|
||||
allowSeedOnStart = *allowSeed
|
||||
@@ -210,6 +217,14 @@ func run(args []string) error {
|
||||
}()
|
||||
}
|
||||
|
||||
// ----- wipe: never serves, exits when it is done (Vikunja #494) -----
|
||||
if *wipe {
|
||||
if locked {
|
||||
return fmt.Errorf("wipe: the store is locked and there is no key to open it with")
|
||||
}
|
||||
return runWipe(ctx, st, os.Stdout, *confirmWipe)
|
||||
}
|
||||
|
||||
// ----- daemon components (only wired when unlocked) -----
|
||||
// Pre-declare so the unlock path can wire them later.
|
||||
var (
|
||||
@@ -237,44 +252,31 @@ func run(args []string) error {
|
||||
// envelope per successful intake write.
|
||||
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
||||
|
||||
// depsNow reads whatever the current path has wired. Both boot paths build
|
||||
// the CoreAPI and start the workers from this one value, so neither can
|
||||
// hold a field the other misses. See cmd/mavend/boot.go.
|
||||
depsNow := func() bootDeps {
|
||||
return bootDeps{
|
||||
coreFor: coreFor,
|
||||
tl: tl,
|
||||
evBus: evBus,
|
||||
voiceW: voiceW,
|
||||
st: st,
|
||||
factWorker: factWorker,
|
||||
evalWorker: evalWorker,
|
||||
feedWkr: feedWkr,
|
||||
crawlWkr: crawlWkr,
|
||||
}
|
||||
}
|
||||
|
||||
if !locked {
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
gatherer = wireGatherer(st, cfg, rules)
|
||||
|
||||
// phraser
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = "127.0.0.1:0"
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = 2048
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = 30 * time.Second
|
||||
}
|
||||
var err error
|
||||
phr, err = phraser.NewLLMPhraser(ctx, pc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
phr, err = wirePhraser(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
|
||||
// ecosystem — nexus + hexis + praxis (all over HTTP; no direct DB access)
|
||||
@@ -287,71 +289,19 @@ func run(args []string) error {
|
||||
}
|
||||
|
||||
// delivery
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
// A crashed prior run may have left "pending" delivery attempts (send
|
||||
// may have landed externally, then the process died before recording
|
||||
// it) — reconcile them to "unknown" before the tick loop resumes
|
||||
// sending, so nothing auto-resends into that ambiguity.
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
dispatcher = delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
})
|
||||
|
||||
// tick loop
|
||||
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), cfg.PatternProposals)
|
||||
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
coreAPI = &daemonAPI{
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
api := coreAPI.(*daemonAPI)
|
||||
api.chatFn = voiceW.handler.handleText
|
||||
// And the reverse: the handler was wired with the bare store
|
||||
// adapter, which cannot serve the day plan. See upgradeAPI.
|
||||
voiceW.handler.upgradeAPI(api)
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
|
||||
}
|
||||
coreAPI = newDaemonAPI(depsNow())
|
||||
} else {
|
||||
// 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
|
||||
@@ -368,7 +318,7 @@ func run(args []string) error {
|
||||
return fmt.Errorf("ipc listen: %w", err)
|
||||
}
|
||||
|
||||
passkeySess := webauthn.NewPasskeySession(5 * time.Minute)
|
||||
passkeySess := webauthn.NewPasskeySession(stepUpTTL)
|
||||
|
||||
// Set Server.Check — the single authorization guard, run once by
|
||||
// Server.dispatch before any CoreAPI method is called (see
|
||||
@@ -412,6 +362,9 @@ func run(args []string) error {
|
||||
if !locked {
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
// Inbound telegram (V-637). Dark unless the telegram block says intake,
|
||||
// and it reads one chat.
|
||||
wireTelegramIntake(ctx, &wg, coreAPI, cfg)
|
||||
// Vision + the media blob store (Vikunja #252). Both stay dark without a
|
||||
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
@@ -516,40 +469,11 @@ func run(args []string) error {
|
||||
|
||||
// Wire everything.
|
||||
rules = wireRules(cfg)
|
||||
gatherer = loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
gatherer = wireGatherer(st, cfg, rules)
|
||||
|
||||
phr = phraser.NewStub()
|
||||
if cfg.Phraser != nil {
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = "llama-server"
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = "127.0.0.1:0"
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = 2048
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = 30 * time.Second
|
||||
}
|
||||
phr, err = phraser.NewLLMPhraser(ctx, pc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
phr, err = wirePhraser(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("phraser: %w", err)
|
||||
}
|
||||
|
||||
eco = wireEcosystem(cfg)
|
||||
@@ -559,65 +483,26 @@ func run(args []string) error {
|
||||
return fmt.Errorf("wire voice: %w", err)
|
||||
}
|
||||
|
||||
var ntfy delivery.Sink
|
||||
if cfg.Ntfy != nil {
|
||||
s, err := ntfysink.New(*cfg.Ntfy)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
ntfy = s
|
||||
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
dispatcher = delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
})
|
||||
|
||||
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), cfg.PatternProposals)
|
||||
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||
newAPI := &daemonAPI{
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
voiceW.handler.upgradeAPI(newAPI)
|
||||
}
|
||||
newAPI := newDaemonAPI(depsNow())
|
||||
srv.SetAPI(newAPI)
|
||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
// Same on the unlock path, with the API that has just replaced the
|
||||
// locked placeholder (V-637).
|
||||
wireTelegramIntake(ctx, &wg, newAPI, cfg)
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
@@ -625,59 +510,10 @@ func run(args []string) error {
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
|
||||
// Start voice server.
|
||||
if voiceW != nil {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("voice serve: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
|
||||
}
|
||||
|
||||
// Start tick loop.
|
||||
go func() {
|
||||
tl.run(ctx)
|
||||
}()
|
||||
|
||||
// Start fact-entity enrichment worker.
|
||||
go func() {
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
|
||||
// Start background memory evaluation (nil unless configured).
|
||||
if evalWorker != nil {
|
||||
go func() {
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Start feed reading (nil unless configured).
|
||||
if feedWkr != nil {
|
||||
go func() {
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Start the watched-page crawls (nil unless configured).
|
||||
if crawlWkr != nil {
|
||||
go func() {
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Keep MCP connections alive (nil unless configured).
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
go voiceW.mcp.run(ctx)
|
||||
}
|
||||
|
||||
// Re-enumerate the house for new devices (nil unless configured).
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
go voiceW.home.run(ctx)
|
||||
}
|
||||
// The voice server and every background worker, on the outer wg
|
||||
// so shutdown waits for them. This used to be nine bare
|
||||
// `go func()` calls and a shadowed WaitGroup (V-639).
|
||||
startBackground(ctx, &wg, depsNow())
|
||||
|
||||
dl.unlock(st)
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
@@ -685,72 +521,15 @@ func run(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
goWorker(&wg, func() {
|
||||
if err := srv.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("ipc serve: %v", err)
|
||||
}
|
||||
}()
|
||||
})
|
||||
log.Printf("mavend: ipc listening on %s", srv.Path())
|
||||
|
||||
if !locked && voiceW != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("voice serve: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
|
||||
}
|
||||
|
||||
if !locked {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tl.run(ctx)
|
||||
}()
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
if evalWorker != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
if feedWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
if crawlWkr != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.mcp.run(ctx)
|
||||
}()
|
||||
}
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
voiceW.home.run(ctx)
|
||||
}()
|
||||
}
|
||||
startBackground(ctx, &wg, depsNow())
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
@@ -780,7 +559,7 @@ func run(args []string) error {
|
||||
func personaFacts(cfg *config.Config) persona.Facts {
|
||||
f := persona.Facts{
|
||||
// Telegram lives outside the voice block, so it counts either way.
|
||||
Telegram: cfg.Telegram != nil && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
Telegram: cfg.Telegram != nil && !cfg.Telegram.Disabled && cfg.Telegram.BotToken != "" && cfg.Telegram.ChatID != "",
|
||||
}
|
||||
if cfg.Voice == nil {
|
||||
return f
|
||||
@@ -837,6 +616,137 @@ func waitWorkers(wg *sync.WaitGroup, d time.Duration) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Phraser defaults, applied when the config block leaves a field unset. They
|
||||
// are the daemon's, not the library's: phraser.Config carries no defaults of
|
||||
// its own, so an empty field here would reach llama-server as an empty flag.
|
||||
const (
|
||||
defaultLlamaBin = "llama-server"
|
||||
defaultPhraserListen = "127.0.0.1:0"
|
||||
defaultPhraserNCtx = 2048
|
||||
defaultPhraserTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// wirePhraser builds the phrasing seam. No phraser block means the
|
||||
// deterministic stub, which is the floor and not an error: the daemon answers
|
||||
// without a model, in fixed words.
|
||||
func wirePhraser(ctx context.Context, cfg *config.Config) (phraser.Phraser, error) {
|
||||
if cfg.Phraser == nil {
|
||||
return phraser.NewStub(), nil
|
||||
}
|
||||
pc := phraser.Config{
|
||||
ModelPath: cfg.Phraser.ModelPath,
|
||||
BinPath: cfg.Phraser.BinPath,
|
||||
Listen: cfg.Phraser.Listen,
|
||||
NGpuLayers: cfg.Phraser.NGpuLayers,
|
||||
NCtx: cfg.Phraser.NCtx,
|
||||
CacheRAMMiB: cacheRAMMiB(cfg.Phraser.CacheRAMMiB),
|
||||
Timeout: time.Duration(cfg.Phraser.Timeout),
|
||||
LLMNudges: cfg.Phraser.LLMNudges,
|
||||
ContextBlock: contextBlockFn(cfg, time.Now),
|
||||
}
|
||||
if pc.BinPath == "" {
|
||||
pc.BinPath = defaultLlamaBin
|
||||
}
|
||||
if pc.Listen == "" {
|
||||
pc.Listen = defaultPhraserListen
|
||||
}
|
||||
if pc.NCtx <= 0 {
|
||||
pc.NCtx = defaultPhraserNCtx
|
||||
}
|
||||
if pc.Timeout <= 0 {
|
||||
pc.Timeout = defaultPhraserTimeout
|
||||
}
|
||||
return phraser.NewLLMPhraser(ctx, pc)
|
||||
}
|
||||
|
||||
// wireGatherer builds the nudge gatherer over the given rule set and applies
|
||||
// the configured quiet hours.
|
||||
func wireGatherer(st *store.Store, cfg *config.Config, rules []loop.Rule) *loop.Gatherer {
|
||||
g := loop.NewGatherer(st, rules)
|
||||
if cfg.QuietHours != nil {
|
||||
g.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// wireDispatcher builds the delivery fan-out. Each sink stays nil unless its
|
||||
// config block is present, and a sink that fails to build fails the boot
|
||||
// rather than going quiet.
|
||||
//
|
||||
// A crashed prior run may have left "pending" delivery attempts (send may have
|
||||
// landed externally, then the process died before recording it). They are
|
||||
// reconciled to "unknown" here, before the tick loop resumes sending, so
|
||||
// nothing auto-resends into that ambiguity.
|
||||
func wireDispatcher(st *store.Store, cfg *config.Config, voiceW *voiceWiring) (*delivery.Dispatcher, error) {
|
||||
ntfy, err := wireNtfySink(cfg.Ntfy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var telegram delivery.Sink
|
||||
if cfg.Telegram != nil && !cfg.Telegram.Disabled {
|
||||
s, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire telegram sink: %w", err)
|
||||
}
|
||||
telegram = s
|
||||
}
|
||||
var voiceSink delivery.Sink
|
||||
if voiceW != nil {
|
||||
voiceSink = voiceW.voiceSink
|
||||
}
|
||||
if _, err := st.ReconcileStaleDeliveryAttempts(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("delivery outbox reconcile: %v", err)
|
||||
}
|
||||
return delivery.NewDispatcher(delivery.Config{
|
||||
Ntfy: ntfy,
|
||||
Telegram: telegram,
|
||||
Voice: voiceSink,
|
||||
Ack: st,
|
||||
Nudges: st,
|
||||
Reminders: st,
|
||||
Outbox: st,
|
||||
}), nil
|
||||
}
|
||||
|
||||
// wireNtfySink keeps an optional reach optional without ever turning a missing
|
||||
// secret into anonymous publishing. A block is live unless it says disabled;
|
||||
// therefore an expanded-empty token in a live block fails startup instead of
|
||||
// spending days in a permanent 403 retry loop. Disabled is an explicit
|
||||
// operator choice and lets another away reach take over.
|
||||
func wireNtfySink(cfg *ntfysink.Config) (delivery.Sink, error) {
|
||||
if cfg == nil || cfg.Disabled {
|
||||
return nil, nil
|
||||
}
|
||||
sink, err := ntfysink.New(*cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wire ntfy sink: %w", err)
|
||||
}
|
||||
return sink, nil
|
||||
}
|
||||
|
||||
// wireTickLoop reads the loop's three intervals and its schedules out of the
|
||||
// config, so the two boot paths cannot disagree about them.
|
||||
func wireTickLoop(st *store.Store, gatherer *loop.Gatherer, dispatcher *delivery.Dispatcher, phr phraser.Phraser, rules []loop.Rule, cfg *config.Config) *tickLoop {
|
||||
return newTickLoop(st, gatherer, dispatcher, phr, rules,
|
||||
time.Duration(cfg.TickInterval),
|
||||
time.Duration(cfg.RepeatInterval),
|
||||
time.Duration(cfg.AutotuneInterval),
|
||||
cfg.Digest,
|
||||
routinesFromConfig(cfg.Routines),
|
||||
config.MorningRoutinesFromConfig(cfg.MorningRoutines),
|
||||
cfg.PatternProposals)
|
||||
}
|
||||
|
||||
// goWorker starts run on its own goroutine and registers it with wg, so
|
||||
// shutdown can wait for it inside workerGrace.
|
||||
func goWorker(wg *sync.WaitGroup, run func()) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
run()
|
||||
}()
|
||||
}
|
||||
|
||||
// wireRules builds the nudge rule set, minus anything config turned off. The
|
||||
// drop is logged because a rule vanishing silently is indistinguishable from a
|
||||
// rule that is broken, and the next person to wonder why she stopped nudging
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// correctionFragment reports that an utterance replaces a referent and states
|
||||
// nothing of its own: "нет, не маме, а папе" (V-576).
|
||||
//
|
||||
// Measured on the box 2026-08-06, that fragment routed to note and was filed.
|
||||
// It is not a repair either, because it names no intent, so parseRepair
|
||||
// declines it and repair.go is the wrong place to catch it. This is the note
|
||||
// path saying it has nothing to store.
|
||||
//
|
||||
// Three offline tests, all of them narrow on purpose. The sentence opens with a
|
||||
// refusal word from the lexicon, it carries the contrastive "а" that names the
|
||||
// replacement, and no token in it is a verb form. The verb test is what keeps
|
||||
// the rule off real notes: "нет, я не поеду, а останусь" says something, and a
|
||||
// Russian verb carries its own subject and tense.
|
||||
func correctionFragment(utterance string) bool {
|
||||
toks := repairTokens(strings.ToLower(strings.TrimSpace(utterance)))
|
||||
if len(toks) < 3 {
|
||||
return false
|
||||
}
|
||||
if !refusalWord(toks[0]) {
|
||||
return false
|
||||
}
|
||||
var negated, contrasted bool
|
||||
for _, tok := range toks[1:] {
|
||||
switch tok {
|
||||
case "не", "not":
|
||||
negated = true
|
||||
case "а", "but":
|
||||
contrasted = true
|
||||
}
|
||||
if morph.IsVerbForm(tok) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return negated && contrasted
|
||||
}
|
||||
|
||||
// refusalWord reports that a token is a one-word refusal. The lexicon set holds
|
||||
// phrases too ("не надо"), and those are not what opens a correction.
|
||||
func refusalWord(tok string) bool {
|
||||
for _, w := range lexicon.ConfirmNo() {
|
||||
if strings.ContainsRune(w, ' ') {
|
||||
continue
|
||||
}
|
||||
if w == tok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
func TestCorrectionFragment(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want bool
|
||||
}{
|
||||
{"нет, не маме, а папе", true},
|
||||
{"Нет, не маме — а папе", true},
|
||||
{"no, not mom, but dad", true},
|
||||
// States something of its own, so it is his to keep.
|
||||
{"нет, я не поеду, а останусь дома", false},
|
||||
{"нет", false},
|
||||
{"не маме, а папе", false}, // no refusal word opening it
|
||||
{"нет, маме и папе", false}, // nothing negated
|
||||
{"нет, не маме", false}, // nothing put in its place
|
||||
{"запомни что кофе закончился", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := correctionFragment(c.utterance); got != c.want {
|
||||
t.Errorf("correctionFragment(%q) = %v, want %v", c.utterance, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newNoteHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Now()
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestNoteBodyIsTheUtterance — the stored body comes from the utterance, never
|
||||
// from Slots.Text, which the LLM router is free to write anything into (V-576).
|
||||
func TestNoteBodyIsTheUtterance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentNote,
|
||||
Utterance: "купил хлеб и молоко",
|
||||
Slots: router.Slots{Text: "ты поедешь на дачу"},
|
||||
}
|
||||
if reply := h.applyAction(ctx, dec); reply != "" {
|
||||
t.Fatalf("applyAction = %q, want empty", reply)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 || notes[0].Text != dec.Utterance {
|
||||
t.Fatalf("stored note = %+v, want body %q", notes, dec.Utterance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoteBodyIsStable — the same utterance twice stores the same text.
|
||||
func TestNoteBodyIsStable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{Intent: router.IntentNote, Utterance: "кофе закончился"}
|
||||
h.applyAction(ctx, dec)
|
||||
h.applyAction(ctx, dec)
|
||||
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 2 {
|
||||
t.Fatalf("notes = %d, want 2", len(notes))
|
||||
}
|
||||
if notes[0].Text != notes[1].Text || notes[0].Text != dec.Utterance {
|
||||
t.Fatalf("bodies differ: %q vs %q", notes[0].Text, notes[1].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectionFragmentWritesNoNote — a correction with nothing behind it is
|
||||
// not a note, and she says so instead of filing it (V-576).
|
||||
func TestCorrectionFragmentWritesNoNote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{Intent: router.IntentNote, Utterance: "нет, не маме, а папе"}
|
||||
if reply := h.applyAction(ctx, dec); reply != nothingToCorrectReply {
|
||||
t.Fatalf("reply = %q, want %q", reply, nothingToCorrectReply)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %+v, want none", notes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||
)
|
||||
|
||||
func TestWireNtfySinkRejectsMissingCredentialWhenEnabled(t *testing.T) {
|
||||
_, err := wireNtfySink(&ntfysink.Config{
|
||||
BaseURL: "https://ntfy.example", Topic: "maven",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expanded-empty credential did not fail an enabled reach")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireNtfySinkLeavesExplicitlyDisabledReachDark(t *testing.T) {
|
||||
sink, err := wireNtfySink(&ntfysink.Config{
|
||||
Disabled: true, BaseURL: "https://ntfy.example", Topic: "maven",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("wireNtfySink: %v", err)
|
||||
}
|
||||
if sink != nil {
|
||||
t.Fatal("disabled reach built a live sink")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWireNtfySinkRejectsMalformedEnabledConfig(t *testing.T) {
|
||||
_, err := wireNtfySink(&ntfysink.Config{Token: "token", Topic: "maven"})
|
||||
if err == nil {
|
||||
t.Fatal("malformed enabled config did not fail wiring")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestMain holds one ONNX Runtime lease across the model-aware topic and
|
||||
// personal-boundary gates. Each test still owns and closes its model session;
|
||||
// the process-global environment is released only after the final test.
|
||||
func TestMain(m *testing.M) {
|
||||
var lease *router.ONNXRuntimeLease
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib != "" {
|
||||
if _, err := os.Stat(lib); err == nil {
|
||||
var acquireErr error
|
||||
lease, acquireErr = router.AcquireONNXRuntime(lib)
|
||||
if acquireErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "initialize shared ONNX test runtime: %v\n", acquireErr)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
if lease != nil {
|
||||
if err := lease.Close(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ONNX test runtime cleanup: %v\n", err)
|
||||
code = 1
|
||||
}
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
+88
-31
@@ -8,6 +8,8 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -26,49 +28,84 @@ import (
|
||||
// does not say what to do with it. Acting on the bare word would guess, and a
|
||||
// wrong guess here closes work he never finished.
|
||||
|
||||
// candidateOrdinals — the words that pick a position, by index. Prefix match,
|
||||
// because Russian declines them: "первый", "первую", "первое".
|
||||
var candidateOrdinals = []struct {
|
||||
word string
|
||||
nth int
|
||||
}{
|
||||
{"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5},
|
||||
{"first", 1}, {"second", 2}, {"third", 3},
|
||||
}
|
||||
// The position words come from the lexicon, which lists every form with its
|
||||
// position and "последний" as -1 (V-522). They used to be stem prefixes here —
|
||||
// {"перв", 1}, {"втор", 2} — which is the shape that sweep removed: a stem
|
||||
// decides meaning by guessing where a word ends, and "трет" also opens
|
||||
// "third-party". The lexicon runs to twelve rather than five, so he can pick
|
||||
// past the fifth of a longer list; resolveCandidate already answers a position
|
||||
// she did not read.
|
||||
|
||||
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
|
||||
// "15" starts with "1" and is a time, not a position.
|
||||
// "15" starts with "1" and is a time, not a position. Digits are not a Russian
|
||||
// word list, so they stay here rather than in the lexicon.
|
||||
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
|
||||
|
||||
// candidateLast — "последний" picks the end of the list whatever its length.
|
||||
var candidateLast = []string{"последн", "last"}
|
||||
|
||||
// parseOrdinal reads which position he named. 0 and false when he named none.
|
||||
// A negative result means the last one.
|
||||
func parseOrdinal(text string) (int, bool) {
|
||||
// Token by token, not substring: " 1" would otherwise match inside
|
||||
// "напомни в 15:00" and turn a reminder into a selection.
|
||||
for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
toks := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
}) {
|
||||
for _, w := range candidateLast {
|
||||
if strings.HasPrefix(tok, w) {
|
||||
return -1, true
|
||||
}
|
||||
}
|
||||
})
|
||||
for i, tok := range toks {
|
||||
if n, ok := candidateDigits[tok]; ok {
|
||||
return n, true
|
||||
}
|
||||
for _, o := range candidateOrdinals {
|
||||
// Prefix, because Russian declines them: "первый", "первую".
|
||||
if strings.HasPrefix(tok, o.word) {
|
||||
return o.nth, true
|
||||
}
|
||||
// A spoken half hour names the hour it is entering with the same
|
||||
// genitive ordinal: "в половине восьмого" is 07:30, not the eighth
|
||||
// thing she read out. She reads a list and he answers with a time
|
||||
// often enough that this has to be declined here, or the reminder
|
||||
// becomes a selection.
|
||||
if i > 0 && lexicon.IsHalfHour(toks[i-1]) {
|
||||
continue
|
||||
}
|
||||
if n, ok := lexicon.Ordinal(tok); ok {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// parseReminderCancelChoice is intentionally narrower than parseOrdinal. A
|
||||
// task ordinal may appear inside a sentence carrying its transition verb; the
|
||||
// reminder list was already offered specifically for cancellation, so the next
|
||||
// mutation requires the whole turn to be one affirmative position answer.
|
||||
// Questions, negation, two positions and new requests all decline and route as
|
||||
// fresh turns instead of cancelling whichever ordinal happened to appear.
|
||||
func parseReminderCancelChoice(text string) (int, bool) {
|
||||
if router.IsQuestionShaped(text) {
|
||||
return 0, false
|
||||
}
|
||||
tokens := turnTokens(text)
|
||||
nth, positions := 0, 0
|
||||
for _, tok := range tokens {
|
||||
if reminderCancelNegation(tok) {
|
||||
return 0, false
|
||||
}
|
||||
if n, ok := candidateDigits[tok]; ok {
|
||||
nth, positions = n, positions+1
|
||||
continue
|
||||
}
|
||||
if n, ok := lexicon.Ordinal(tok); ok {
|
||||
nth, positions = n, positions+1
|
||||
continue
|
||||
}
|
||||
if lexicon.IsFillerParticle(tok) || reminderCancelVerbs[tok] ||
|
||||
isReminderCancelTarget(tok) || reminderCancelFrame[tok] {
|
||||
continue
|
||||
}
|
||||
switch tok {
|
||||
case "номер", "вариант", "number", "option", "one":
|
||||
continue
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
return nth, positions == 1
|
||||
}
|
||||
|
||||
// candidateVerbs — what he wants done with the one he picked. Nothing here is
|
||||
// destructive: a task moves forward or is dropped, and both are recorded with a
|
||||
// provenance the /tasks page shows.
|
||||
@@ -97,24 +134,38 @@ func parseCandidateVerb(text string) (status, say string, ok bool) {
|
||||
// offerCandidates records the list she just read, so his next words can pick
|
||||
// from it. Best effort: no session store, or a session that expired between the
|
||||
// question and the answer, means the words route normally.
|
||||
func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) {
|
||||
func (h *reactiveHandler) offerCandidates(ctx context.Context, cands []dialogue.Candidate) {
|
||||
if h.dialogueSessions == nil || len(cands) == 0 {
|
||||
return
|
||||
}
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands)
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), cands)
|
||||
}
|
||||
|
||||
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
|
||||
// the list she just read.
|
||||
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) {
|
||||
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||
if h.dialogueSessions == nil {
|
||||
return "", false
|
||||
}
|
||||
sess := h.dialogueSessions.Get(voiceDialogueID, h.now())
|
||||
sess := h.dialogueSessions.Get(dialogueIDOf(ctx), h.now())
|
||||
if sess == nil || len(sess.Candidates) == 0 {
|
||||
return "", false
|
||||
}
|
||||
reminderList := true
|
||||
for _, candidate := range sess.Candidates {
|
||||
if candidate.Kind != "reminder-cancel" {
|
||||
reminderList = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if reminderList && classifyConfirm(text) == confirmNo {
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
|
||||
return "хорошо, ничего не отменяю.", true
|
||||
}
|
||||
nth, ok := parseOrdinal(text)
|
||||
if reminderList {
|
||||
nth, ok = parseReminderCancelChoice(text)
|
||||
}
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
@@ -127,19 +178,25 @@ func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (st
|
||||
return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true
|
||||
}
|
||||
pick := sess.Candidates[nth-1]
|
||||
if pick.Kind == "reminder-cancel" {
|
||||
// Unlike a task list, this list was offered in answer to the explicit
|
||||
// question "which reminder should I cancel?" A bare ordinal is the
|
||||
// answer to that question and therefore completes the cancellation.
|
||||
return h.cancelReminderChoice(ctx, pick.Ref, pick.Label), true
|
||||
}
|
||||
status, say, hasVerb := parseCandidateVerb(text)
|
||||
if !hasVerb || pick.Kind != "task" {
|
||||
// Read it back and keep the list: naming one is often the first half of
|
||||
// a sentence, and the second half is the next turn.
|
||||
return pick.Label, true
|
||||
}
|
||||
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil {
|
||||
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), string(src)); err != nil {
|
||||
log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
|
||||
return "не получилось изменить задачу.", true
|
||||
}
|
||||
// Spent: the list she read is no longer the list, and a second ordinal
|
||||
// against it would close the wrong task.
|
||||
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil)
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
|
||||
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
||||
return say + ": " + pick.Label, true
|
||||
}
|
||||
|
||||
+24
-12
@@ -27,6 +27,16 @@ func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||
{"", 0, false},
|
||||
// A digit inside a time is not a position.
|
||||
{"напомни в 15:00", 0, false},
|
||||
// Forms the stem list used to miss, and positions past its fifth.
|
||||
{"вторым", 2, true},
|
||||
{"седьмую", 7, true},
|
||||
{"одиннадцатый", 11, true},
|
||||
// A spoken half hour names its hour with the same genitive ordinal, so
|
||||
// this is 07:30 and not the eighth thing she read out (V-522).
|
||||
{"напомни в половине восьмого", 0, false},
|
||||
{"полвосьмого", 0, false},
|
||||
// The ordinal still wins when the half word is not in front of it.
|
||||
{"восьмую сделал", 8, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := parseOrdinal(c.text)
|
||||
@@ -38,7 +48,7 @@ func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||
|
||||
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
if _, handled := h.resolveCandidate(context.Background(), "второй"); handled {
|
||||
if _, handled := h.resolveCandidate(context.Background(), "второй", sourceVoice); handled {
|
||||
t.Error("an ordinal with no list behind it was claimed")
|
||||
}
|
||||
}
|
||||
@@ -47,14 +57,14 @@ func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "второй")
|
||||
reply, handled := h.resolveCandidate(ctx, "второй", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "позвонить маме") {
|
||||
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled)
|
||||
}
|
||||
// Still live: naming one is often the first half of a sentence.
|
||||
if _, handled := h.resolveCandidate(ctx, "первый"); !handled {
|
||||
if _, handled := h.resolveCandidate(ctx, "первый", sourceVoice); !handled {
|
||||
t.Error("the list was spent by a read-back")
|
||||
}
|
||||
}
|
||||
@@ -63,9 +73,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||
|
||||
reply, handled := h.resolveCandidate(ctx, "первую сделал")
|
||||
reply, handled := h.resolveCandidate(ctx, "первую сделал", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "купить хлеб") {
|
||||
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
|
||||
}
|
||||
@@ -80,7 +90,7 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
}
|
||||
// Spent: a second ordinal against a list that no longer holds would close
|
||||
// the wrong task.
|
||||
if _, handled := h.resolveCandidate(ctx, "второй"); handled {
|
||||
if _, handled := h.resolveCandidate(ctx, "второй", sourceVoice); handled {
|
||||
t.Error("the list survived the pick it was spent on")
|
||||
}
|
||||
}
|
||||
@@ -88,9 +98,9 @@ func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
ids := seedTasks(t, st, "купить хлеб")
|
||||
putCandidates(h, ids, "купить хлеб")
|
||||
putCandidates(h, context.Background(), ids, "купить хлеб")
|
||||
|
||||
reply, handled := h.resolveCandidate(context.Background(), "третий")
|
||||
reply, handled := h.resolveCandidate(context.Background(), "третий", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "1") {
|
||||
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled)
|
||||
}
|
||||
@@ -118,11 +128,13 @@ func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
|
||||
return ids
|
||||
}
|
||||
|
||||
func putCandidates(h *reactiveHandler, ids []int64, labels ...string) {
|
||||
// putCandidates binds a list to the reach the ctx names, the way queryTasks
|
||||
// does when she recites one.
|
||||
func putCandidates(h *reactiveHandler, ctx context.Context, ids []int64, labels ...string) {
|
||||
cands := make([]dialogue.Candidate, 0, len(ids))
|
||||
for i, id := range ids {
|
||||
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
|
||||
}
|
||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()})
|
||||
h.offerCandidates(cands)
|
||||
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{Timestamp: h.now()})
|
||||
h.offerCandidates(ctx, cands)
|
||||
}
|
||||
|
||||
+329
-47
@@ -2,6 +2,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"log"
|
||||
"math"
|
||||
"sync"
|
||||
@@ -23,31 +25,40 @@ import (
|
||||
// every utterance the list misses is one that reaches the world. It also drifts
|
||||
// silently — a missing verb looks exactly like no bug.
|
||||
//
|
||||
// So the boundary asks the embedder instead. Two frozen seed sets — questions
|
||||
// about him, questions about the world — are embedded once, and the turn's own
|
||||
// query vector, already computed by queryEmbed upstream, is scored against
|
||||
// both. Nearest side wins. Word order, verb form and unseen phrasing stop
|
||||
// mattering, which is exactly what a lexicon could not do.
|
||||
// So the boundary asks the embedder instead. A frozen bilingual corpus is
|
||||
// embedded at model-fit time, then a class-balanced logistic head is fitted
|
||||
// over those vectors. The head learns a direction in semantic space instead
|
||||
// of choosing whichever single example happens to share the most words. That
|
||||
// matters for a public noun inside a private question and for advice about an
|
||||
// owned object: nearest-neighbour scoring confuses both, while a trained head
|
||||
// combines the evidence across the whole sentence.
|
||||
//
|
||||
// Measured 03-08-2026 against multilingual-e5-small on 19 held-out utterances,
|
||||
// none of them a seed: 19 right (TestONNXPersonalBoundary). A 20th, "as i said,
|
||||
// what is the population of india", missed by +0.008 during the first pass and
|
||||
// is a world seed now, which is why it is not in the held-out set. True
|
||||
// positives clear the world side by +0.014 to +0.089 and the nearest true
|
||||
// negative sits at -0.005, so the gate is the sign of the difference and
|
||||
// nothing tighter: the margins are too thin to justify a threshold, and the
|
||||
// asymmetry favours claiming anyway. A false claim costs one honest "не знаю";
|
||||
// a false pass sends his life to an upstream engine.
|
||||
// The corpus covers six sentence shapes on both sides: remembered speech,
|
||||
// possession, narrative, first-person preambles, current advice/information,
|
||||
// and public proper nouns. Training weights each class equally, so the larger
|
||||
// world corpus cannot move the prior merely by containing more examples. A
|
||||
// small L2 term makes the solution stable; its value and the fixed optimiser
|
||||
// are measured by model-backed cross-validation, not adjusted at runtime.
|
||||
//
|
||||
// The embedder is the one model CLAUDE.md pins to homesrv permanently, and it
|
||||
// is what makes this affordable: no llama-server call, no network, one cosine
|
||||
// per seed against a vector the turn already has.
|
||||
// This linear head measures 29/29 on the historical regression suite and
|
||||
// 72/72 on the separate stratified fixture (V-702, 13-08-2026). The gate is
|
||||
// still probability 0.5: a false claim costs one honest "не знаю", while a
|
||||
// false pass can send his life to an upstream engine.
|
||||
//
|
||||
// The embedder is the one model CLAUDE.md pins to homesrv permanently. Its head
|
||||
// is fitted and verified by the model-backed gate, then frozen into the binary;
|
||||
// inference is one dot product against a vector the turn already has. Unknown
|
||||
// embedding spaces fit their own head once per process instead of applying
|
||||
// foreign weights. Neither path calls llama-server or the network.
|
||||
|
||||
// personalSeeds — questions about him. Frozen: they are scoring data, so
|
||||
// editing one moves the boundary and must be re-measured, not eyeballed. Cover
|
||||
// both classes the boundary owns, possession and first-person speech, in both
|
||||
// languages.
|
||||
// personalSeeds and worldSeeds are the frozen training corpus for the linear
|
||||
// boundary head. Editing either changes a model, not a phrase list: every edit
|
||||
// therefore needs the model-backed regression, stratified evaluation and
|
||||
// training-corpus cross-validation. The examples describe where an answer can
|
||||
// come from, in both languages. None is a special case copied from an eval.
|
||||
var personalSeeds = []string{
|
||||
// The original compact corpus. It remains here both as training signal and
|
||||
// as provenance for the regressions that introduced the semantic boundary.
|
||||
"что я говорил про это",
|
||||
"я тебе рассказывал об этом?",
|
||||
"что я записал про врача",
|
||||
@@ -56,42 +67,194 @@ var personalSeeds = []string{
|
||||
"когда моя встреча",
|
||||
"what did i say about this",
|
||||
"did i mention this to you",
|
||||
|
||||
// Remembered speech.
|
||||
"какой адрес я тебе сообщал?",
|
||||
"что я говорил о своём самочувствии?",
|
||||
"какое решение по ремонту я озвучил?",
|
||||
"что я обещал сделать после отпуска?",
|
||||
"what reason did I give for declining the offer?",
|
||||
"did I tell you where I grew up?",
|
||||
"which restaurant did I say I wanted to visit?",
|
||||
"what explanation did I give for missing the meeting?",
|
||||
|
||||
// Stored attributes of his possessions and records.
|
||||
"где лежит мой договор аренды?",
|
||||
"когда заканчивается моя подписка на спортзал?",
|
||||
"какой размер у моей запасной куртки?",
|
||||
"до какой даты действует мой пропуск?",
|
||||
"какой размер у моего велосипедного шлема?",
|
||||
"where is my vehicle registration document?",
|
||||
"when is my museum membership renewal?",
|
||||
"what number is on my travel insurance policy?",
|
||||
"which shelf did I put my tax folder on?",
|
||||
"what size is my waterproof coat?",
|
||||
|
||||
// Narratives that only his memories or records can supply.
|
||||
"собери по моим записям рассказ о поездке в Самару",
|
||||
"напомни, как прошёл мой первый урок вождения",
|
||||
"восстанови из дневника, как я искал первую квартиру",
|
||||
"перескажи по моим словам, как прошла встреча выпускников",
|
||||
"summarize my account of moving into this apartment",
|
||||
"tell me what happened during my first week at the new job",
|
||||
"recreate the story of my graduation from my journal",
|
||||
"piece together my account of adopting the dog",
|
||||
|
||||
// First-person framing around a private answer.
|
||||
"возвращаясь к нашей беседе, какой банк я выбрал?",
|
||||
"кажется, я уже говорил: на какую дату записался к врачу?",
|
||||
"если мы это обсуждали, какую школу вождения я предпочёл?",
|
||||
"напомню наш разговор: когда я решил менять работу?",
|
||||
"as I mentioned before, which contractor did I hire?",
|
||||
"coming back to our chat, what date did I book the inspection for?",
|
||||
"if we covered this already, which course did I enroll in?",
|
||||
"back to what I told you: where did I plan to stay in Oslo?",
|
||||
|
||||
// Current information that lives only in his records.
|
||||
"какой счёт мне нужно оплатить на этой неделе?",
|
||||
"сколько часов я работал в прошлом месяце?",
|
||||
"какую процедуру мастер советовал выполнить утром?",
|
||||
"какая из моих заявок всё ещё не закрыта?",
|
||||
"which appointment do I have tomorrow morning?",
|
||||
"how many kilometres did I run last week?",
|
||||
"what maintenance did the mechanic tell me to schedule?",
|
||||
"which item on my project list is overdue?",
|
||||
|
||||
// Public names inside questions that still require his records.
|
||||
"какую цитату из Набокова я сохранил?",
|
||||
"когда у меня созвон с Ириной Петровой?",
|
||||
"что я думал о романе Умберто Эко?",
|
||||
"какую оценку я дал выставке Айвазовского?",
|
||||
"какую фотографию Эрмитажа я отметил для печати?",
|
||||
"what did I note down after Margaret Hamilton's lecture?",
|
||||
"when is my booking at the Royal Albert Hall?",
|
||||
"which Nina Simone song did I call my favourite?",
|
||||
"what opinion did I share about Zadie Smith's new novel?",
|
||||
"what reminder did I attach to the Jira migration?",
|
||||
}
|
||||
|
||||
// worldSeeds — questions the world can answer, including the two shapes that
|
||||
// look personal and are not: a first-person preamble on a world question ("как
|
||||
// я говорил, ..."), and first person without possession ("что я могу
|
||||
// посмотреть вечером"). Refusing those is the opposite mistake and the older
|
||||
// comment on personalMarkers already named it.
|
||||
var worldSeeds = []string{
|
||||
// The original compact corpus, retained as above.
|
||||
"почему небо синее",
|
||||
"какая столица франции",
|
||||
"как сварить борщ",
|
||||
"кто написал эту книгу",
|
||||
"what is the capital of france",
|
||||
"how do i boil an egg",
|
||||
"как я говорил, почему небо синее",
|
||||
"as i said, why is the sky blue",
|
||||
"as i said, what is the population of india",
|
||||
"что я могу посмотреть вечером",
|
||||
"что мне почитать про историю",
|
||||
"что я должен знать про питон",
|
||||
"what can i watch tonight",
|
||||
// A third shape that looks personal and is not: asking when something
|
||||
// happens (Vikunja #553). "во сколько закат сегодня" scored personal,
|
||||
// because "что у меня сегодня" and "когда моя встреча" put that frame on
|
||||
// the personal side and nothing here answered it. The sunset is the one
|
||||
// thing on his list that is the same for everybody standing outside.
|
||||
// "сегодня" is carried on purpose. Without it these caught nothing: the
|
||||
// day word is most of what pulls the frame personal, because "что у меня
|
||||
// сегодня" is a personal seed and the day word is the half it shares.
|
||||
"во сколько сегодня открывается магазин",
|
||||
"когда сегодня начинается матч",
|
||||
"во сколько сегодня восход солнца",
|
||||
// The other frame a day word carries, and the same story: "что у меня
|
||||
// сегодня" is a personal seed, so "какой сегодня праздник" and "что
|
||||
// интересного произошло сегодня в мире" were refused as his after the
|
||||
// topic seeds had already let them past the weather source.
|
||||
"какой сегодня курс валют",
|
||||
"что сегодня происходит в мире",
|
||||
// The narrative shape (Vikunja #554). "расскажи про Байкал" was refused as
|
||||
// his by 0.0052, and nothing here was phrased as an order rather than a
|
||||
// question: every world seed above opens with an interrogative. So a world
|
||||
// question that names its subject and asks for prose landed nearer "я тебе
|
||||
// рассказывал об этом?", which is the same verb about his own words.
|
||||
"расскажи про байкал",
|
||||
"расскажи про древний рим",
|
||||
"объясни как работает двигатель",
|
||||
"tell me about the roman empire",
|
||||
|
||||
// Speech and reports by somebody other than the owner.
|
||||
"что Александр Пушкин писал о Москве?",
|
||||
"как учёные объясняли исчезновение динозавров?",
|
||||
"что Менделеев говорил о будущем химии?",
|
||||
"какие выводы сделал Амундсен после экспедиции?",
|
||||
"what did Virginia Woolf write about fiction?",
|
||||
"how did researchers describe the Tunguska event?",
|
||||
"what did witnesses report after the Lisbon earthquake?",
|
||||
"which ideas did Ada Lovelace describe in her notes?",
|
||||
|
||||
// General advice about an owned object. Ownership supplies context, but an
|
||||
// outside source can still supply the answer.
|
||||
"как починить мой скрипящий стул?",
|
||||
"почему мой роутер теряет соединение?",
|
||||
"чем очистить мой велосипед от ржавчины?",
|
||||
"какой бензин подходит для моего генератора?",
|
||||
"какой чехол подобрать для моего планшета?",
|
||||
"как защитить мой деревянный стол от влаги?",
|
||||
"how do I remove a stain from my jacket?",
|
||||
"why is my freezer building up ice?",
|
||||
"which oil should I use in my lawn mower?",
|
||||
"what detergent is safe for my washing machine?",
|
||||
"which replacement blade should I buy for my circular saw?",
|
||||
"how can I keep my garden tools from rusting?",
|
||||
|
||||
// Public narratives.
|
||||
"расскажи историю строительства Транссибирской магистрали",
|
||||
"опиши, как развивалась письменность",
|
||||
"объясни, как появился периодический закон",
|
||||
"опиши первую успешную зимовку в Антарктиде",
|
||||
"tell the story of the discovery of penicillin",
|
||||
"describe how the first transatlantic cable was laid",
|
||||
"explain how the Olympic Games were revived",
|
||||
"describe the expedition that first reached the South Pole",
|
||||
|
||||
// First-person framing around a public answer.
|
||||
"как я уже спрашивал, почему звёзды мерцают?",
|
||||
"повторю свой вопрос: как образуются коралловые рифы?",
|
||||
"возможно, я повторяюсь: когда возвели собор Святого Петра?",
|
||||
"я мог уже спрашивать: из чего делают фарфор?",
|
||||
"as I asked earlier, why do leaves change colour?",
|
||||
"to repeat my question, how are fjords formed?",
|
||||
"I might be asking twice, when was Angkor Wat constructed?",
|
||||
"I may have asked before, what causes bioluminescence?",
|
||||
|
||||
// Public current information and generally applicable advice.
|
||||
"какие поезда сегодня идут из Москвы в Тверь?",
|
||||
"как правильно хранить чугунную сковороду?",
|
||||
"какие выставки проходят в Петербурге в этом месяце?",
|
||||
"какой сейчас уровень воды в Волге?",
|
||||
"what is the latest supported version of Ubuntu?",
|
||||
"how should I prepare a wooden deck for winter?",
|
||||
"which film festivals are taking place this season?",
|
||||
"what is the current exchange rate for the Norwegian krone?",
|
||||
|
||||
// Public facts about named people, places and organisations.
|
||||
"кто такая Софья Ковалевская?",
|
||||
"когда была основана компания Nintendo?",
|
||||
"чем прославился архитектор Фрэнк Ллойд Райт?",
|
||||
"где находится музей Прадо?",
|
||||
"who was James Baldwin?",
|
||||
"what is the city of Petra known for?",
|
||||
"when was the composer Philip Glass born?",
|
||||
"where is the Uffizi Gallery located?",
|
||||
}
|
||||
|
||||
// personalBoundary holds the embedded seeds. Zero value is usable and means
|
||||
// "not loaded yet"; a handler built without an embedder never loads and the
|
||||
// boundary falls back to personalMarkers.
|
||||
// personalBoundary holds the frozen or locally fitted head and, when fitting
|
||||
// was necessary, its embedded corpus. Zero value is usable and means "not
|
||||
// loaded yet"; a handler built without an embedder never loads and the boundary
|
||||
// falls back to personalMarkers.
|
||||
type personalBoundary struct {
|
||||
once sync.Once
|
||||
personal [][]float32
|
||||
world [][]float32
|
||||
head personalBoundaryLinearHead
|
||||
loaded bool
|
||||
}
|
||||
|
||||
// load embeds both seed sets, once per process. Seeds are embedded on the QUERY
|
||||
// side, like the utterance they are compared with — a question against a
|
||||
// question. Mixing sides would measure the e5 prefix, not the meaning.
|
||||
// load selects the pinned frozen head or embeds and fits the seed sets once per
|
||||
// process for another embedding space. Seeds are embedded on the QUERY side,
|
||||
// like the utterance they classify. Mixing sides would measure the e5 prefix,
|
||||
// not the meaning.
|
||||
func (b *personalBoundary) load(ctx context.Context, emb router.Embedder) {
|
||||
b.once.Do(func() {
|
||||
if emb == nil {
|
||||
@@ -109,30 +272,149 @@ func (b *personalBoundary) load(ctx context.Context, emb router.Embedder) {
|
||||
}
|
||||
return out
|
||||
}
|
||||
// The deployed e5-small head is fitted offline from the corpus below and
|
||||
// checked back against it by TestONNXPersonalBoundaryFrozenHead. Loading
|
||||
// it directly keeps the first personal query from embedding 132 examples.
|
||||
if router.EmbedderID(emb) == personalBoundaryHeadModelID {
|
||||
head, ok := frozenPersonalBoundaryHead()
|
||||
if ok && len(head.weights) == emb.Dim() {
|
||||
b.head, b.loaded = head, true
|
||||
return
|
||||
}
|
||||
log.Printf("voice: frozen personal boundary head is corrupt; rebuilding from its corpus")
|
||||
}
|
||||
|
||||
p, w := embedAll(personalSeeds), embedAll(worldSeeds)
|
||||
if p == nil || w == nil {
|
||||
return
|
||||
}
|
||||
b.personal, b.world, b.loaded = p, w, true
|
||||
epochs := personalBoundaryTrainingEpochs
|
||||
if router.EmbedderID(emb) == personalBoundaryHashModelID {
|
||||
epochs = personalBoundaryHashTrainingEpochs
|
||||
}
|
||||
head, ok := trainPersonalBoundaryLinearHeadEpochs(p, w, epochs)
|
||||
if !ok {
|
||||
log.Printf("voice: personal boundary training examples have inconsistent dimensions; falling back to possession markers")
|
||||
return
|
||||
}
|
||||
b.personal, b.world, b.head, b.loaded = p, w, head, true
|
||||
})
|
||||
}
|
||||
|
||||
// score returns the best similarity to each side. ok is false when the seeds
|
||||
// are not loaded, which is the caller's signal to use the markers instead.
|
||||
func (b *personalBoundary) score(vec []float32) (personal, world float64, ok bool) {
|
||||
if !b.loaded || len(vec) == 0 {
|
||||
return 0, 0, false
|
||||
const (
|
||||
personalBoundaryTrainingEpochs = 5000
|
||||
personalBoundaryLearningRate = 10.0
|
||||
personalBoundaryL2 = 0.0003
|
||||
)
|
||||
|
||||
const personalBoundaryHeadModelID = "model_quantized@384/tok2"
|
||||
|
||||
const personalBoundaryHashModelID = "hash@1024"
|
||||
|
||||
const personalBoundaryHeadWeights = "a3q5vmod2L7msrs+1RE8Pp5HDEBlv609AC9cvzm4D0CL7Fc/FU9cvxLmAMA638c/BgDBP6Is1r7PzBO/6MVAPsmEWT6XowjAouT0v8jMN79d2Sk+7XLlPX2akD+lmKi/q922vvLSFcBb0ma/cN3QP27zBMDl45i/iuE0P4KIJb+7dua+gTePP5unVz9H3q29Sxsev7YJe7+SvoQ+r6jyPxW2DL8sMQc/+iExQM5y8D/qJSZAtFyKP3PbyD8OK0dAHD+0v056qj4AbOS+AFHzP1KPeT9+cqu/aMIQv9wCqL8WbYe/xED1vu7pHMCPlxe/ZUGLPqFoDb8GPQ6/XE6cvqPVi7xKdr0/CE1PP4dPrj6TxoK+KokGP7xxu73h6DW/Lw8APsjd1D43aci/ZBMoQPyy8D8G6w/AMT1tPSEUU7/Sp+c+sjpRvyfl2L4KDs8/q/Ibv3urHj/+7ls/yxjaP8WS8jy8cd6+BO+4P/IcJkBTEPo/q2VGvqvsUD9anuk8UiO/PSw707+5+oY+zBpHP6e+UT4qaEe/zqjGvypN1j45TFY+nZ36v9rP8L9bmyE/Rn8UwONI0D5Yhs6/InCYv4kGgz/LNXO/rhK+Pu2Qdz/W8ijAdi3hv5qT5D9383k8Ir2wP0MRD0AxCCQ/0CUDP5kWoz+TQjdAOxI0vSbxDb/xj54/N/G6v86Ixr932Lk/jQ2jvqn2nr9y3JC96jDDPsyPlj9q/OQ/cOcCQJ+15z9747s/8Zh8PoS4oL0GKma/lfuPv/Clgb9GPKW+2OR3vimzAUBVYxXARcw0vynpsr/IUqe/bsUhv5kwWcCZtnE/fr87vjvfdr4mHis/xMpzvn20HL4SHFu/1DFXvVgOg76GXEq/pB2QP2u6e71q7w0+7F3APlte1j9YKXK/1cljPkFx0L/CndS9b4CeP4BIvj/fP5Q99jbZvL1h778WhC0/pNhov4+x1r+lYeE/9Y6gP9gtqr75dIe/wGiKv4q56D10ckY+UuvDvoIUnz/3TVM/moHcP6FkUz6//pY+FYhcwFEkD8B2a2c9mC+UP/ZeTb5FgIq+rgEOvylj8D9dvx8/OngmPyiplT9oiLy/AJwswKOJdL+i8/m9GPNfvyyWk77jVPC/0u+IPpx/Fz/QdvG/Ag9gP41l2rxmXUo/hdL0vx1XX0BUp+w9hmYyPk21dT6UJmK/zajGP7gBSD0FqoXAkis4P7kehz94wNa//nfZvxA0Fz8b9ze/IETPv3xEb76BG8k/SpyVP9xkEUC2/jlAcv8/wKKxU75E0xM+9BItPzlQKr6S0wdAMa39v0GKA8AMB3G/IeKvvyTZkz+es62/UEYTP3j+lj4SRM+/Dbfgvupdsj/wcUbAbjqRv/WV/r5WRaO/iB67P3/UyD8AK5Q+LzvJPsjPPL/fwkS/atd9P56MHz9CIJu9ugjgvp7J2D8otQC/YYoowKGEFD4eMVC/xy3UP2UEND9nU0i/ol4GQJuwfb+xeaa/B3IjwDK6Gz8dVv8/2wbLPlUo6j+FDCk/4Q/VP/J8JkCYVd0/gMS/P9Bwhj9R94a9M0Mjv/hKdL8cl6Y/lD73vwgior9+56Q/YI+1v9Wd0j8ltAjAmD5dP56Hnb+rdrA+gn2jP0bFA7/lkZU/tK6VP63ItT5Oi7O+YjfUv5iUzT+n5H8/zXMpvjefvj67z66/GA71Pj2h2T5bXxW/EyfLP1LZxr/B758/iCd2v0jnoT8twoG/oAO9vjpYDr61q6I+AEVFv1OP2b1VQpO/5FYdP5vgaz/4Lbm9CMCjvhbWlL9pYQk/1l5hPjCTYj8dtiJATXjavb6SlL7rp0E/cMBgP9UIXLwVYXC+rFS2v9yeFUD88JBAbwWcvt7s1D/bsuU/BCv0PzSdQEA7l36/FULEvmxlo79jjzc+gFvav1vptb/YjkS/Zo76vqK+3j+qvqi/qyfpPj1BLj+ehSzA4Z8nPyS/1b8kz5a9NIuZv31beL/k0oXAXFO/P8cCh8BSPzS+N7agvhjPUD6/G24/GIP0PYlNOsAFe6q+"
|
||||
|
||||
// HashEmbedder is a deterministic offline floor. Its 1024-dimensional head is
|
||||
// trained on first use instead of embedded here because the binary form is
|
||||
// still tiny but not meaningful as a production quality claim. The floor's
|
||||
// optimizer uses fewer steps: the hash vectors are sparse and converge long
|
||||
// before the semantic head, keeping an unconfigured box responsive.
|
||||
const personalBoundaryHashTrainingEpochs = 400
|
||||
|
||||
type personalBoundaryLinearHead struct {
|
||||
weights []float64
|
||||
bias float64
|
||||
}
|
||||
|
||||
func frozenPersonalBoundaryHead() (personalBoundaryLinearHead, bool) {
|
||||
raw, err := base64.StdEncoding.DecodeString(personalBoundaryHeadWeights)
|
||||
if err != nil || len(raw)%4 != 0 {
|
||||
return personalBoundaryLinearHead{}, false
|
||||
}
|
||||
best := func(seeds [][]float32) float64 {
|
||||
m := -1.0
|
||||
for _, s := range seeds {
|
||||
if c := cosine(vec, s); c > m {
|
||||
m = c
|
||||
weights := make([]float64, len(raw)/4)
|
||||
for i := range weights {
|
||||
weights[i] = float64(math.Float32frombits(binary.LittleEndian.Uint32(raw[4*i:])))
|
||||
}
|
||||
return personalBoundaryLinearHead{weights: weights, bias: -3.122734201373742}, true
|
||||
}
|
||||
|
||||
// trainPersonalBoundaryLinearHead fits binary logistic regression with full
|
||||
// batch gradient descent. Each side contributes total weight 0.5 regardless
|
||||
// of its number of examples. The optimiser is intentionally tiny and local:
|
||||
// the embedder supplies all learned language knowledge; this only learns one
|
||||
// separating hyperplane over its 384-dimensional vectors.
|
||||
func trainPersonalBoundaryLinearHead(personal, world [][]float32) (personalBoundaryLinearHead, bool) {
|
||||
return trainPersonalBoundaryLinearHeadEpochs(personal, world, personalBoundaryTrainingEpochs)
|
||||
}
|
||||
|
||||
func trainPersonalBoundaryLinearHeadEpochs(personal, world [][]float32, epochs int) (personalBoundaryLinearHead, bool) {
|
||||
if len(personal) == 0 || len(world) == 0 || len(personal[0]) == 0 {
|
||||
return personalBoundaryLinearHead{}, false
|
||||
}
|
||||
dim := len(personal[0])
|
||||
for _, vectors := range [][][]float32{personal, world} {
|
||||
for _, vector := range vectors {
|
||||
if len(vector) != dim {
|
||||
return personalBoundaryLinearHead{}, false
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
return best(b.personal), best(b.world), true
|
||||
|
||||
head := personalBoundaryLinearHead{weights: make([]float64, dim)}
|
||||
personalWeight := 0.5 / float64(len(personal))
|
||||
worldWeight := 0.5 / float64(len(world))
|
||||
for epoch := 0; epoch < epochs; epoch++ {
|
||||
gradient := make([]float64, dim)
|
||||
biasGradient := 0.0
|
||||
accumulate := func(vectors [][]float32, target, sampleWeight float64) {
|
||||
for _, vector := range vectors {
|
||||
probability := logistic(head.logit(vector))
|
||||
error := (probability - target) * sampleWeight
|
||||
biasGradient += error
|
||||
for i, value := range vector {
|
||||
gradient[i] += error * float64(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
accumulate(personal, 1, personalWeight)
|
||||
accumulate(world, 0, worldWeight)
|
||||
|
||||
step := personalBoundaryLearningRate / (1 + float64(epoch)/1000)
|
||||
for i := range head.weights {
|
||||
head.weights[i] -= step * (gradient[i] + personalBoundaryL2*head.weights[i])
|
||||
}
|
||||
head.bias -= step * biasGradient
|
||||
}
|
||||
return head, true
|
||||
}
|
||||
|
||||
func (h personalBoundaryLinearHead) logit(vec []float32) float64 {
|
||||
if len(vec) != len(h.weights) {
|
||||
return 0
|
||||
}
|
||||
score := h.bias
|
||||
for i, value := range vec {
|
||||
score += h.weights[i] * float64(value)
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func logistic(value float64) float64 {
|
||||
if value >= 0 {
|
||||
return 1 / (1 + math.Exp(-value))
|
||||
}
|
||||
exp := math.Exp(value)
|
||||
return exp / (1 + exp)
|
||||
}
|
||||
|
||||
// score returns complementary class probabilities. ok is false when the
|
||||
// corpus is not loaded or the query vector belongs to another embedding
|
||||
// space, which is the caller's signal to use the offline marker floor.
|
||||
func (b *personalBoundary) score(vec []float32) (personal, world float64, ok bool) {
|
||||
if !b.loaded || len(vec) != len(b.head.weights) {
|
||||
return 0, 0, false
|
||||
}
|
||||
personal = logistic(b.head.logit(vec))
|
||||
return personal, 1 - personal, true
|
||||
}
|
||||
|
||||
// cosine — same math as internal/router and internal/memory, small enough that
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// This fixture is intentionally separate from personalboundary_test.go. The
|
||||
// small regression table there explains individual fixes; this matrix measures
|
||||
// the boundary as a classifier and prevents a repaired sentence shape from
|
||||
// standing in for language and subject coverage.
|
||||
//
|
||||
//go:embed testdata/personal_boundary_v1.json
|
||||
var personalBoundaryFixtureJSON []byte
|
||||
|
||||
type personalBoundaryEvalCase struct {
|
||||
ID string `json:"id"`
|
||||
Utterance string `json:"utterance"`
|
||||
Lang string `json:"lang"`
|
||||
Want string `json:"want"`
|
||||
Stratum string `json:"stratum"`
|
||||
}
|
||||
|
||||
type personalBoundaryEvalFixture struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
Cases []personalBoundaryEvalCase `json:"cases"`
|
||||
}
|
||||
|
||||
var personalBoundaryEvalStrata = []string{
|
||||
"remembered_speech",
|
||||
"possession",
|
||||
"narrative",
|
||||
"first_person_preamble",
|
||||
"advice_current_info",
|
||||
"public_proper_nouns",
|
||||
}
|
||||
|
||||
func loadPersonalBoundaryEvalFixture(t *testing.T) personalBoundaryEvalFixture {
|
||||
t.Helper()
|
||||
var fixture personalBoundaryEvalFixture
|
||||
if err := json.Unmarshal(personalBoundaryFixtureJSON, &fixture); err != nil {
|
||||
t.Fatalf("parse personal boundary fixture: %v", err)
|
||||
}
|
||||
if fixture.SchemaVersion != 1 {
|
||||
t.Fatalf("personal boundary fixture schema_version = %d, want 1", fixture.SchemaVersion)
|
||||
}
|
||||
if fixture.Name != "personal_boundary_v1" {
|
||||
t.Fatalf("personal boundary fixture name = %q, want personal_boundary_v1", fixture.Name)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
// TestPersonalBoundaryEvalFixture enforces the sampling contract separately
|
||||
// from the model measurement. It runs in ordinary CI even when ONNX Runtime is
|
||||
// absent, so a fixture edit cannot silently unbalance a language, side or
|
||||
// sentence shape, or turn a production seed into a held-out case.
|
||||
func TestPersonalBoundaryEvalFixture(t *testing.T) {
|
||||
fixture := loadPersonalBoundaryEvalFixture(t)
|
||||
const wantPerCell = 3
|
||||
const wantTotal = 6 * 2 * 2 * wantPerCell
|
||||
if len(fixture.Cases) != wantTotal {
|
||||
t.Errorf("fixture has %d cases, want %d", len(fixture.Cases), wantTotal)
|
||||
}
|
||||
|
||||
validStrata := make(map[string]bool, len(personalBoundaryEvalStrata))
|
||||
for _, stratum := range personalBoundaryEvalStrata {
|
||||
validStrata[stratum] = true
|
||||
}
|
||||
seedSource := make(map[string]string, len(personalSeeds)+len(worldSeeds))
|
||||
for _, seed := range personalSeeds {
|
||||
seedSource[normalizePersonalBoundaryEval(seed)] = "personalSeeds"
|
||||
}
|
||||
for _, seed := range worldSeeds {
|
||||
seedSource[normalizePersonalBoundaryEval(seed)] = "worldSeeds"
|
||||
}
|
||||
|
||||
seenID := make(map[string]bool, len(fixture.Cases))
|
||||
seenUtterance := make(map[string]string, len(fixture.Cases))
|
||||
cells := make(map[string]int)
|
||||
for _, c := range fixture.Cases {
|
||||
if strings.TrimSpace(c.ID) == "" || seenID[c.ID] {
|
||||
t.Errorf("case %q: empty or duplicate id", c.ID)
|
||||
}
|
||||
seenID[c.ID] = true
|
||||
if c.Lang != "ru" && c.Lang != "en" {
|
||||
t.Errorf("%s: lang = %q, want ru|en", c.ID, c.Lang)
|
||||
}
|
||||
if c.Want != "personal" && c.Want != "world" {
|
||||
t.Errorf("%s: want = %q, want personal|world", c.ID, c.Want)
|
||||
}
|
||||
if !validStrata[c.Stratum] {
|
||||
t.Errorf("%s: stratum = %q, not one of the six declared strata", c.ID, c.Stratum)
|
||||
}
|
||||
|
||||
normalized := normalizePersonalBoundaryEval(c.Utterance)
|
||||
if normalized == "" {
|
||||
t.Errorf("%s: empty utterance", c.ID)
|
||||
}
|
||||
if previous, ok := seenUtterance[normalized]; ok {
|
||||
t.Errorf("%s: utterance duplicates %s after normalization", c.ID, previous)
|
||||
}
|
||||
seenUtterance[normalized] = c.ID
|
||||
if source, ok := seedSource[normalized]; ok {
|
||||
t.Errorf("%s: %q is verbatim in %s, so it is not held out", c.ID, c.Utterance, source)
|
||||
}
|
||||
// The original failure names Baikal. Replacing that sentence's verb or
|
||||
// punctuation would measure an exception, not the boundary. This corpus
|
||||
// instead varies people, places, products and events.
|
||||
if strings.Contains(normalized, "байкал") || strings.Contains(normalized, "baikal") {
|
||||
t.Errorf("%s: the stratified fixture must not copy the Baikal regression", c.ID)
|
||||
}
|
||||
cells[c.Stratum+"/"+c.Lang+"/"+c.Want]++
|
||||
}
|
||||
|
||||
for _, stratum := range personalBoundaryEvalStrata {
|
||||
for _, lang := range []string{"ru", "en"} {
|
||||
for _, want := range []string{"personal", "world"} {
|
||||
cell := stratum + "/" + lang + "/" + want
|
||||
if got := cells[cell]; got != wantPerCell {
|
||||
t.Errorf("fixture cell %s has %d cases, want %d", cell, got, wantPerCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizePersonalBoundaryEval compares content rather than typography:
|
||||
// case, punctuation and repeated whitespace cannot disguise a copied seed or
|
||||
// duplicate case. This is fixture hygiene only; it does not participate in the
|
||||
// production boundary.
|
||||
func normalizePersonalBoundaryEval(s string) string {
|
||||
var b strings.Builder
|
||||
space := true
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if unicode.IsLetter(r) || unicode.IsNumber(r) {
|
||||
b.WriteRune(r)
|
||||
space = false
|
||||
continue
|
||||
}
|
||||
if !space {
|
||||
b.WriteByte(' ')
|
||||
space = true
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
type personalBoundaryEvalStat struct {
|
||||
Correct int
|
||||
Total int
|
||||
}
|
||||
|
||||
type personalBoundaryEvalReport struct {
|
||||
Name string
|
||||
Correct int
|
||||
Total int
|
||||
MinimumMargin float64
|
||||
ByStratum map[string]personalBoundaryEvalStat
|
||||
ByLanguage map[string]personalBoundaryEvalStat
|
||||
ByExpectedClass map[string]personalBoundaryEvalStat
|
||||
ByCell map[string]personalBoundaryEvalStat
|
||||
}
|
||||
|
||||
func newPersonalBoundaryEvalReport(name string) *personalBoundaryEvalReport {
|
||||
return &personalBoundaryEvalReport{
|
||||
Name: name,
|
||||
MinimumMargin: math.Inf(1),
|
||||
ByStratum: make(map[string]personalBoundaryEvalStat),
|
||||
ByLanguage: make(map[string]personalBoundaryEvalStat),
|
||||
ByExpectedClass: make(map[string]personalBoundaryEvalStat),
|
||||
ByCell: make(map[string]personalBoundaryEvalStat),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *personalBoundaryEvalReport) add(c personalBoundaryEvalCase, gotPersonal bool, personal, world float64) {
|
||||
wantPersonal := c.Want == "personal"
|
||||
correct := gotPersonal == wantPersonal
|
||||
r.Total++
|
||||
if correct {
|
||||
r.Correct++
|
||||
}
|
||||
signedMargin := personal - world
|
||||
if !wantPersonal {
|
||||
signedMargin = -signedMargin
|
||||
}
|
||||
if signedMargin < r.MinimumMargin {
|
||||
r.MinimumMargin = signedMargin
|
||||
}
|
||||
add := func(stats map[string]personalBoundaryEvalStat, key string) {
|
||||
stat := stats[key]
|
||||
stat.Total++
|
||||
if correct {
|
||||
stat.Correct++
|
||||
}
|
||||
stats[key] = stat
|
||||
}
|
||||
add(r.ByStratum, c.Stratum)
|
||||
add(r.ByLanguage, c.Lang)
|
||||
add(r.ByExpectedClass, c.Want)
|
||||
add(r.ByCell, c.Stratum+"/"+c.Lang+"/"+c.Want)
|
||||
}
|
||||
|
||||
// TestONNXPersonalBoundaryStratified scores the model homesrv actually runs.
|
||||
// Production is read from personalBoundary.score; top1, top2, top3 and a
|
||||
// whole-class centroid are diagnostics over the same embedded seeds. Today
|
||||
// production and top3 coincide, but keeping them separate means a later scoring
|
||||
// experiment can be compared without rewriting this evaluation or putting its
|
||||
// candidate math in runtime code. The privacy boundary is a hard contract, so
|
||||
// every production miss is a test failure rather than an accuracy target to
|
||||
// average away.
|
||||
func TestONNXPersonalBoundaryStratified(t *testing.T) {
|
||||
if os.Getenv("MAVEN_EVAL_PERSONAL_BOUNDARY") == "" {
|
||||
t.Skip("set MAVEN_EVAL_PERSONAL_BOUNDARY=1 to run the deliberately strict V-702 matrix")
|
||||
}
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
modelDir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
model := filepath.Join(modelDir, "model_quantized.onnx")
|
||||
tokenizer := filepath.Join(modelDir, "tokenizer.json")
|
||||
for _, path := range []string{lib, model, tokenizer} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("personal boundary eval dependency %s unavailable: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
embedder, err := router.NewONNXEmbedder(model, tokenizer, lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer embedder.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
boundary := &personalBoundary{}
|
||||
boundary.load(ctx, embedder)
|
||||
if !boundary.loaded {
|
||||
t.Fatal("personal boundary seeds did not load with a working embedder")
|
||||
}
|
||||
// Production loads its model-ID-pinned frozen head and deliberately skips
|
||||
// the 132 corpus embeddings on a user's first query. This test still needs
|
||||
// those vectors for the historical top-k/centroid diagnostics, so build
|
||||
// them here without putting that latency back in runtime code.
|
||||
embedCorpus := func(values []string) [][]float32 {
|
||||
vectors := make([][]float32, len(values))
|
||||
for i, value := range values {
|
||||
vector, err := router.EmbedQuery(ctx, embedder, value)
|
||||
if err != nil {
|
||||
t.Fatalf("embed diagnostic corpus %q: %v", value, err)
|
||||
}
|
||||
vectors[i] = vector
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
boundary.personal = embedCorpus(personalSeeds)
|
||||
boundary.world = embedCorpus(worldSeeds)
|
||||
personalCentroid := personalBoundaryEvalCentroid(boundary.personal)
|
||||
worldCentroid := personalBoundaryEvalCentroid(boundary.world)
|
||||
if len(personalCentroid) == 0 || len(worldCentroid) == 0 {
|
||||
t.Fatal("personal boundary seed vectors do not share a dimension")
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
name string
|
||||
score func([]float32) (float64, float64)
|
||||
}
|
||||
candidates := []candidate{
|
||||
{name: "production", score: func(vec []float32) (float64, float64) {
|
||||
personal, world, ok := boundary.score(vec)
|
||||
if !ok {
|
||||
t.Fatal("loaded personal boundary declined to score")
|
||||
}
|
||||
return personal, world
|
||||
}},
|
||||
{name: "top1", score: func(vec []float32) (float64, float64) {
|
||||
return meanNearest(vec, boundary.personal, 1), meanNearest(vec, boundary.world, 1)
|
||||
}},
|
||||
{name: "top2", score: func(vec []float32) (float64, float64) {
|
||||
return meanNearest(vec, boundary.personal, 2), meanNearest(vec, boundary.world, 2)
|
||||
}},
|
||||
{name: "top3", score: func(vec []float32) (float64, float64) {
|
||||
return meanNearest(vec, boundary.personal, 3), meanNearest(vec, boundary.world, 3)
|
||||
}},
|
||||
{name: "centroid", score: func(vec []float32) (float64, float64) {
|
||||
return cosine(vec, personalCentroid), cosine(vec, worldCentroid)
|
||||
}},
|
||||
}
|
||||
reports := make(map[string]*personalBoundaryEvalReport, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
reports[candidate.name] = newPersonalBoundaryEvalReport(candidate.name)
|
||||
}
|
||||
|
||||
fixture := loadPersonalBoundaryEvalFixture(t)
|
||||
for _, c := range fixture.Cases {
|
||||
vec, err := router.EmbedQuery(ctx, embedder, c.Utterance)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: embed %q: %v", c.ID, c.Utterance, err)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
personal, world := candidate.score(vec)
|
||||
gotPersonal := personal > world
|
||||
reports[candidate.name].add(c, gotPersonal, personal, world)
|
||||
if candidate.name == "production" && gotPersonal != (c.Want == "personal") {
|
||||
t.Errorf("%s [%s/%s]: got %s, want %s (personal %.4f world %.4f delta %+.4f): %q",
|
||||
c.ID, c.Lang, c.Stratum, boundaryEvalSide(gotPersonal), c.Want,
|
||||
personal, world, personal-world, c.Utterance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
report := reports[candidate.name]
|
||||
t.Logf("candidate %-15s %2d/%d (%.1f%%), minimum signed margin %+.4f",
|
||||
report.Name, report.Correct, report.Total,
|
||||
100*float64(report.Correct)/float64(report.Total), report.MinimumMargin)
|
||||
}
|
||||
production := reports["production"]
|
||||
for _, lang := range []string{"ru", "en"} {
|
||||
stat := production.ByLanguage[lang]
|
||||
t.Logf("production language %-2s %2d/%d", lang, stat.Correct, stat.Total)
|
||||
}
|
||||
for _, side := range []string{"personal", "world"} {
|
||||
stat := production.ByExpectedClass[side]
|
||||
t.Logf("production expected %-8s %2d/%d", side, stat.Correct, stat.Total)
|
||||
}
|
||||
strata := append([]string(nil), personalBoundaryEvalStrata...)
|
||||
sort.Strings(strata)
|
||||
for _, stratum := range strata {
|
||||
stat := production.ByStratum[stratum]
|
||||
ruPersonal := production.ByCell[stratum+"/ru/personal"]
|
||||
ruWorld := production.ByCell[stratum+"/ru/world"]
|
||||
enPersonal := production.ByCell[stratum+"/en/personal"]
|
||||
enWorld := production.ByCell[stratum+"/en/world"]
|
||||
t.Logf("production stratum %-21s %2d/%d | ru personal %d/%d world %d/%d | en personal %d/%d world %d/%d",
|
||||
stratum, stat.Correct, stat.Total,
|
||||
ruPersonal.Correct, ruPersonal.Total, ruWorld.Correct, ruWorld.Total,
|
||||
enPersonal.Correct, enPersonal.Total, enWorld.Correct, enWorld.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func personalBoundaryEvalCentroid(vectors [][]float32) []float32 {
|
||||
if len(vectors) == 0 {
|
||||
return nil
|
||||
}
|
||||
centroid := make([]float32, len(vectors[0]))
|
||||
for _, vector := range vectors {
|
||||
if len(vector) != len(centroid) {
|
||||
return nil
|
||||
}
|
||||
for i, value := range vector {
|
||||
centroid[i] += value
|
||||
}
|
||||
}
|
||||
for i := range centroid {
|
||||
centroid[i] /= float32(len(vectors))
|
||||
}
|
||||
return centroid
|
||||
}
|
||||
|
||||
func boundaryEvalSide(personal bool) string {
|
||||
if personal {
|
||||
return "personal"
|
||||
}
|
||||
return "world"
|
||||
}
|
||||
|
||||
// meanNearest is an evaluation baseline retained beside the strict fixture;
|
||||
// production uses the linear head in personalboundary.go.
|
||||
func meanNearest(vec []float32, seeds [][]float32, k int) float64 {
|
||||
if len(seeds) == 0 || k <= 0 {
|
||||
return -1
|
||||
}
|
||||
if k > len(seeds) {
|
||||
k = len(seeds)
|
||||
}
|
||||
top := make([]float64, k)
|
||||
for i := range top {
|
||||
top[i] = -1
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
candidate := cosine(vec, seed)
|
||||
for i := range top {
|
||||
if candidate > top[i] {
|
||||
candidate, top[i] = top[i], candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
var sum float64
|
||||
for _, similarity := range top {
|
||||
sum += similarity
|
||||
}
|
||||
return sum / float64(k)
|
||||
}
|
||||
@@ -2,13 +2,230 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
func TestPersonalBoundaryLinearHeadSeparatesSemanticDirections(t *testing.T) {
|
||||
personal := [][]float32{{1, 0}, {0.9, 0.1}, {0.8, -0.1}}
|
||||
world := [][]float32{{-1, 0}, {-0.9, 0.1}, {-0.8, -0.1}}
|
||||
head, ok := trainPersonalBoundaryLinearHead(personal, world)
|
||||
if !ok {
|
||||
t.Fatal("valid training vectors were rejected")
|
||||
}
|
||||
b := personalBoundary{personal: personal, world: world, head: head, loaded: true}
|
||||
for _, tc := range []struct {
|
||||
vector []float32
|
||||
personal bool
|
||||
}{
|
||||
{vector: []float32{0.75, 0.2}, personal: true},
|
||||
{vector: []float32{-0.75, 0.2}, personal: false},
|
||||
} {
|
||||
personalScore, worldScore, ok := b.score(tc.vector)
|
||||
if !ok {
|
||||
t.Fatal("loaded boundary did not score")
|
||||
}
|
||||
if got := personalScore > worldScore; got != tc.personal {
|
||||
t.Fatalf("vector %v classified personal=%v (scores %.4f/%.4f), want %v",
|
||||
tc.vector, got, personalScore, worldScore, tc.personal)
|
||||
}
|
||||
if math.Abs(personalScore+worldScore-1) > 1e-12 {
|
||||
t.Fatalf("scores %.8f and %.8f are not complementary probabilities", personalScore, worldScore)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryTrainingBalancesClasses(t *testing.T) {
|
||||
personal := [][]float32{{1, 0}, {0.8, 0.2}}
|
||||
world := [][]float32{{-1, 0}}
|
||||
oneWorld, ok := trainPersonalBoundaryLinearHead(personal, world)
|
||||
if !ok {
|
||||
t.Fatal("valid training vectors were rejected")
|
||||
}
|
||||
repeatedWorld := make([][]float32, 12)
|
||||
for i := range repeatedWorld {
|
||||
repeatedWorld[i] = world[0]
|
||||
}
|
||||
twelveWorld, ok := trainPersonalBoundaryLinearHead(personal, repeatedWorld)
|
||||
if !ok {
|
||||
t.Fatal("valid repeated training vectors were rejected")
|
||||
}
|
||||
if math.Abs(oneWorld.bias-twelveWorld.bias) > 1e-10 {
|
||||
t.Fatalf("duplicating one class moved bias from %.12f to %.12f", oneWorld.bias, twelveWorld.bias)
|
||||
}
|
||||
for i := range oneWorld.weights {
|
||||
if math.Abs(oneWorld.weights[i]-twelveWorld.weights[i]) > 1e-10 {
|
||||
t.Fatalf("duplicating one class moved weight %d from %.12f to %.12f",
|
||||
i, oneWorld.weights[i], twelveWorld.weights[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryTrainingRejectsMixedDimensions(t *testing.T) {
|
||||
if _, ok := trainPersonalBoundaryLinearHead(
|
||||
[][]float32{{1, 0}},
|
||||
[][]float32{{-1, 0, 0}},
|
||||
); ok {
|
||||
t.Fatal("mixed embedding dimensions were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The corpus is grouped by sentence shape in personalboundary.go. This test
|
||||
// leaves one entire shape out of training at a time, then requires the linear
|
||||
// head to classify the omitted examples from the semantics learned from the
|
||||
// other shapes. It is ordinary deterministic CI: the small axis vectors stand
|
||||
// in for frozen embedding directions, so the test proves the training code
|
||||
// generalises across groups rather than memorising one row at a time.
|
||||
func TestPersonalBoundaryLinearHeadLeaveOneShapeOut(t *testing.T) {
|
||||
type example struct {
|
||||
vector []float32
|
||||
shape int
|
||||
want bool
|
||||
}
|
||||
const shapeCount = 6
|
||||
examples := make([]example, 0, shapeCount*4)
|
||||
for shape := 0; shape < shapeCount; shape++ {
|
||||
for variant := 0; variant < 2; variant++ {
|
||||
personal := make([]float32, shapeCount+1)
|
||||
world := make([]float32, shapeCount+1)
|
||||
personal[0], world[0] = 1, -1
|
||||
personal[shape+1] = float32(0.1 * float64(variant+1))
|
||||
world[shape+1] = float32(-0.1 * float64(variant+1))
|
||||
examples = append(examples,
|
||||
example{vector: personal, shape: shape, want: true},
|
||||
example{vector: world, shape: shape, want: false},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for omitted := 0; omitted < shapeCount; omitted++ {
|
||||
var personal, world [][]float32
|
||||
for _, example := range examples {
|
||||
if example.shape == omitted {
|
||||
continue
|
||||
}
|
||||
if example.want {
|
||||
personal = append(personal, example.vector)
|
||||
} else {
|
||||
world = append(world, example.vector)
|
||||
}
|
||||
}
|
||||
head, ok := trainPersonalBoundaryLinearHead(personal, world)
|
||||
if !ok {
|
||||
t.Fatalf("fold %d rejected valid vectors", omitted)
|
||||
}
|
||||
for _, example := range examples {
|
||||
if example.shape != omitted {
|
||||
continue
|
||||
}
|
||||
if got := head.logit(example.vector) > 0; got != example.want {
|
||||
t.Errorf("fold %d classified %v as personal=%v, want %v", omitted, example.vector, got, example.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryTrainingCorpusIsIndependent(t *testing.T) {
|
||||
// The strict stratified fixture already enforces this for its 72 rows. The
|
||||
// historical regression table lives here, so protect it here too: a future
|
||||
// seed addition must not copy a regression sentence into training.
|
||||
training := make(map[string]bool, len(personalSeeds)+len(worldSeeds))
|
||||
for _, seed := range append(append([]string(nil), personalSeeds...), worldSeeds...) {
|
||||
training[normalizePersonalBoundaryTraining(seed)] = true
|
||||
}
|
||||
for _, regression := range []string{
|
||||
"что я говорил про бэкапы?",
|
||||
"что я сказал вчера про отпуск",
|
||||
"я писал что-нибудь про сервер",
|
||||
"я упоминал про конференцию?",
|
||||
"что я отмечал по поводу переезда",
|
||||
"я рассказывал тебе про новую работу?",
|
||||
"во сколько у меня встреча",
|
||||
"когда мой следующий отпуск",
|
||||
"what did i say about backups",
|
||||
"did i tell you about the doctor",
|
||||
"как я говорил, почему небо синее",
|
||||
"как уже я говорил, какая столица франции",
|
||||
"почему трава зелёная",
|
||||
"столица франции",
|
||||
"как мне сварить борщ",
|
||||
"что мне посмотреть вечером",
|
||||
"я хочу узнать про рим",
|
||||
"кто такой гагарин",
|
||||
"how do i boil an egg",
|
||||
"во сколько закат сегодня",
|
||||
"когда сегодня заканчивается концерт",
|
||||
"во сколько завтра открывается аптека",
|
||||
"какой сегодня праздник",
|
||||
"что интересного произошло сегодня в мире",
|
||||
"кто выиграл вчера матч",
|
||||
"расскажи про эверест",
|
||||
"расскажи про войну 1812 года",
|
||||
"объясни что такое инфляция",
|
||||
"я рассказывал тебе про байкал?",
|
||||
} {
|
||||
if training[normalizePersonalBoundaryTraining(regression)] {
|
||||
t.Errorf("regression utterance leaked into training: %q", regression)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryFrozenHeadDecodes(t *testing.T) {
|
||||
head, ok := frozenPersonalBoundaryHead()
|
||||
if !ok {
|
||||
t.Fatal("frozen head did not decode")
|
||||
}
|
||||
if len(head.weights) != 384 {
|
||||
t.Fatalf("frozen head has %d weights, want 384", len(head.weights))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBoundaryHashFloorFitsAndScores(t *testing.T) {
|
||||
b := &personalBoundary{}
|
||||
embedder := router.NewHashEmbedder(1024)
|
||||
query, err := router.EmbedQuery(context.Background(), embedder, "когда моя встреча")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.load(context.Background(), embedder)
|
||||
if _, _, ok := b.score(query); !ok {
|
||||
t.Fatal("hash-floor boundary declined to score")
|
||||
}
|
||||
if len(b.head.weights) != 1024 {
|
||||
t.Fatalf("hash-floor boundary has %d weights, want 1024", len(b.head.weights))
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkPersonalBoundaryHashFloorFitAndScore keeps startup cost measurable
|
||||
// without making ambient CI load a correctness condition. In particular,
|
||||
// -race and coverage instrumentation both multiply the cost of this numeric
|
||||
// training loop; the functional test above is the deterministic gate.
|
||||
func BenchmarkPersonalBoundaryHashFloorFitAndScore(b *testing.B) {
|
||||
embedder := router.NewHashEmbedder(1024)
|
||||
query, err := router.EmbedQuery(context.Background(), embedder, "когда моя встреча")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
boundary := &personalBoundary{}
|
||||
boundary.load(context.Background(), embedder)
|
||||
if _, _, ok := boundary.score(query); !ok {
|
||||
b.Fatal("hash-floor boundary declined to score")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePersonalBoundaryTraining(value string) string {
|
||||
return strings.Join(strings.Fields(strings.ToLower(value)), " ")
|
||||
}
|
||||
|
||||
// A handler with no embedder never loads the seeds, so the boundary falls back
|
||||
// to the possession markers. That is the offline floor and it must keep working
|
||||
// — an embedder that fails to load must not open the boundary.
|
||||
@@ -68,6 +285,27 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
||||
{"я хочу узнать про рим", false},
|
||||
{"кто такой гагарин", false},
|
||||
{"how do i boil an egg", false},
|
||||
// Asking when a public thing happens (Vikunja #553). "во сколько закат
|
||||
// сегодня" was answered "не знаю — не нашла у тебя такой записи",
|
||||
// because the frame lived only on the personal side. The pair above it
|
||||
// is the control: "во сколько у меня встреча" is the same frame about
|
||||
// something that IS his, and it has to stay personal.
|
||||
{"во сколько закат сегодня", false},
|
||||
{"когда сегодня заканчивается концерт", false},
|
||||
{"во сколько завтра открывается аптека", false},
|
||||
// The "какой сегодня X" frame. These clear the weather topic after the
|
||||
// V-553 seeds and were then refused here, which is the same defect one
|
||||
// source further down the chain.
|
||||
{"какой сегодня праздник", false},
|
||||
{"что интересного произошло сегодня в мире", false},
|
||||
{"кто выиграл вчера матч", false},
|
||||
// The narrative shape, held out from the seeds above (Vikunja #554).
|
||||
// The control is the row after them: the same verb about his own words
|
||||
// is still his.
|
||||
{"расскажи про эверест", false},
|
||||
{"расскажи про войну 1812 года", false},
|
||||
{"объясни что такое инфляция", false},
|
||||
{"я рассказывал тебе про байкал?", true},
|
||||
}
|
||||
|
||||
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||
@@ -92,3 +330,419 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
||||
}
|
||||
t.Logf("personal boundary: %d/%d held-out utterances correct", len(cases)-wrong, len(cases))
|
||||
}
|
||||
|
||||
func TestONNXPersonalBoundaryFourFold(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
ctx := context.Background()
|
||||
embedAll := func(values []string) [][]float32 {
|
||||
vectors := make([][]float32, len(values))
|
||||
for i, value := range values {
|
||||
vector, err := router.EmbedQuery(ctx, emb, value)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", value, err)
|
||||
}
|
||||
vectors[i] = vector
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
personalVectors := embedAll(personalSeeds)
|
||||
worldVectors := embedAll(worldSeeds)
|
||||
|
||||
type group struct {
|
||||
name string
|
||||
personalStart, personalCount int
|
||||
worldStart, worldCount int
|
||||
}
|
||||
groups := []group{
|
||||
{name: "remembered_speech", personalStart: 8, personalCount: 8, worldStart: 20, worldCount: 8},
|
||||
{name: "possession", personalStart: 16, personalCount: 10, worldStart: 28, worldCount: 12},
|
||||
{name: "narrative", personalStart: 26, personalCount: 8, worldStart: 40, worldCount: 8},
|
||||
{name: "first_person_preamble", personalStart: 34, personalCount: 8, worldStart: 48, worldCount: 8},
|
||||
{name: "advice_current_info", personalStart: 42, personalCount: 8, worldStart: 56, worldCount: 8},
|
||||
{name: "public_proper_nouns", personalStart: 50, personalCount: 10, worldStart: 64, worldCount: 8},
|
||||
}
|
||||
|
||||
const foldCount = 4
|
||||
aggregateCorrect, aggregateTotal := 0, 0
|
||||
for omittedFold := 0; omittedFold < foldCount; omittedFold++ {
|
||||
trainingPersonal := append([][]float32(nil), personalVectors[:8]...)
|
||||
trainingWorld := append([][]float32(nil), worldVectors[:20]...)
|
||||
var heldPersonal, heldWorld [][]float32
|
||||
partition := func(vectors [][]float32, start, count int, training, held *[][]float32) {
|
||||
for relative, vector := range vectors[start : start+count] {
|
||||
if relative%foldCount == omittedFold {
|
||||
*held = append(*held, vector)
|
||||
} else {
|
||||
*training = append(*training, vector)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, group := range groups {
|
||||
partition(personalVectors, group.personalStart, group.personalCount, &trainingPersonal, &heldPersonal)
|
||||
partition(worldVectors, group.worldStart, group.worldCount, &trainingWorld, &heldWorld)
|
||||
}
|
||||
head, ok := trainPersonalBoundaryLinearHead(
|
||||
trainingPersonal,
|
||||
trainingWorld,
|
||||
)
|
||||
if !ok {
|
||||
t.Fatalf("fold %d: valid training fold rejected", omittedFold)
|
||||
}
|
||||
correct, total := 0, 0
|
||||
for _, vector := range heldPersonal {
|
||||
total++
|
||||
if head.logit(vector) > 0 {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
for _, vector := range heldWorld {
|
||||
total++
|
||||
if head.logit(vector) <= 0 {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
t.Logf("fold %d: %d/%d held-out training examples", omittedFold+1, correct, total)
|
||||
aggregateCorrect += correct
|
||||
aggregateTotal += total
|
||||
}
|
||||
t.Logf("four-fold aggregate: %d/%d", aggregateCorrect, aggregateTotal)
|
||||
if aggregateCorrect < 99 {
|
||||
t.Errorf("four-fold aggregate %d/%d, want at least 99/104", aggregateCorrect, aggregateTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestONNXPersonalBoundarySemanticGroupHoldout(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
ctx := context.Background()
|
||||
embedAll := func(values []string) [][]float32 {
|
||||
vectors := make([][]float32, len(values))
|
||||
for i, value := range values {
|
||||
vector, err := router.EmbedQuery(ctx, emb, value)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", value, err)
|
||||
}
|
||||
vectors[i] = vector
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
personalVectors := embedAll(personalSeeds)
|
||||
worldVectors := embedAll(worldSeeds)
|
||||
|
||||
type group struct {
|
||||
name string
|
||||
personalStart, personalCount int
|
||||
worldStart, worldCount int
|
||||
}
|
||||
groups := []group{
|
||||
{name: "remembered_speech", personalStart: 8, personalCount: 8, worldStart: 20, worldCount: 8},
|
||||
{name: "possession", personalStart: 16, personalCount: 10, worldStart: 28, worldCount: 12},
|
||||
{name: "narrative", personalStart: 26, personalCount: 8, worldStart: 40, worldCount: 8},
|
||||
{name: "first_person_preamble", personalStart: 34, personalCount: 8, worldStart: 48, worldCount: 8},
|
||||
{name: "advice_current_info", personalStart: 42, personalCount: 8, worldStart: 56, worldCount: 8},
|
||||
{name: "public_proper_nouns", personalStart: 50, personalCount: 10, worldStart: 64, worldCount: 8},
|
||||
}
|
||||
|
||||
aggregateCorrect, aggregateTotal := 0, 0
|
||||
for _, omitted := range groups {
|
||||
excluding := func(vectors [][]float32, start, count int) [][]float32 {
|
||||
result := make([][]float32, 0, len(vectors)-count)
|
||||
result = append(result, vectors[:start]...)
|
||||
return append(result, vectors[start+count:]...)
|
||||
}
|
||||
head, ok := trainPersonalBoundaryLinearHead(
|
||||
excluding(personalVectors, omitted.personalStart, omitted.personalCount),
|
||||
excluding(worldVectors, omitted.worldStart, omitted.worldCount),
|
||||
)
|
||||
if !ok {
|
||||
t.Fatalf("%s: valid training fold rejected", omitted.name)
|
||||
}
|
||||
correct, total := 0, 0
|
||||
for _, vector := range personalVectors[omitted.personalStart : omitted.personalStart+omitted.personalCount] {
|
||||
total++
|
||||
if head.logit(vector) > 0 {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
for _, vector := range worldVectors[omitted.worldStart : omitted.worldStart+omitted.worldCount] {
|
||||
total++
|
||||
if head.logit(vector) <= 0 {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
t.Logf("leave %-21s out: %d/%d", omitted.name, correct, total)
|
||||
aggregateCorrect += correct
|
||||
aggregateTotal += total
|
||||
// Whole-shape holdout is an honest diagnostic, not a 100% release gate:
|
||||
// some shapes (notably private-vs-general possession) define a distinct
|
||||
// semantic ambiguity. The separately authored challenge set remains the
|
||||
// strict generalisation gate.
|
||||
}
|
||||
if aggregateCorrect < 92 {
|
||||
t.Errorf("whole-shape aggregate %d/%d, want at least 92/104", aggregateCorrect, aggregateTotal)
|
||||
}
|
||||
}
|
||||
|
||||
// This challenge set was originally authored after the six-shape training
|
||||
// corpus and the 72-case matrix were frozen. Its sole miss then informed the
|
||||
// regularisation comparison, so it is now a strict regression gate rather than
|
||||
// independent evidence. It remains outside the production corpus.
|
||||
func TestONNXPersonalBoundaryChallenge(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
|
||||
cases := []struct {
|
||||
utterance string
|
||||
personal bool
|
||||
}{
|
||||
{"какой пароль приложения я задал для почтового клиента?", true},
|
||||
{"на каком порту я решил поднять тестовый сервис?", true},
|
||||
{"какую причину я указал, когда отменил бронь?", true},
|
||||
{"где в гараже я сложил зимние шины?", true},
|
||||
{"какой сериал я бросил после второго сезона?", true},
|
||||
{"о чём мы договорились с Олегом на прошлой неделе?", true},
|
||||
{"почему мой монитор мерцает при частоте 144 герца?", false},
|
||||
{"подойдёт ли кабель Thunderbolt 3 к разъёму USB4?", false},
|
||||
{"как вывести запах дыма из моей куртки?", false},
|
||||
{"что означают кольца на флаге Олимпиады?", false},
|
||||
{"почему после дождя на асфальте видна радуга?", false},
|
||||
{"какой формат файлов поддерживает Kindle Paperwhite?", false},
|
||||
{"which SSH key did I install on the build server?", true},
|
||||
{"what spending limit did I set for the travel card?", true},
|
||||
{"where did I store the spare apartment fob?", true},
|
||||
{"which objection did I raise during the design review?", true},
|
||||
{"what route did I plan for the Sunday hike?", true},
|
||||
{"when did I promise Maya I would send the draft?", true},
|
||||
{"why does my mechanical keyboard sometimes chatter?", false},
|
||||
{"can my USB-C charger safely power a Steam Deck?", false},
|
||||
{"how do I stop condensation inside my camera lens?", false},
|
||||
{"what caused the Tacoma Narrows Bridge to collapse?", false},
|
||||
{"why are some auroras red instead of green?", false},
|
||||
{"which codecs does the current Firefox release support?", false},
|
||||
}
|
||||
|
||||
b := &personalBoundary{}
|
||||
b.load(context.Background(), emb)
|
||||
correct := 0
|
||||
minimumMargin := math.Inf(1)
|
||||
for _, testCase := range cases {
|
||||
vector, err := router.EmbedQuery(context.Background(), emb, testCase.utterance)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", testCase.utterance, err)
|
||||
}
|
||||
personal, world, ok := b.score(vector)
|
||||
if !ok {
|
||||
t.Fatal("loaded boundary declined to score")
|
||||
}
|
||||
got := personal > world
|
||||
signedMargin := personal - world
|
||||
if !testCase.personal {
|
||||
signedMargin = -signedMargin
|
||||
}
|
||||
if signedMargin < minimumMargin {
|
||||
minimumMargin = signedMargin
|
||||
}
|
||||
if got == testCase.personal {
|
||||
correct++
|
||||
} else {
|
||||
t.Logf("miss %q: personal=%v want %v (%.4f/%.4f)", testCase.utterance, got, testCase.personal, personal, world)
|
||||
}
|
||||
}
|
||||
t.Logf("regularisation challenge: %d/%d, minimum signed margin %+.4f", correct, len(cases), minimumMargin)
|
||||
if correct != len(cases) {
|
||||
t.Errorf("regularisation challenge %d/%d, want every case correct", correct, len(cases))
|
||||
}
|
||||
}
|
||||
|
||||
// TestONNXPersonalBoundaryPostRetuneChallenge was authored only after the L2
|
||||
// coefficient and frozen head had been selected using corpus cross-validation.
|
||||
// It deliberately returns to private configuration, commitments and stored
|
||||
// choices with new objects, and contrasts them with public technical facts,
|
||||
// compatibility and maintenance. No result from this table may be used to
|
||||
// tune the current head; a miss is evidence for the next independently
|
||||
// evaluated model revision.
|
||||
func TestONNXPersonalBoundaryPostRetuneChallenge(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
|
||||
cases := []struct {
|
||||
utterance string
|
||||
personal bool
|
||||
}{
|
||||
{"какое имя я выбрал для гостевой сети Wi-Fi?", true},
|
||||
{"на какой день я перенёс техосмотр машины?", true},
|
||||
{"какую сумму мы с Мариной согласовали за ремонт кухни?", true},
|
||||
{"где я сохранил резервные коды от GitHub?", true},
|
||||
{"какой из макетов визитки я одобрил?", true},
|
||||
{"что я решил делать со страховкой перед поездкой?", true},
|
||||
{"какой диапазон частот использует Wi-Fi 6E?", false},
|
||||
{"почему OLED-экраны со временем выгорают?", false},
|
||||
{"можно ли подключить монитор DisplayPort к Thunderbolt 4?", false},
|
||||
{"чем безопасно чистить замшевые ботинки?", false},
|
||||
{"когда появился протокол WebSocket?", false},
|
||||
{"почему соль ускоряет таяние льда?", false},
|
||||
{"which hostname did I assign to the home NAS?", true},
|
||||
{"what date did I move the annual checkup to?", true},
|
||||
{"where did I save the recovery phrase for the hardware wallet?", true},
|
||||
{"which catering quote did we accept for the party?", true},
|
||||
{"what did I decide about renewing the domain?", true},
|
||||
{"which paint sample did I approve for the hallway?", true},
|
||||
{"does Wi-Fi 7 work with older wireless clients?", false},
|
||||
{"why can an SSD slow down when it is nearly full?", false},
|
||||
{"how should suede shoes be cleaned?", false},
|
||||
{"when was the WebSocket protocol standardized?", false},
|
||||
{"what does a hardware-wallet recovery phrase do?", false},
|
||||
{"why does road salt damage concrete?", false},
|
||||
}
|
||||
|
||||
b := &personalBoundary{}
|
||||
b.load(context.Background(), emb)
|
||||
correct := 0
|
||||
minimumMargin := math.Inf(1)
|
||||
for _, testCase := range cases {
|
||||
vector, err := router.EmbedQuery(context.Background(), emb, testCase.utterance)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", testCase.utterance, err)
|
||||
}
|
||||
personal, world, ok := b.score(vector)
|
||||
if !ok {
|
||||
t.Fatal("loaded boundary declined to score")
|
||||
}
|
||||
got := personal > world
|
||||
signedMargin := personal - world
|
||||
if !testCase.personal {
|
||||
signedMargin = -signedMargin
|
||||
}
|
||||
if signedMargin < minimumMargin {
|
||||
minimumMargin = signedMargin
|
||||
}
|
||||
if got == testCase.personal {
|
||||
correct++
|
||||
} else {
|
||||
t.Logf("miss %q: personal=%v want %v (%.4f/%.4f)", testCase.utterance, got, testCase.personal, personal, world)
|
||||
}
|
||||
}
|
||||
t.Logf("post-retune challenge: %d/%d, minimum signed margin %+.4f", correct, len(cases), minimumMargin)
|
||||
if correct != len(cases) {
|
||||
t.Errorf("post-retune challenge %d/%d, want every case correct", correct, len(cases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestONNXPersonalBoundaryLatency(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
ctx := context.Background()
|
||||
query, err := router.EmbedQuery(ctx, emb, "что я решил насчёт переезда?")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
b := &personalBoundary{}
|
||||
coldStart := time.Now()
|
||||
b.load(ctx, emb)
|
||||
if _, _, ok := b.score(query); !ok {
|
||||
t.Fatal("loaded boundary declined to score")
|
||||
}
|
||||
cold := time.Since(coldStart)
|
||||
|
||||
const iterations = 100000
|
||||
steadyStart := time.Now()
|
||||
for i := 0; i < iterations; i++ {
|
||||
if _, _, ok := b.score(query); !ok {
|
||||
t.Fatal("loaded boundary declined to score")
|
||||
}
|
||||
}
|
||||
steady := time.Since(steadyStart) / iterations
|
||||
t.Logf("boundary cold load+train+score: %s; steady score: %s/op", cold, steady)
|
||||
// This is a user-visible first-turn path. Keep a generous ceiling to avoid
|
||||
// noisy CI while making an accidental per-turn training/load regression
|
||||
// unmistakable.
|
||||
if cold > 5*time.Second {
|
||||
t.Errorf("cold boundary load %s exceeds 5s local usability ceiling", cold)
|
||||
}
|
||||
if steady > 100*time.Microsecond {
|
||||
t.Errorf("steady boundary score %s exceeds 100µs ceiling", steady)
|
||||
}
|
||||
}
|
||||
|
||||
func TestONNXPersonalBoundaryFrozenHeadMatchesCorpusFit(t *testing.T) {
|
||||
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||
if lib == "" {
|
||||
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
|
||||
}
|
||||
dir := filepath.Join("../..", "models/embedder/multilingual-e5-small")
|
||||
emb, err := router.NewONNXEmbedder(filepath.Join(dir, "model_quantized.onnx"), filepath.Join(dir, "tokenizer.json"), lib)
|
||||
if err != nil {
|
||||
t.Skipf("onnx embedder unavailable: %v", err)
|
||||
}
|
||||
defer emb.Close()
|
||||
ctx := context.Background()
|
||||
embedAll := func(values []string) [][]float32 {
|
||||
vectors := make([][]float32, len(values))
|
||||
for i, value := range values {
|
||||
vector, err := router.EmbedQuery(ctx, emb, value)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", value, err)
|
||||
}
|
||||
vectors[i] = vector
|
||||
}
|
||||
return vectors
|
||||
}
|
||||
fitted, ok := trainPersonalBoundaryLinearHead(embedAll(personalSeeds), embedAll(worldSeeds))
|
||||
if !ok {
|
||||
t.Fatal("corpus fit failed")
|
||||
}
|
||||
frozen, ok := frozenPersonalBoundaryHead()
|
||||
if !ok {
|
||||
t.Fatal("frozen head did not decode")
|
||||
}
|
||||
if math.Abs(fitted.bias-frozen.bias) > 1e-9 {
|
||||
t.Fatalf("frozen bias %.12f != fitted %.12f", frozen.bias, fitted.bias)
|
||||
}
|
||||
for i := range fitted.weights {
|
||||
if math.Abs(fitted.weights[i]-frozen.weights[i]) > 5e-7 {
|
||||
t.Fatalf("frozen weight %d %.12f != fitted %.12f", i, frozen.weights[i], fitted.weights[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A Praxis lifecycle failure used to return a hardcoded constant that named the
|
||||
// verb and never the service, so an outage, a refused token and a contract
|
||||
// mismatch all produced the identical sentence (Vikunja #588). The helpers and
|
||||
// the Hexis half of the same defect are in ecosystem_gap_test.go.
|
||||
|
||||
func TestPraxisLifecycle401NamesPraxis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "item_1"))
|
||||
if !strings.Contains(reply, servicePraxis) {
|
||||
t.Fatalf("praxis failure does not name Praxis: %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, phraser.A(phraser.EcoDenied, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("401 from praxis: got %q, want the denied line", reply)
|
||||
}
|
||||
// The verb that did not happen is still said: the trace is the only other
|
||||
// place it exists and he is not reading the trace.
|
||||
if !strings.Contains(reply, "не получилось отметить сделанным.") {
|
||||
t.Errorf("reply dropped the operation that failed: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisLifecycleOutageDiffersFrom401 — the identity that was the bug.
|
||||
func TestPraxisLifecycleOutageDiffersFrom401(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
refused := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
|
||||
h.ecosystem = &ecosystemWiring{praxis: newPraxisClient(unreachableURL)}
|
||||
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
|
||||
if refused == outage {
|
||||
t.Fatalf("a refused token and an outage still say the same thing: %q", refused)
|
||||
}
|
||||
if !strings.Contains(outage, phraser.A(phraser.EcoDown, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("praxis outage: got %q, want the outage line naming Praxis", outage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "отметь второй пункт" names a position, and only the daemon knows which item
|
||||
// that is. The router fills the value slot with "2"; this is where it becomes an
|
||||
// item id (Vikunja #516).
|
||||
func TestPositionResolvesAgainstTheLastSpokenList(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[
|
||||
{"id":"item_a","title":"диск заканчивается"},
|
||||
{"id":"item_b","title":"бэкап не прошёл"},
|
||||
{"id":"item_c","title":"сертификат истекает"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")); reply == "" {
|
||||
t.Fatal("attention returned nothing")
|
||||
}
|
||||
|
||||
cases := []struct{ ref, wantItem string }{
|
||||
{"2", "item_b"},
|
||||
{"1", "item_a"},
|
||||
{"last", "item_c"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", c.ref))
|
||||
if !strings.Contains(reply, "принято") {
|
||||
t.Errorf("ref %q: reply %q", c.ref, reply)
|
||||
}
|
||||
if !requestedPathContaining(praxis, c.wantItem) {
|
||||
t.Errorf("ref %q did not acknowledge %s; paths %v", c.ref, c.wantItem, paths(praxis))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A position past the end must not acknowledge the wrong item. It asks.
|
||||
func TestPositionPastTheEndAsksInsteadOfGuessing(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск заканчивается"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "4"))
|
||||
if !strings.Contains(reply, "какой пункт") {
|
||||
t.Errorf("a position with no item should ask, got %q", reply)
|
||||
}
|
||||
if requestedPathContaining(praxis, "item_a") {
|
||||
t.Error("the only surfaced item was resolved for a position that did not name it")
|
||||
}
|
||||
}
|
||||
|
||||
// No digest yet means no positions. Nothing is mutated.
|
||||
func TestPositionWithNoSpokenListAsks(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||
if !strings.Contains(reply, "какой пункт") {
|
||||
t.Errorf("want the ask, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit id is not a position and passes through untouched.
|
||||
func TestExplicitItemIDIsNotRewritten(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("pin_item", "item_zz"))
|
||||
if !requestedPathContaining(praxis, "item_zz") {
|
||||
t.Errorf("the id he gave was not the one called; paths %v", paths(praxis))
|
||||
}
|
||||
}
|
||||
|
||||
// An item Praxis sent without a title is never spoken, so it holds no position.
|
||||
func TestUnspokenItemsHoldNoPosition(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[
|
||||
{"id":"item_silent"},
|
||||
{"id":"item_said","title":"бэкап не прошёл"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||
if !requestedPathContaining(praxis, "item_said") {
|
||||
t.Errorf("position 1 is the first item she SAID; paths %v", paths(praxis))
|
||||
}
|
||||
}
|
||||
|
||||
// The item id travels in the POST body, so that is what these read.
|
||||
func paths(f *fakeServer) []string {
|
||||
var out []string
|
||||
for _, r := range f.Requests() {
|
||||
out = append(out, r.Path+" "+string(r.Body))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func requestedPathContaining(f *fakeServer, want string) bool {
|
||||
for _, r := range f.Requests() {
|
||||
if strings.Contains(string(r.Body), want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// "отметь это как сделанное" after a one-item digest points at that item.
|
||||
func TestDemonstrativeResolvesWhenOneItemWasSpoken(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[{"id":"item_only","title":"бэкап не прошёл"}]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "this"))
|
||||
if !strings.Contains(reply, "принято") {
|
||||
t.Errorf("reply %q", reply)
|
||||
}
|
||||
if !requestedPathContaining(praxis, "item_only") {
|
||||
t.Errorf("the one surfaced item was not acknowledged; paths %v", paths(praxis))
|
||||
}
|
||||
}
|
||||
|
||||
// Pointing at one of several is a guess, and a wrong guess transitions the wrong
|
||||
// item. The turn goes back to the cascade instead.
|
||||
func TestDemonstrativeWithSeveralItemsGivesTheTurnBack(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[
|
||||
{"id":"item_a","title":"диск"},
|
||||
{"id":"item_b","title":"бэкап"}
|
||||
]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||
t.Errorf("want a fall-through, got %q", reply)
|
||||
}
|
||||
for _, p := range paths(praxis) {
|
||||
if strings.Contains(p, "resolve") {
|
||||
t.Error("an ambiguous demonstrative resolved an item anyway")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// "я это сделал" with no digest behind it is a sentence about his day.
|
||||
func TestDemonstrativeWithNoDigestGivesTheTurnBack(t *testing.T) {
|
||||
praxis := newFakePraxis(t, `[]`)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||
t.Errorf("want a fall-through, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// The row is the whole mechanism, and nothing wrote it (Vikunja #532).
|
||||
//
|
||||
// The existing hysteresis test in internal/store scores the pure function and
|
||||
// passed throughout, which is exactly why this went unnoticed: Resolve was
|
||||
// always correct and was always handed the cold-start Away. So this test asserts
|
||||
// the round trip — the tick writes what gather resolved, and the next load
|
||||
// reads it back — rather than re-testing the function.
|
||||
func TestTickPersistsTheResolvedBucket(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
|
||||
// Cold start: no row, so a load must say Away and the zero time.
|
||||
b, score, updated, err := st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load before: %v", err)
|
||||
}
|
||||
if b != store.Away || score != 0 || !updated.IsZero() {
|
||||
t.Fatalf("cold start = %s/%v/%v, want away/0/zero", b, score, updated)
|
||||
}
|
||||
|
||||
tl := &tickLoop{store: st}
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
|
||||
|
||||
b, score, updated, err = st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load after: %v", err)
|
||||
}
|
||||
if b != store.Present {
|
||||
t.Errorf("bucket = %s, want present", b)
|
||||
}
|
||||
if score != 0.9 {
|
||||
t.Errorf("score = %v, want 0.9", score)
|
||||
}
|
||||
if updated.IsZero() {
|
||||
t.Error("updated_ts was not written, so /dash still reads (never)")
|
||||
}
|
||||
}
|
||||
|
||||
// The singleton stays a singleton, and a later tick overwrites rather than
|
||||
// accumulating. A row per tick would make LoadPresenceState's single-row query
|
||||
// return whichever one SQLite felt like.
|
||||
func TestPresenceStateIsOverwrittenNotAppended(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
tl := &tickLoop{store: st}
|
||||
now := time.Now()
|
||||
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Away, PresenceScore: 0.1}, now.Add(time.Minute))
|
||||
|
||||
b, score, _, err := st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if b != store.Away || score != 0.1 {
|
||||
t.Fatalf("got %s/%v, want the second write (away/0.1)", b, score)
|
||||
}
|
||||
}
|
||||
|
||||
// What the persisted row buys: the hold band. A score sitting between Exit and
|
||||
// Enter holds Present when the last bucket was Present, and stays Away when it
|
||||
// was Away. Before the write existed the second arm was the only one that could
|
||||
// ever run, so presence dropped at roughly four minutes of idle instead of
|
||||
// holding to the exit threshold at about nine.
|
||||
func TestPersistedBucketIsWhatFeedsHysteresis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
tl := &tickLoop{store: st}
|
||||
|
||||
mid := (store.PresenceExit + store.PresenceEnter) / 2
|
||||
if mid <= store.PresenceExit || mid >= store.PresenceEnter {
|
||||
t.Fatalf("%v is not inside the hold band", mid)
|
||||
}
|
||||
|
||||
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, time.Now())
|
||||
last, _, _, err := st.LoadPresenceState(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got := store.Resolve(mid, last); got != store.Present {
|
||||
t.Errorf("Resolve(%v, %s) = %s, want present — the hold band did not apply", mid, last, got)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,11 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -31,6 +34,57 @@ func (f *fixedEmbedder) Embed(_ context.Context, text string) ([]float32, error)
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// brokenEmbedder fails every call, which is what an ONNX session error looks
|
||||
// like from the query chain's side.
|
||||
type brokenEmbedder struct{}
|
||||
|
||||
func (brokenEmbedder) Dim() int { return 4 }
|
||||
func (brokenEmbedder) Close() error { return nil }
|
||||
func (brokenEmbedder) Embed(context.Context, string) ([]float32, error) {
|
||||
return nil, errors.New("onnx: session failed")
|
||||
}
|
||||
|
||||
// TestQueryEmbedFailureDoesNotStopTheChain — V-568. The embed source used to
|
||||
// claim the turn on an embedder error, so one failing EmbedQuery answered every
|
||||
// question below it with "не смогла ответить", including the ones the search
|
||||
// answers without an embedder at all. A source that could not look must pass.
|
||||
func TestQueryEmbedFailureDoesNotStopTheChain(t *testing.T) {
|
||||
const q = "почему небо голубое"
|
||||
|
||||
h, _ := searchHandler(t, searchBody, http.StatusOK)
|
||||
h.api = ipc.NewStoreAPI(newTestStore(t))
|
||||
h.recall = recallWiring{embedder: brokenEmbedder{}, minScore: 0.55, minMargin: 0.008}
|
||||
h.now = time.Now
|
||||
|
||||
// The embed source itself passes rather than claiming.
|
||||
turn := &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: q}}
|
||||
if reply, ok := h.queryEmbed(context.Background(), turn); ok {
|
||||
t.Fatalf("queryEmbed claimed the turn on an embedder error: %q", reply)
|
||||
}
|
||||
|
||||
// And the whole chain still reaches the search below it.
|
||||
reply := askQuery(t, h, q)
|
||||
if !strings.Contains(reply, "рэлеевского рассеяния") {
|
||||
t.Fatalf("reply = %q, want the search answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The recall sources read the empty vector the failed embed left behind, and
|
||||
// neither of them may turn that into an answer: no vector means they could not
|
||||
// look, which is not the same as looking and finding nothing.
|
||||
func TestQueryRecallPassesWithoutAVector(t *testing.T) {
|
||||
h, _ := buildRecallHandler(t, "где молоко", []recallCase{
|
||||
{text: "молоко стоит в холодильнике", score: 0.90, kind: "note"},
|
||||
})
|
||||
turn := &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: "где молоко"}}
|
||||
if reply, ok := h.queryMemory(context.Background(), turn); ok {
|
||||
t.Errorf("queryMemory claimed with no vector: %q", reply)
|
||||
}
|
||||
if reply, ok := h.queryNotes(context.Background(), turn); ok {
|
||||
t.Errorf("queryNotes claimed with no vector: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// scoreVec builds a unit vector whose cosine against the query vector
|
||||
// (1,0,0,0) is exactly score.
|
||||
func scoreVec(score float64) []float32 {
|
||||
@@ -139,9 +193,9 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
||||
t.Run("the better-matching fact answers", func(t *testing.T) {
|
||||
h, _ := buildRecallHandler(t, q, []recallCase{
|
||||
{text: "молоко стоит в холодильнике", score: 0.80, kind: "note"},
|
||||
{text: "купил молоко в среду", score: 0.95, kind: "fact"},
|
||||
{text: "молоко было в холодильнике в среду", score: 0.95, kind: "fact"},
|
||||
})
|
||||
if reply := askQuery(t, h, q); reply != "купил молоко в среду" {
|
||||
if reply := askQuery(t, h, q); reply != "молоко было в холодильнике в среду" {
|
||||
t.Errorf("reply %q, want the fact read back", reply)
|
||||
}
|
||||
})
|
||||
@@ -158,3 +212,63 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestQueryRecallRequiresStructuralOrTopicEvidence — the whole-assistant
|
||||
// cold-start regression. The routing heads called an ordinary past-tense
|
||||
// report a query; with one note in the store the margin gate has no runner-up,
|
||||
// and cosine 0.825 was enough to speak a completely unrelated spare-key note.
|
||||
// A bare question mark does not turn the proposition into an open information
|
||||
// question, negation must not weaken the refusal, and a locative question must
|
||||
// corroborate the target it asks Maven to locate (V-719).
|
||||
func TestQueryRecallRequiresStructuralOrTopicEvidence(t *testing.T) {
|
||||
const unrelated = "запомни: запасной ключ лежит в синей коробке"
|
||||
for _, tc := range []struct {
|
||||
query string
|
||||
score float64
|
||||
}{
|
||||
{"я отменил напоминание про молоко", 0.825031306},
|
||||
{"я отменил напоминание про молоко?", 0.825031306},
|
||||
{"я не отменил напоминание про молоко", 0.825031306},
|
||||
{"я не отменил напоминание про молоко?", 0.825031306},
|
||||
{"где мой паспорт?", 0.817210},
|
||||
{"где я отменил напоминание про молоко?", 0.805800},
|
||||
{"где лежит синяя рубашка?", 0.837694},
|
||||
{"где лежит синяя папка?", 0.837472},
|
||||
{"где мой запасной паспорт?", 0.831662},
|
||||
{"где лежит запасная флешка?", 0.838980},
|
||||
{"где находится синяя коробка с документами?", 0.866553},
|
||||
{"где лежит ключ от машины?", 0.843853},
|
||||
{"где синяя коробка?", 0.90},
|
||||
} {
|
||||
t.Run(tc.query, func(t *testing.T) {
|
||||
h, phr := buildRecallHandler(t, tc.query, []recallCase{
|
||||
{text: unrelated, score: tc.score, kind: "note"},
|
||||
})
|
||||
reply := askQuery(t, h, tc.query)
|
||||
if strings.Contains(reply, "запасной ключ") {
|
||||
t.Fatalf("unrelated note escaped into reply %q", reply)
|
||||
}
|
||||
if len(phr.notes) != 0 {
|
||||
t.Fatalf("unrelated note reached the phraser: %q", phr.notes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Voice punctuation is optional. A nominal request with no interrogative
|
||||
// still works when the candidate itself corroborates the named topic.
|
||||
const nominal = "адрес домашнего сервера"
|
||||
h, _ := buildRecallHandler(t, nominal, []recallCase{
|
||||
{text: "домашний сервер на 192.168.1.104", score: 0.90, kind: "note"},
|
||||
})
|
||||
if reply := askQuery(t, h, nominal); !strings.Contains(reply, "домашний сервер") {
|
||||
t.Fatalf("nominal recall lost its shared-topic answer: %q", reply)
|
||||
}
|
||||
|
||||
const locative = "где лежит запасной ключ?"
|
||||
h, _ = buildRecallHandler(t, locative, []recallCase{
|
||||
{text: "запасной ключ лежит в синей коробке", score: 0.90, kind: "note"},
|
||||
})
|
||||
if reply := askQuery(t, h, locative); !strings.Contains(reply, "запасной ключ") {
|
||||
t.Fatalf("locative recall lost its corroborated target: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// The query source that claimed a turn was visible in the daemon log and
|
||||
// nowhere else (V-539). A QA step reading /chat could see a wrong answer but
|
||||
// not tell a wrong answer from a wrongly ordered chain: "почему небо голубое"
|
||||
// answered badly reads the same whether search claimed it, the ZIM did, or the
|
||||
// resident model answered from memory.
|
||||
//
|
||||
// It rides the context rather than a return value because handleText answers
|
||||
// every reach through one string, and threading a second value through the
|
||||
// whole action dispatch would change a signature the mic, telegram and the web
|
||||
// all share. The sink is per turn, created by the caller that wants to read it;
|
||||
// a turn with no sink notes nothing, which is what the mic path does.
|
||||
type querySourceKey struct{}
|
||||
|
||||
// querySourceSink holds the name of the source that claimed one turn. The mutex
|
||||
// is there because a query source may fan out to goroutines of its own, not
|
||||
// because two turns share a sink.
|
||||
type querySourceSink struct {
|
||||
mu sync.Mutex
|
||||
name string
|
||||
}
|
||||
|
||||
func (s *querySourceSink) note(name string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.name = name
|
||||
}
|
||||
|
||||
// Name is the source that claimed, or empty when nothing did or the turn was
|
||||
// not a query at all.
|
||||
func (s *querySourceSink) Name() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.name
|
||||
}
|
||||
|
||||
// withQuerySourceSink returns a context that collects the claiming source, and
|
||||
// the sink to read after the turn has answered.
|
||||
func withQuerySourceSink(ctx context.Context) (context.Context, *querySourceSink) {
|
||||
sink := &querySourceSink{}
|
||||
return context.WithValue(ctx, querySourceKey{}, sink), sink
|
||||
}
|
||||
|
||||
// noteQuerySource records which source claimed the turn. It is a no-op when the
|
||||
// caller did not ask for one.
|
||||
func noteQuerySource(ctx context.Context, name string) {
|
||||
if sink, ok := ctx.Value(querySourceKey{}).(*querySourceSink); ok {
|
||||
sink.note(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuerySourceSinkCollectsTheClaimingName(t *testing.T) {
|
||||
ctx, sink := withQuerySourceSink(context.Background())
|
||||
if sink.Name() != "" {
|
||||
t.Fatalf("a fresh sink names a source: %q", sink.Name())
|
||||
}
|
||||
noteQuerySource(ctx, "kiwix")
|
||||
if got := sink.Name(); got != "kiwix" {
|
||||
t.Errorf("sink.Name() = %q, want kiwix", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A turn with no sink must not panic. The mic path asks for no source, and a
|
||||
// query source calls noteQuerySource unconditionally.
|
||||
func TestNoteQuerySourceWithoutASinkIsSilent(t *testing.T) {
|
||||
noteQuerySource(context.Background(), "search")
|
||||
}
|
||||
|
||||
// The last source to claim wins, because only one does: actionQuery returns on
|
||||
// the first claim. This pins that the sink overwrites rather than appends, so a
|
||||
// second turn on the same context could not read a stale name.
|
||||
func TestQuerySourceSinkKeepsTheLastNote(t *testing.T) {
|
||||
ctx, sink := withQuerySourceSink(context.Background())
|
||||
noteQuerySource(ctx, "search")
|
||||
noteQuerySource(ctx, "kiwix")
|
||||
if got := sink.Name(); got != "kiwix" {
|
||||
t.Errorf("sink.Name() = %q, want kiwix", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// The floor, and it is the reason a destination is safe to add at all: a box
|
||||
// whose model is down names nothing, and naming nothing has to walk the chain
|
||||
// the way it walked before the field existed.
|
||||
func TestNoDestinationWalksTheWholeChain(t *testing.T) {
|
||||
walk, skipped := queryWalk(router.SourceUnknown, false)
|
||||
if len(skipped) != 0 {
|
||||
t.Errorf("skipped %d sources with no destination named, want none", len(skipped))
|
||||
}
|
||||
if len(walk) != len(querySources) {
|
||||
t.Fatalf("walk has %d sources, want the whole table of %d", len(walk), len(querySources))
|
||||
}
|
||||
for i := range walk {
|
||||
if walk[i].name != querySources[i].name {
|
||||
t.Fatalf("position %d is %q, want %q", i, walk[i].name, querySources[i].name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The 2026-08-07 defects, one per line. Each is a source that decides by seed
|
||||
// similarity claiming a turn that was never its own, and then answering it
|
||||
// because it has no lookup that could come back empty.
|
||||
func TestANamedDestinationSilencesTheOtherGuessers(t *testing.T) {
|
||||
cases := []struct {
|
||||
dest router.Source
|
||||
utterance string
|
||||
silenced string
|
||||
anchored bool // a stage 0 grammar named the destination
|
||||
}{
|
||||
{router.SourceWorld, "что такое TCP?", "weather", true},
|
||||
{router.SourceWorld, "сколько будет 17 на 23?", "weather", true},
|
||||
{router.SourceWorld, "кто такой Линус Торвальдс?", "personal", true},
|
||||
{router.SourceRecall, "какой у меня любимый язык?", "feeds", false},
|
||||
{router.SourceCalendar, "что в календаре на завтра?", "weather", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
walk, skipped := queryWalk(c.dest, c.anchored)
|
||||
if inWalk(walk, c.silenced) {
|
||||
t.Errorf("%q named %q: %q is still asked", c.utterance, c.dest, c.silenced)
|
||||
}
|
||||
if !inWalk(skipped, c.silenced) {
|
||||
t.Errorf("%q named %q: %q is missing from the record of who was skipped",
|
||||
c.utterance, c.dest, c.silenced)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Naming the world must not send the turn outside. His notes, his facts and the
|
||||
// boundary in front of them are the invariant CLAUDE.md states as "the owner's
|
||||
// data first, then the world", and a destination a model wrote must not be able
|
||||
// to reverse it.
|
||||
func TestNamingTheWorldStillReadsHisDataFirst(t *testing.T) {
|
||||
walk, _ := queryWalk(router.SourceWorld, true)
|
||||
for _, look := range []string{"fact-by-key", "embed", "memory", "notes"} {
|
||||
if !inWalk(walk, look) {
|
||||
t.Errorf("%q was dropped; only the sources that guess may be dropped", look)
|
||||
}
|
||||
}
|
||||
if posOf(walk, "notes") > posOf(walk, "search") {
|
||||
t.Error("search is asked before his notes are")
|
||||
}
|
||||
if posOf(walk, "search") < 0 {
|
||||
t.Fatal("search is not in the walk at all")
|
||||
}
|
||||
}
|
||||
|
||||
// The boundary belongs to his data, so naming recall keeps it. That is what
|
||||
// makes "какой у меня любимый язык?" answer "не нашла у тебя такой записи"
|
||||
// rather than reaching SearXNG once nothing local had it.
|
||||
func TestNamingRecallKeepsTheBoundary(t *testing.T) {
|
||||
walk, _ := queryWalk(router.SourceRecall, true)
|
||||
if !inWalk(walk, "personal") {
|
||||
t.Fatal("the personal boundary was skipped on a turn named for his own data")
|
||||
}
|
||||
if posOf(walk, "personal") > posOf(walk, "search") {
|
||||
t.Error("the boundary no longer sits in front of the world")
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's call of 2026-08-09 (V-666): only a stage 0 grammar may take the
|
||||
// personal boundary off a turn. The routing heads and the resident model both
|
||||
// name a destination by inference, and an inferred SourceWorld would send a
|
||||
// question about him upstream. Every other guesser still goes.
|
||||
func TestOnlyAGrammarMayDropTheBoundary(t *testing.T) {
|
||||
walk, skipped := queryWalk(router.SourceWorld, false)
|
||||
if !inWalk(walk, "personal") {
|
||||
t.Error("an inferred destination took the boundary off the turn")
|
||||
}
|
||||
if !inWalk(skipped, "weather") {
|
||||
t.Error("weather is still asked; the rule covers the boundary alone")
|
||||
}
|
||||
if posOf(walk, "personal") > posOf(walk, "search") {
|
||||
t.Error("the boundary no longer sits in front of the world")
|
||||
}
|
||||
if anchored, _ := queryWalk(router.SourceWorld, true); inWalk(anchored, "personal") {
|
||||
t.Error(`a grammar named the world and the boundary stayed: ` +
|
||||
`"кто такой Линус Торвальдс?" is answered "не нашла у тебя такой записи" again`)
|
||||
}
|
||||
}
|
||||
|
||||
// Whatever the destination, the walk is a subsequence of the table. Every
|
||||
// comment on that table argues an order between two sources, and none of those
|
||||
// reasons is about this field.
|
||||
func TestTheWalkNeverReordersTheTable(t *testing.T) {
|
||||
for _, dest := range append([]router.Source{router.SourceUnknown}, router.Sources...) {
|
||||
walk, skipped := queryWalk(dest, true)
|
||||
if len(walk)+len(skipped) != len(querySources) {
|
||||
t.Errorf("%q: %d walked + %d skipped, want %d", dest, len(walk), len(skipped), len(querySources))
|
||||
}
|
||||
last := -1
|
||||
for _, s := range walk {
|
||||
at := posOf(querySources, s.name)
|
||||
if at <= last {
|
||||
t.Errorf("%q: %q is out of table order", dest, s.name)
|
||||
}
|
||||
last = at
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func inWalk(list []querySource, name string) bool { return posOf(list, name) >= 0 }
|
||||
|
||||
func posOf(list []querySource, name string) int {
|
||||
for i, s := range list {
|
||||
if s.name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
@@ -22,7 +23,7 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
matcher := tool.NewMatcher(api)
|
||||
rtr := buildRouter(emb, matcher, 0.55, nil)
|
||||
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
@@ -44,9 +45,12 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
HasTime: true,
|
||||
},
|
||||
}
|
||||
// The confirmation is phrased from the row now (Vikunja #507), so it
|
||||
// names the stored hour rather than leaving the replier to read one
|
||||
// out of the sentence.
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply != "" {
|
||||
t.Errorf("expected empty reply from applyAction, got %q", reply)
|
||||
if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want {
|
||||
t.Errorf("reply = %q, want %q", reply, want)
|
||||
}
|
||||
reminders, err := st.ListReminders(ctx, 10)
|
||||
if err != nil {
|
||||
@@ -81,12 +85,76 @@ func TestReactiveNotesReminders(t *testing.T) {
|
||||
t.Fatal("expected at least one note, got none")
|
||||
}
|
||||
last := notes[0]
|
||||
if last.Text != "запомни что кофе закончился" {
|
||||
t.Errorf("note text = %q, want %q", last.Text, "запомни что кофе закончился")
|
||||
if last.Text != "кофе закончился" {
|
||||
t.Errorf("note text = %q, want %q", last.Text, "кофе закончился")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRunTurnExplicitNoteStoresOnlyTheBody pins the live failure end to end:
|
||||
// a routed text turn reaches actionNote, stores only the dictated body in both
|
||||
// durable and vector memory, and cannot ask the resident model to choose the
|
||||
// acknowledgement's grammatical gender (V-721).
|
||||
func TestRunTurnExplicitNoteStoresOnlyTheBody(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Date(2026, 8, 15, 8, 0, 0, 0, time.FixedZone("+04", 4*60*60))
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
mem := memory.NewInMemoryStore()
|
||||
rtr := router.New(router.Config{
|
||||
Grammars: []router.Grammar{{
|
||||
Name: "explicit-note-test",
|
||||
Decide: func(string) (router.Decision, bool) {
|
||||
return router.Decision{
|
||||
Stage: 0, Intent: router.IntentNote, Confidence: 1,
|
||||
// Deliberately hostile model slot: neither persistence nor
|
||||
// acknowledgement may use it.
|
||||
Slots: router.Slots{Text: "ты поедешь на дачу"},
|
||||
}, true
|
||||
},
|
||||
}},
|
||||
Threshold: 0.55,
|
||||
})
|
||||
model := &countingCompleter{out: `{"response":"Хорошо, сохранил.","mood":"neutral"}`}
|
||||
h := &reactiveHandler{
|
||||
api: api, router: rtr,
|
||||
recall: recallWiring{embedder: emb, memStore: mem},
|
||||
replier: newLLMReplier(model, nil),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
}
|
||||
|
||||
const utterance = "запомни: запасной ключ лежит в синей коробке"
|
||||
if reply := h.runTurn(ctx, utterance, sourceText); reply != "сохранила заметку." {
|
||||
t.Fatalf("reply = %q, want the fixed feminine acknowledgement", reply)
|
||||
}
|
||||
if model.calls != 0 {
|
||||
t.Fatalf("resident model was called %d time(s) for a note acknowledgement", model.calls)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
const body = "запасной ключ лежит в синей коробке"
|
||||
if len(notes) != 1 || notes[0].Text != body || notes[0].Source != "tap:voice" || !notes[0].Ts.Equal(now) {
|
||||
t.Fatalf("stored notes = %+v, want one exact body at the turn time", notes)
|
||||
}
|
||||
records, err := mem.ByPrefix(ctx, "note:")
|
||||
if err != nil {
|
||||
t.Fatalf("vector catalog: %v", err)
|
||||
}
|
||||
if len(records) != 1 || records[0].Meta["text"] != body {
|
||||
t.Fatalf("vector records = %+v, want the same extracted body", records)
|
||||
}
|
||||
if records[0].Meta["text"] == utterance || records[0].Meta["text"] == "ты поедешь на дачу" {
|
||||
t.Fatalf("vector metadata used a command or model rewrite: %+v", records[0].Meta)
|
||||
}
|
||||
if !phraser.IsAck(phraser.AckNote, nil, "сохранила заметку.") {
|
||||
t.Fatal("fixed acknowledgement is not registered as the note acknowledgement")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpokenTaskCaptureFilesATask — the whole path, from the utterance to the
|
||||
// task table. It went dead when the router started claiming the marker as an
|
||||
// act: capture rides the note intent, so nothing below actionNote was ever
|
||||
@@ -101,7 +169,7 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, matcher, 0.55, nil),
|
||||
router: buildRouter(emb, matcher, 0.55, nil, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
|
||||
@@ -56,7 +56,7 @@ type recallWiring struct {
|
||||
// minScore — the note-recall confidence gate. Top cosine below this ⇒
|
||||
// "I don't know" instead of a guess. Tuned for the ONNX embedder; a knob,
|
||||
// not load-bearing math (same posture as the presence thresholds). Set by
|
||||
// wireVoice from VoiceConfig; default 0.55.
|
||||
// wireVoice from VoiceConfig; default 0.80.
|
||||
minScore float64
|
||||
|
||||
// minMargin — the second half of that gate: how far the top hit must beat
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// reminderCancelRequest exists to make the parser's contract explicit: a hit
|
||||
// proves only that the turn is an addressed imperative naming the reminder
|
||||
// store. Subject and time are resolved separately after that safety boundary.
|
||||
type reminderCancelRequest struct{}
|
||||
|
||||
var reminderCancelVerbs = func() map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, word := range lexicon.ReminderCancelVerbs() {
|
||||
out[strings.ToLower(word)] = true
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
var reminderCancelFrame = func() map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, word := range lexicon.ReminderCancelFrame() {
|
||||
out[strings.ToLower(word)] = true
|
||||
}
|
||||
return out
|
||||
}()
|
||||
|
||||
// isReminderCancelTarget is deliberately a noun test, not a substring test.
|
||||
// A committed reminder must be named, otherwise "убери со стола" would reach
|
||||
// the reminder store. Russian cases are grammar and go through morph; the
|
||||
// English singular/plural forms are closed command vocabulary.
|
||||
func isReminderCancelTarget(tok string) bool {
|
||||
if morph.SameWord(tok, "напоминание") || morph.SameWord(tok, "будильник") {
|
||||
return true
|
||||
}
|
||||
switch tok {
|
||||
case "reminder", "reminders", "alarm", "alarms":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// reminderCancelLead reports which words may precede the imperative without
|
||||
// becoming a subject of their own. Filler/politeness vocabulary already has
|
||||
// one home in the lexicon; Maven's name is an address, not a Russian class.
|
||||
func reminderCancelLead(tok string) bool {
|
||||
return lexicon.IsFillerParticle(tok) || tok == "мавен" || tok == "maven"
|
||||
}
|
||||
|
||||
// parseReminderCancelRequest recognizes an exact cancel imperative at the
|
||||
// start of the addressed command plus an explicit reminder noun. Both are
|
||||
// whole tokens. Requiring command position is the safety boundary: infinitive
|
||||
// questions ("как отменить ..."), reported speech ("он сказал: отмени ...")
|
||||
// and past-tense remarks never reach the reminder store. A relative clause
|
||||
// after a real command remains valid even though it may contain a question
|
||||
// pronoun, so this is stronger and more precise than a punctuation test.
|
||||
func parseReminderCancelRequest(text string) (reminderCancelRequest, bool) {
|
||||
tokens := turnTokens(text)
|
||||
verbAt := -1
|
||||
for i, tok := range tokens {
|
||||
if reminderCancelVerbs[tok] {
|
||||
verbAt = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if verbAt < 0 {
|
||||
return reminderCancelRequest{}, false
|
||||
}
|
||||
for _, tok := range tokens[:verbAt] {
|
||||
if !reminderCancelLead(tok) {
|
||||
return reminderCancelRequest{}, false
|
||||
}
|
||||
}
|
||||
for _, tok := range tokens[verbAt+1:] {
|
||||
if isReminderCancelTarget(tok) {
|
||||
return reminderCancelRequest{}, true
|
||||
}
|
||||
}
|
||||
return reminderCancelRequest{}, false
|
||||
}
|
||||
|
||||
func reminderCancelNegation(tok string) bool {
|
||||
switch tok {
|
||||
case "не", "ни", "not", "no", "don't", "dont":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func reminderCancelTimeLead(tok string) bool {
|
||||
switch tok {
|
||||
case "в", "во", "на", "к", "ко", "через", "спустя",
|
||||
"at", "in", "by", "until", "after", "before":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func reminderCancelTimeUnit(tok string) bool {
|
||||
if lexicon.IsHourUnit(tok) || lexicon.IsMinuteUnit(tok) {
|
||||
return true
|
||||
}
|
||||
for _, part := range lexicon.PartsOfDay() {
|
||||
if tok == part {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return tok == "утра" || tok == "дня" || tok == "вечера" || tok == "ночи" ||
|
||||
tok == "am" || tok == "pm" || tok == "noon" || tok == "midnight"
|
||||
}
|
||||
|
||||
func reminderCancelNumeral(tok string) (int, bool) {
|
||||
if n, ok := lexicon.Cardinal(tok); ok {
|
||||
return n, true
|
||||
}
|
||||
if n, ok := lexicon.Ordinal(tok); ok && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
n, err := strconv.Atoi(tok)
|
||||
return n, err == nil
|
||||
}
|
||||
|
||||
// reminderClockTokenBudget records the numeric pieces that came from a written
|
||||
// clock. turnTokens deliberately splits 21:30 into 21 and 30, so a small
|
||||
// multiset lets subject extraction ignore exactly those occurrences without
|
||||
// discarding the same number when it also belongs to the reminder text.
|
||||
func reminderClockTokenBudget(text string) map[string]int {
|
||||
out := make(map[string]int)
|
||||
for _, field := range strings.Fields(strings.ToLower(text)) {
|
||||
field = strings.Trim(field, ".,!?;()[]{}«»\"'")
|
||||
hour, minute, ok := strings.Cut(field, ":")
|
||||
if !ok || len(minute) != 2 {
|
||||
continue
|
||||
}
|
||||
h, herr := strconv.Atoi(hour)
|
||||
m, merr := strconv.Atoi(minute)
|
||||
if herr != nil || merr != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
continue
|
||||
}
|
||||
out[hour]++
|
||||
out[minute]++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reminderCancellationTerms keeps identity-bearing words, including negation
|
||||
// and quantities. The old ownContent shortcut erased both, so "не звонить" and
|
||||
// "звонить", or "одну таблетку" and "две таблетки", could select the same
|
||||
// row. Time framing is removed only after the shared parser proved that this
|
||||
// turn actually carries a readable time; numerals are removed only in a clock
|
||||
// position, never merely because they are numbers.
|
||||
func reminderCancellationTerms(text string, hasTime bool) []string {
|
||||
tokens := turnTokens(text)
|
||||
clockBudget := reminderClockTokenBudget(text)
|
||||
out := make([]string, 0, len(tokens))
|
||||
for i, tok := range tokens {
|
||||
if reminderCancelVerbs[tok] || isReminderCancelTarget(tok) ||
|
||||
reminderCancelFrame[tok] || lexicon.IsFillerParticle(tok) {
|
||||
continue
|
||||
}
|
||||
if !hasTime || reminderCancelNegation(tok) {
|
||||
out = append(out, tok)
|
||||
continue
|
||||
}
|
||||
if clockBudget[tok] > 0 {
|
||||
clockBudget[tok]--
|
||||
continue
|
||||
}
|
||||
if _, numeric := reminderCancelNumeral(tok); numeric {
|
||||
prevTime := i > 0 && reminderCancelTimeLead(tokens[i-1])
|
||||
nextTime := i+1 < len(tokens) && reminderCancelTimeUnit(tokens[i+1])
|
||||
if prevTime || nextTime {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// frameWords is assembled exclusively from the closed time/grammar
|
||||
// lexicons. At this point a time was parsed, and negation has already
|
||||
// been preserved above, so these words identify the time rather than
|
||||
// the stored reminder body.
|
||||
if frameWords[tok] {
|
||||
continue
|
||||
}
|
||||
out = append(out, tok)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reminderCancellationTime applies the same parse and resolved-hour gate as a
|
||||
// newly created reminder. A time expression that is present but unread is not
|
||||
// silently discarded: the caller asks for a clearer time instead of cancelling
|
||||
// whichever row happens to match the remaining words.
|
||||
func (h *reactiveHandler) reminderCancellationTime(ctx context.Context, text string) (time.Time, bool) {
|
||||
if slots := h.extractor.Extract(ctx, router.IntentReminder, text, h.now()); slots.HasTime {
|
||||
return slots.Time, true
|
||||
}
|
||||
if h.timeParser == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
parsed, ok, err := h.timeParser.Parse(ctx, text, h.now())
|
||||
if err != nil || !ok || !router.ResolvedTheHour(text, parsed) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
|
||||
func reminderNextFire(r ipc.Reminder) time.Time {
|
||||
if !r.NextFireTs.IsZero() {
|
||||
return r.NextFireTs
|
||||
}
|
||||
return r.FireTs
|
||||
}
|
||||
|
||||
// reminderTimeMatches lets state disambiguate a clock when the day was not
|
||||
// named. "На девять" can therefore select the sole 09:00/21:00 reminder, but
|
||||
// if both exist they both remain candidates and Maven asks. A named day or an
|
||||
// interval denotes an absolute minute and must match that minute exactly.
|
||||
func reminderTimeMatches(text string, parsed, fire time.Time) bool {
|
||||
local := fire.In(parsed.Location())
|
||||
if router.NamesADay(text) || router.NamesAnInterval(text) {
|
||||
return local.Truncate(time.Minute).Equal(parsed.Truncate(time.Minute))
|
||||
}
|
||||
if router.HourIsAmbiguous(text) {
|
||||
return local.Minute() == parsed.Minute() && local.Hour()%12 == parsed.Hour()%12
|
||||
}
|
||||
return local.Hour() == parsed.Hour() && local.Minute() == parsed.Minute()
|
||||
}
|
||||
|
||||
func reminderTextMatchesTerms(r ipc.Reminder, terms []string) bool {
|
||||
if len(terms) == 0 {
|
||||
return true
|
||||
}
|
||||
words := turnTokens(store.ReminderText(r.Payload))
|
||||
used := make([]bool, len(words))
|
||||
for _, term := range terms {
|
||||
found := false
|
||||
for i, word := range words {
|
||||
if used[i] {
|
||||
continue
|
||||
}
|
||||
tn, tok := reminderCancelNumeral(term)
|
||||
wn, wok := reminderCancelNumeral(word)
|
||||
if term == word || morph.SameWord(term, word) || (tok && wok && tn == wn) {
|
||||
used[i] = true
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func reminderCancellationLabel(r ipc.Reminder, now time.Time) string {
|
||||
fire := reminderNextFire(r).In(now.Location())
|
||||
when := dayPrefix(now, fire)
|
||||
if when == "это" {
|
||||
when = fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month())))
|
||||
}
|
||||
return fmt.Sprintf("%s в %s — %s", when, fire.Format("15:04"), store.ReminderText(r.Payload))
|
||||
}
|
||||
|
||||
// offerReminderCancellations binds exactly the rows Maven names, in that order.
|
||||
// An ordinal on the next turn therefore points at the spoken list, never at a
|
||||
// fresh query whose order may have changed in between.
|
||||
func (h *reactiveHandler) offerReminderCancellations(ctx context.Context, text string, matches []ipc.Reminder) string {
|
||||
const maxSpoken = 5
|
||||
truncated := len(matches) > maxSpoken
|
||||
if len(matches) > maxSpoken {
|
||||
matches = matches[:maxSpoken]
|
||||
}
|
||||
candidates := make([]dialogue.Candidate, 0, len(matches))
|
||||
parts := make([]string, 0, len(matches))
|
||||
for i, r := range matches {
|
||||
label := reminderCancellationLabel(r, h.now())
|
||||
candidates = append(candidates, dialogue.Candidate{Kind: "reminder-cancel", Ref: r.ID, Label: label})
|
||||
parts = append(parts, fmt.Sprintf("%d: %s", i+1, label))
|
||||
}
|
||||
|
||||
if h.dialogueSessions == nil {
|
||||
return "нашла несколько подходящих напоминаний — уточни текст или время."
|
||||
}
|
||||
id, now := dialogueIDOf(ctx), h.now()
|
||||
// This command is its own turn. Reusing an older session would keep stale
|
||||
// intent/slots alive after the choice and let the next utterance inherit
|
||||
// unrelated state, so the offered list gets a fresh system session.
|
||||
h.dialogueSessions.Put(id, &dialogue.Session{
|
||||
Intent: dialogue.IntentSystem, Utterance: text, Timestamp: now,
|
||||
Candidates: candidates,
|
||||
})
|
||||
prefix := "нашла несколько подходящих. какое отменить? "
|
||||
if truncated {
|
||||
prefix = "нашла больше пяти подходящих; называю первые пять. если нужного здесь нет, уточни текст или время. какое отменить? "
|
||||
}
|
||||
return prefix + strings.Join(parts, "; ") + ". ответь одним порядковым словом, например «второе»."
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) clearReminderCandidates(ctx context.Context) {
|
||||
if h.dialogueSessions != nil {
|
||||
h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) cancelReminderChoice(ctx context.Context, id int64, label string) string {
|
||||
if err := h.api.CancelReminder(ctx, id); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ipc.ErrReminderNotFound), errors.Is(err, ipc.ErrReminderState):
|
||||
h.clearReminderCandidates(ctx)
|
||||
return "это напоминание уже не ожидает отправки."
|
||||
case errors.Is(err, ipc.ErrReminderInFlight):
|
||||
h.clearReminderCandidates(ctx)
|
||||
return "я уже начала отправлять это напоминание — надёжно отменить его уже нельзя."
|
||||
default:
|
||||
log.Printf("voice: cancel reminder %d: %v", id, err)
|
||||
return "не получилось отменить напоминание."
|
||||
}
|
||||
}
|
||||
h.clearReminderCandidates(ctx)
|
||||
log.Printf("voice: cancelled reminder %d (%q)", id, label)
|
||||
return "отменила напоминание: " + label + "."
|
||||
}
|
||||
|
||||
// resolveReminderCancellation is the stateful pre-route resolver for a
|
||||
// committed reminder. It claims only the explicit structural command above,
|
||||
// resolves against every pending row, and never ranks an ambiguous set down to
|
||||
// one. One match cancels; more than one is an offered, ordinal-bound question.
|
||||
func (h *reactiveHandler) resolveReminderCancellation(ctx context.Context, text string) (string, bool) {
|
||||
_, ok := parseReminderCancelRequest(text)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
rows, err := h.api.ListPendingReminders(ctx, 0)
|
||||
if err != nil {
|
||||
log.Printf("voice: list reminders for cancellation: %v", err)
|
||||
return "не получилось посмотреть напоминания.", true
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return "ожидающих напоминаний нет.", true
|
||||
}
|
||||
|
||||
parsed, hasTime := h.reminderCancellationTime(ctx, text)
|
||||
if router.MentionsTime(text) && !hasTime {
|
||||
return "не смогла разобрать время напоминания — уточни его.", true
|
||||
}
|
||||
terms := reminderCancellationTerms(text, hasTime)
|
||||
matches := make([]ipc.Reminder, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if !reminderTextMatchesTerms(r, terms) {
|
||||
continue
|
||||
}
|
||||
if hasTime && !reminderTimeMatches(text, parsed, reminderNextFire(r)) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, r)
|
||||
}
|
||||
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "не нашла такого ожидающего напоминания.", true
|
||||
case 1:
|
||||
label := reminderCancellationLabel(matches[0], h.now())
|
||||
return h.cancelReminderChoice(ctx, matches[0].ID, label), true
|
||||
default:
|
||||
return h.offerReminderCancellations(ctx, text, matches), true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tts"
|
||||
)
|
||||
|
||||
func TestParseReminderCancelRequest(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
ok bool
|
||||
}{
|
||||
{"отмени напоминание про врача", true},
|
||||
{"убери моё напоминание о визите", true},
|
||||
{"удали будильник на девять", true},
|
||||
{"пожалуйста, Maven, cancel the reminder about doctor", true},
|
||||
{"отмени напоминание, которое стоит на завтра", true},
|
||||
{"напоминание про врача", false},
|
||||
{"отмени задачу про врача", false},
|
||||
{"я отменил напоминание про врача", false},
|
||||
{"как отменить напоминание про врача?", false},
|
||||
{"можно отменить напоминание про врача?", false},
|
||||
{"он сказал: отмени напоминание про врача", false},
|
||||
{"how to cancel the reminder about doctor?", false},
|
||||
{"can you cancel the reminder about doctor?", false},
|
||||
{"убери со стола", false},
|
||||
{"отмена", false},
|
||||
} {
|
||||
_, ok := parseReminderCancelRequest(tc.text)
|
||||
if ok != tc.ok {
|
||||
t.Errorf("parseReminderCancelRequest(%q) ok = %v, want %v", tc.text, ok, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderCancellationTermsPreserveIdentity(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
hasTime bool
|
||||
want []string
|
||||
}{
|
||||
{"отмени напоминание про врача", false, []string{"врача"}},
|
||||
{"отмени напоминание не звонить врачу", false, []string{"не", "звонить", "врачу"}},
|
||||
{"отмени напоминание принять две таблетки", false, []string{"принять", "две", "таблетки"}},
|
||||
{"отмени напоминание принять две таблетки на девять", true, []string{"принять", "две", "таблетки"}},
|
||||
{"cancel the reminder to take 2 pills at 21:30", true, []string{"take", "2", "pills"}},
|
||||
} {
|
||||
got := reminderCancellationTerms(tc.text, tc.hasTime)
|
||||
if strings.Join(got, "|") != strings.Join(tc.want, "|") {
|
||||
t.Errorf("reminderCancellationTerms(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seedVoiceReminder(t *testing.T, st *store.Store, fire time.Time, text string) int64 {
|
||||
t.Helper()
|
||||
id, err := st.CreateReminder(context.Background(), fire, `{"text":"`+text+`"}`, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create reminder: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func reminderStatuses(t *testing.T, st *store.Store) map[int64]string {
|
||||
t.Helper()
|
||||
rows, err := st.ListReminders(context.Background(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("list reminders: %v", err)
|
||||
}
|
||||
out := make(map[int64]string, len(rows))
|
||||
for _, row := range rows {
|
||||
out[row.ID] = row.Status
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestReminderCancellationResolvesSubjectByMorphology(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
h.timeParser = router.StubDateTimeParser{}
|
||||
doctor := seedVoiceReminder(t, st, now.Add(3*time.Hour), "позвонить врачу")
|
||||
bread := seedVoiceReminder(t, st, now.Add(4*time.Hour), "купить хлеб")
|
||||
|
||||
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание про врача")
|
||||
if !handled || !strings.Contains(reply, "отменила") || !strings.Contains(reply, "позвонить врачу") {
|
||||
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
||||
}
|
||||
statuses := reminderStatuses(t, st)
|
||||
if statuses[doctor] != store.ReminderCancelled || statuses[bread] != store.ReminderPending {
|
||||
t.Fatalf("statuses = %+v, want doctor cancelled and bread pending", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderCancellationKeepsNegationAndQuantityDistinct(t *testing.T) {
|
||||
t.Run("negation", func(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
positive := seedVoiceReminder(t, st, now.Add(time.Hour), "звонить врачу")
|
||||
negative := seedVoiceReminder(t, st, now.Add(2*time.Hour), "не звонить врачу")
|
||||
|
||||
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание не звонить врачу")
|
||||
if !handled || !strings.Contains(reply, "не звонить врачу") {
|
||||
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
||||
}
|
||||
statuses := reminderStatuses(t, st)
|
||||
if statuses[positive] != store.ReminderPending || statuses[negative] != store.ReminderCancelled {
|
||||
t.Fatalf("negation selected the wrong row: %+v", statuses)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("quantity", func(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
one := seedVoiceReminder(t, st, now.Add(time.Hour), "принять одну таблетку")
|
||||
two := seedVoiceReminder(t, st, now.Add(2*time.Hour), "принять две таблетки")
|
||||
|
||||
reply, handled := h.resolveReminderCancellation(context.Background(), "удали напоминание принять две таблетки")
|
||||
if !handled || !strings.Contains(reply, "две таблетки") {
|
||||
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
||||
}
|
||||
statuses := reminderStatuses(t, st)
|
||||
if statuses[one] != store.ReminderPending || statuses[two] != store.ReminderCancelled {
|
||||
t.Fatalf("quantity selected the wrong row: %+v", statuses)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReminderCancellationQuestionNeverMutates(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу")
|
||||
for _, text := range []string{
|
||||
"как отменить напоминание про врача?",
|
||||
"можно отменить напоминание про врача?",
|
||||
"он сказал: отмени напоминание про врача",
|
||||
} {
|
||||
if reply, handled := h.resolveReminderCancellation(context.Background(), text); handled || reply != "" {
|
||||
t.Fatalf("non-command %q was claimed: reply=%q handled=%v", text, reply, handled)
|
||||
}
|
||||
if got := reminderStatuses(t, st)[id]; got != store.ReminderPending {
|
||||
t.Fatalf("non-command %q changed reminder to %q", text, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderCancellationUsesClockAndAsksWhenStateIsAmbiguous(t *testing.T) {
|
||||
t.Run("one matching half of day is enough", func(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
h.timeParser = router.StubDateTimeParser{}
|
||||
evening := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day(), 21, 0, 0, 0, now.Location()), "вечернее лекарство")
|
||||
seedVoiceReminder(t, st, now.Add(2*time.Hour), "купить хлеб")
|
||||
|
||||
reply, handled := h.resolveReminderCancellation(context.Background(), "убери напоминание на девять")
|
||||
if !handled || !strings.Contains(reply, "отменила") {
|
||||
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
||||
}
|
||||
if got := reminderStatuses(t, st)[evening]; got != store.ReminderCancelled {
|
||||
t.Fatalf("21:00 status = %q, want cancelled", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("two matching halves are offered and ordinal is bound", func(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
h.timeParser = router.StubDateTimeParser{}
|
||||
evening := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day(), 21, 0, 0, 0, now.Location()), "вечернее лекарство")
|
||||
morning := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day()+1, 9, 0, 0, 0, now.Location()), "утреннее лекарство")
|
||||
|
||||
reply, handled := h.resolveReminderCancellation(context.Background(), "убери напоминание на девять")
|
||||
if !handled || !strings.Contains(reply, "порядковым словом") {
|
||||
t.Fatalf("ambiguous reply = %q, handled=%v", reply, handled)
|
||||
}
|
||||
statuses := reminderStatuses(t, st)
|
||||
if statuses[evening] != store.ReminderPending || statuses[morning] != store.ReminderPending {
|
||||
t.Fatalf("ambiguous command mutated rows: %+v", statuses)
|
||||
}
|
||||
sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now())
|
||||
if sess == nil || len(sess.Candidates) != 2 || sess.Candidates[1].Ref != morning {
|
||||
t.Fatalf("bound candidates = %+v", sess)
|
||||
}
|
||||
|
||||
reply, handled = h.resolveCandidate(context.Background(), "второе", sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "утреннее лекарство") {
|
||||
t.Fatalf("ordinal reply = %q, handled=%v", reply, handled)
|
||||
}
|
||||
statuses = reminderStatuses(t, st)
|
||||
if statuses[evening] != store.ReminderPending || statuses[morning] != store.ReminderCancelled {
|
||||
t.Fatalf("ordinal cancelled the wrong row: %+v", statuses)
|
||||
}
|
||||
if sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()); sess == nil || len(sess.Candidates) != 0 {
|
||||
t.Fatalf("spent candidates survived: %+v", sess)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReminderCancellationChoiceRequiresAWholeAffirmativeOrdinal(t *testing.T) {
|
||||
unsafe := []string{
|
||||
"почему второе?",
|
||||
"не второе",
|
||||
"второе не отменяй",
|
||||
"первое и второе",
|
||||
"напомни мне первого сентября оплатить счёт",
|
||||
}
|
||||
for _, answer := range unsafe {
|
||||
t.Run(answer, func(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
h.timeParser = router.StubDateTimeParser{}
|
||||
first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое лекарство")
|
||||
second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе лекарство")
|
||||
if _, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание"); !handled {
|
||||
t.Fatal("ambiguous cancellation was not offered")
|
||||
}
|
||||
if reply, handled := h.resolveCandidate(context.Background(), answer, sourceVoice); handled || reply != "" {
|
||||
t.Fatalf("unsafe answer was claimed: reply=%q handled=%v", reply, handled)
|
||||
}
|
||||
statuses := reminderStatuses(t, st)
|
||||
if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending {
|
||||
t.Fatalf("unsafe answer mutated rows: %+v", statuses)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderCancellationChoiceCanBeAbandoned(t *testing.T) {
|
||||
for _, answer := range []string{"отмена", "не надо", "no"} {
|
||||
t.Run(answer, func(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое")
|
||||
second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе")
|
||||
if _, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание"); !handled {
|
||||
t.Fatal("ambiguous cancellation was not offered")
|
||||
}
|
||||
reply, handled := h.resolveCandidate(context.Background(), answer, sourceVoice)
|
||||
if !handled || !strings.Contains(reply, "ничего не отменяю") {
|
||||
t.Fatalf("cancel answer = %q handled=%v", reply, handled)
|
||||
}
|
||||
statuses := reminderStatuses(t, st)
|
||||
if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending {
|
||||
t.Fatalf("abandoning the choice mutated rows: %+v", statuses)
|
||||
}
|
||||
if sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()); sess == nil || len(sess.Candidates) != 0 {
|
||||
t.Fatalf("abandoned candidates survived: %+v", sess)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderCancellationOfferStartsFreshAndNamesTruncation(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
id := dialogueIDOf(context.Background())
|
||||
h.dialogueSessions.Put(id, &dialogue.Session{
|
||||
Intent: dialogue.IntentReminder,
|
||||
Slots: dialogue.Slots{Text: "stale subject", HasTime: true, Time: now.Add(time.Hour)},
|
||||
Timestamp: now.Add(-time.Minute),
|
||||
})
|
||||
for i := 0; i < 6; i++ {
|
||||
seedVoiceReminder(t, st, now.Add(time.Duration(i+1)*time.Hour), fmt.Sprintf("row %d", i+1))
|
||||
}
|
||||
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание")
|
||||
if !handled || !strings.Contains(reply, "первые пять") || !strings.Contains(reply, "уточни текст или время") {
|
||||
t.Fatalf("truncated offer = %q handled=%v", reply, handled)
|
||||
}
|
||||
sess := h.dialogueSessions.Get(id, h.now())
|
||||
if sess == nil || sess.Intent != dialogue.IntentSystem || sess.Slots.Text != "" ||
|
||||
len(sess.Candidates) != 5 || sess.Utterance != "отмени напоминание" {
|
||||
t.Fatalf("offer reused stale dialogue state: %+v", sess)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderCancellationNeverGuesses(t *testing.T) {
|
||||
t.Run("bare command over several rows", func(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое")
|
||||
second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе")
|
||||
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание")
|
||||
if !handled || !strings.Contains(reply, "порядковым словом") {
|
||||
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
||||
}
|
||||
statuses := reminderStatuses(t, st)
|
||||
if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending {
|
||||
t.Fatalf("bare ambiguous command mutated rows: %+v", statuses)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unread time", func(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
h.timeParser = router.StubDateTimeParser{}
|
||||
id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу")
|
||||
reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание через вечность")
|
||||
if !handled || !strings.Contains(reply, "не смогла разобрать время") {
|
||||
t.Fatalf("reply = %q, handled=%v", reply, handled)
|
||||
}
|
||||
if got := reminderStatuses(t, st)[id]; got != store.ReminderPending {
|
||||
t.Fatalf("unread time cancelled reminder: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type cancelReminderAPI struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
|
||||
rows []ipc.Reminder
|
||||
listErr error
|
||||
cancelErr error
|
||||
calls []int64
|
||||
}
|
||||
|
||||
func (a *cancelReminderAPI) ListPendingReminders(context.Context, int) ([]ipc.Reminder, error) {
|
||||
return a.rows, a.listErr
|
||||
}
|
||||
|
||||
func (a *cancelReminderAPI) CancelReminder(_ context.Context, id int64) error {
|
||||
a.calls = append(a.calls, id)
|
||||
return a.cancelErr
|
||||
}
|
||||
|
||||
func cancelHandler(api ipc.CoreAPI) *reactiveHandler {
|
||||
now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC)
|
||||
parser := router.StubDateTimeParser{}
|
||||
return &reactiveHandler{
|
||||
api: api, now: func() time.Time { return now }, timeParser: parser,
|
||||
extractor: router.Extractor{Time: parser},
|
||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderCancellationReportsStoreOutcomes(t *testing.T) {
|
||||
row := ipc.Reminder{
|
||||
ID: 7, FireTs: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC),
|
||||
NextFireTs: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC),
|
||||
Payload: `{"text":"позвонить врачу"}`, Status: store.ReminderPending,
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"already terminal", ipc.ErrReminderState, "уже не ожидает"},
|
||||
{"delivery in flight", ipc.ErrReminderInFlight, "уже начала отправлять"},
|
||||
{"transport", errors.New("socket closed"), "не получилось отменить"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api := &cancelReminderAPI{rows: []ipc.Reminder{row}, cancelErr: tc.err}
|
||||
reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание про врача")
|
||||
if !handled || !strings.Contains(reply, tc.want) || len(api.calls) != 1 || api.calls[0] != 7 {
|
||||
t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("list failure", func(t *testing.T) {
|
||||
api := &cancelReminderAPI{listErr: errors.New("offline")}
|
||||
reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание")
|
||||
if !handled || !strings.Contains(reply, "не получилось посмотреть") || len(api.calls) != 0 {
|
||||
t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nothing pending", func(t *testing.T) {
|
||||
api := &cancelReminderAPI{}
|
||||
reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание")
|
||||
if !handled || !strings.Contains(reply, "ожидающих напоминаний нет") || len(api.calls) != 0 {
|
||||
t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestReminderCancellationIsAPreRouteTurnAndDoesNotGetSwallowedByClarify(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
h.timeParser = router.StubDateTimeParser{}
|
||||
h.decisions = decision.NewRing()
|
||||
id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу")
|
||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime},
|
||||
Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL,
|
||||
})
|
||||
|
||||
reply := h.runTurn(ctx, "отмени напоминание про врача", sourceText)
|
||||
if !strings.Contains(reply, clarifyDropped) || !strings.Contains(reply, "отменила напоминание") {
|
||||
t.Fatalf("reply = %q, want dropped clarify notice and cancellation", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDOf(ctx), h.now()) != nil {
|
||||
t.Fatal("the superseded clarify question survived the cancellation request")
|
||||
}
|
||||
if got := reminderStatuses(t, st)[id]; got != store.ReminderCancelled {
|
||||
t.Fatalf("status = %q, want cancelled", got)
|
||||
}
|
||||
recs := h.decisions.Recent(1)
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("decision records = %d, want 1", len(recs))
|
||||
}
|
||||
claim := findClaim(recs[0], "reminder-cancel")
|
||||
if claim == nil || claim.Outcome != decision.Won {
|
||||
t.Fatalf("reminder-cancel claim = %+v, want pre-route winner", claim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderCancellationThroughPushToTalk(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
h.stt = simTranscriber{text: "отмени напоминание про врача"}
|
||||
h.tts = tts.NewStub()
|
||||
h.timeParser = router.StubDateTimeParser{}
|
||||
h.router = buildRouter(router.NewHashEmbedder(64), h.matcher, 0.55, nil, nil)
|
||||
doctor := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу")
|
||||
bread := seedVoiceReminder(t, st, now.Add(2*time.Hour), "купить хлеб")
|
||||
|
||||
resp, err := h.HandlePushToTalk(context.Background(), voicePTT(), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(resp.ReplyText, "отменила напоминание") || len(resp.ReplyAudio.Bytes) == 0 {
|
||||
t.Fatalf("PTT response = text %q audio=%d bytes", resp.ReplyText, len(resp.ReplyAudio.Bytes))
|
||||
}
|
||||
statuses := reminderStatuses(t, st)
|
||||
if statuses[doctor] != store.ReminderCancelled || statuses[bread] != store.ReminderPending {
|
||||
t.Fatalf("PTT cancellation changed the wrong rows: %+v", statuses)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A reminder confirmation is the one sentence that must match a database row.
|
||||
// It used to be phrased by the replier from Slots.Text, which meant it named
|
||||
// whatever hour the sentence contained — including an hour the parser had
|
||||
// rejected or read differently (Vikunja #507).
|
||||
|
||||
func TestReminderConfirmNamesTheStoredHour(t *testing.T) {
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*time.Hour), now) // 19:00 today
|
||||
if !strings.Contains(got, "19:00") {
|
||||
t.Fatalf("confirmation = %q, want the stored 19:00 in it", got)
|
||||
}
|
||||
if !strings.Contains(got, "сегодня") {
|
||||
t.Fatalf("confirmation = %q, want it to say сегодня", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmUsesADateBeyondTheDayWords(t *testing.T) {
|
||||
// dayPrefix answers "это" past послезавтра, and "напомню это в 09:00" is
|
||||
// not a sentence. A date is.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(10*24*time.Hour), now)
|
||||
if strings.Contains(got, "это") {
|
||||
t.Fatalf("confirmation = %q, want a date rather than the fallback day word", got)
|
||||
}
|
||||
if !strings.Contains(got, "15 августа") {
|
||||
t.Fatalf("confirmation = %q, want the date in it", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderConfirmIsFeminineAndInformal(t *testing.T) {
|
||||
// The persona checks the phrasing eval enforces apply here too, and this
|
||||
// sentence never passes through a phraser.
|
||||
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
|
||||
got := reminderConfirm(now.Add(time.Hour), now)
|
||||
for _, bad := range []string{"вы", "ваш", "напомнил ", "рад "} {
|
||||
if strings.Contains(strings.ToLower(got), bad) {
|
||||
t.Fatalf("confirmation = %q contains %q", got, bad)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(got, "хорошо, напомню") {
|
||||
t.Fatalf("confirmation = %q, want it to open with the promise", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// A reminder commits only when three things are answered: what to say, what
|
||||
// time to say it, and what day (owner's rule, 2026-08-06, V-579). Anything
|
||||
// missing is asked about, and nothing missing is filled from the clock.
|
||||
//
|
||||
// "напомни завтра в 3 заказать цветы" has the what and the day and an hour that
|
||||
// could be either half of the day, so she asks which 3. "напомни в 9 вечера
|
||||
// разгрузить стиралку" has the what and an unambiguous hour and no day, so she
|
||||
// asks which day. Today being a valid reading is not the same as him saying it.
|
||||
//
|
||||
// Two things are already whole and are not asked about. A time that admits one
|
||||
// reading is not queried for its half of the day, so "завтра в 15:00" commits.
|
||||
// And an interval is an instant, so "через час" carries all three by itself.
|
||||
type whenGap string
|
||||
|
||||
const (
|
||||
whenComplete whenGap = ""
|
||||
whenNoHour whenGap = "hour"
|
||||
whenAmbiguousHour whenGap = "part_of_day"
|
||||
whenNoDay whenGap = "day"
|
||||
)
|
||||
|
||||
// whenGapOf reads the request and names the first thing about its time that he
|
||||
// has not said. hasTime is whether a parser could read an instant out of it,
|
||||
// which is necessary and not sufficient: the parser answers a dayless "в 9"
|
||||
// with a day it picked.
|
||||
func whenGapOf(text string, hasTime bool) whenGap {
|
||||
if !router.NamesAnHour(text) {
|
||||
return whenNoHour
|
||||
}
|
||||
if router.NamesAnInterval(text) {
|
||||
return whenComplete
|
||||
}
|
||||
if !hasTime {
|
||||
return whenNoHour
|
||||
}
|
||||
if router.HourIsAmbiguous(text) {
|
||||
return whenAmbiguousHour
|
||||
}
|
||||
if !router.NamesADay(text) {
|
||||
return whenNoDay
|
||||
}
|
||||
return whenComplete
|
||||
}
|
||||
|
||||
// whenQuestion is what she asks for each gap. Every one of them opens with the
|
||||
// current time, because she is reasoning from it and he cannot check that
|
||||
// reasoning unless he hears it. The hour deck varies with the attempt, like
|
||||
// every other slot; the other two say one thing and there is only one way to
|
||||
// say it.
|
||||
//
|
||||
// taken is what his last turn added, in his own words, and it goes between the
|
||||
// clock and the question (V-593). It is empty whenever his turn moved nothing
|
||||
// forward, which is the case where repeating the question verbatim is honest.
|
||||
func whenQuestion(gap whenGap, attempt int, now time.Time, taken string) (string, bool) {
|
||||
clock := fmt.Sprintf("Сейчас %s.", now.Format("15:04"))
|
||||
if taken != "" {
|
||||
clock += " " + taken
|
||||
}
|
||||
switch gap {
|
||||
case whenNoHour:
|
||||
q, ok := clarifyQuestionFor(dialogue.SlotTime, attempt)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return clock + " " + q, true
|
||||
case whenAmbiguousHour:
|
||||
return clock + " Это утра или вечера?", true
|
||||
case whenNoDay:
|
||||
return clock + " В какой день?", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// whenKnown — the three things he has to say about the time, and whether the
|
||||
// words so far say them. Read off the same predicates whenGapOf reads, so the
|
||||
// two cannot disagree about what is still open.
|
||||
type whenKnown struct{ hour, part, day bool }
|
||||
|
||||
func whenKnownOf(text string, hasTime bool) whenKnown {
|
||||
if !router.NamesAnHour(text) {
|
||||
return whenKnown{}
|
||||
}
|
||||
if router.NamesAnInterval(text) {
|
||||
return whenKnown{hour: true, part: true, day: true}
|
||||
}
|
||||
if !hasTime {
|
||||
return whenKnown{}
|
||||
}
|
||||
return whenKnown{
|
||||
hour: true,
|
||||
part: !router.HourIsAmbiguous(text),
|
||||
day: router.NamesADay(text),
|
||||
}
|
||||
}
|
||||
|
||||
// movedForward reports whether b says something a did not.
|
||||
func (a whenKnown) movedForward(b whenKnown) bool {
|
||||
return (!a.hour && b.hour) || (!a.part && b.part) || (!a.day && b.day)
|
||||
}
|
||||
|
||||
// whenTakenLine — the acknowledgement in front of a re-ask, in the words he
|
||||
// just used (V-593).
|
||||
//
|
||||
// It is an echo and never a restatement, for the same reason the fact
|
||||
// confirmation is (V-592): a 1.7B asked to say a Russian sentence back invents.
|
||||
// Its only job is evidence that the turn between two asks was heard, so after
|
||||
// "на 9" and then "на завтра" she does not ask "утра или вечера?" twice
|
||||
// byte-identically while he wonders whether the microphone is on.
|
||||
func whenTakenLine(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.TrimRight(text, " \t.,!?;:")
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "Поняла: " + text + "."
|
||||
}
|
||||
|
||||
// whenTextOf is everything he has said about when, the original request plus
|
||||
// every answer he has given to a question about it.
|
||||
//
|
||||
// The answers are kept apart from the utterance on purpose. The utterance is
|
||||
// the reminder's payload, so folding "завтра" into it would have her read the
|
||||
// day back to him at the time she says it. And a time answer has to be read
|
||||
// against the request rather than alone: "завтра" names no hour, and the hour
|
||||
// it belongs to is the one she is already holding.
|
||||
func whenTextOf(q *dialogue.PendingQuestion) string {
|
||||
if q.WhenText == "" {
|
||||
return q.Utterance
|
||||
}
|
||||
return strings.TrimSpace(q.Utterance + " " + q.WhenText)
|
||||
}
|
||||
|
||||
// slotStillMissing reports whether a slot is still open. Every slot but the
|
||||
// reminder's time is open when it is empty; the time is open until all three of
|
||||
// what he must say about it are said.
|
||||
func slotStillMissing(slot dialogue.Slot, utterance string, s dialogue.Slots) bool {
|
||||
if len(dialogue.StillMissing([]dialogue.Slot{slot}, s)) > 0 {
|
||||
return true
|
||||
}
|
||||
return slot == dialogue.SlotTime && whenGapOf(utterance, s.HasTime) != whenComplete
|
||||
}
|
||||
|
||||
// readWhen reads the instant out of what he has said about the time, newest
|
||||
// statement first.
|
||||
//
|
||||
// The request plus his latest answer is tried before the whole history, and
|
||||
// that order is what makes a correction win: "нет, сегодня в 15:00" after "в
|
||||
// 11:00" must land on 15:00, and a parser reading left to right off the joined
|
||||
// history would find the 11 he just took back. The history is the fallback,
|
||||
// because an answer often completes an earlier one rather than replacing it -
|
||||
// "вечера" says which 9, and alone it names no hour at all.
|
||||
func (h *reactiveHandler) readWhen(ctx context.Context, intent router.Intent, q *dialogue.PendingQuestion, text string) (time.Time, bool) {
|
||||
latest := strings.TrimSpace(q.Utterance + " " + text)
|
||||
if router.NamesAnHour(text) {
|
||||
if w := h.extractor.Extract(ctx, intent, latest, h.now()); w.HasTime {
|
||||
return w.Time, true
|
||||
}
|
||||
}
|
||||
if w := h.extractor.Extract(ctx, intent, whenTextOf(q), h.now()); w.HasTime {
|
||||
return w.Time, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// asksAboutTime reports whether the parked question is one about when.
|
||||
func asksAboutTime(missing []dialogue.Slot) bool {
|
||||
for _, s := range missing {
|
||||
if s == dialogue.SlotTime {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillOpen reports whether any of the slots she asked about is still unsaid.
|
||||
func stillOpen(missing []dialogue.Slot, utterance string, s dialogue.Slots) bool {
|
||||
for _, slot := range missing {
|
||||
if slotStillMissing(slot, utterance, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillMissingFor is missingFor's engine, in wantedSlots order. It reads the
|
||||
// utterance as well as the slots, which plain StillMissing cannot: whether an
|
||||
// hour is ambiguous is a fact about the words, not about the instant they
|
||||
// parsed to.
|
||||
func stillMissingFor(intent router.Intent, utterance string, s dialogue.Slots) []dialogue.Slot {
|
||||
var out []dialogue.Slot
|
||||
for _, want := range wantedSlots[intent] {
|
||||
if slotStillMissing(want, utterance, s) {
|
||||
out = append(out, want)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+159
-14
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -35,6 +36,11 @@ type routedTurn struct {
|
||||
utterance string
|
||||
intent router.Intent
|
||||
at time.Time
|
||||
// traceID — the persisted trace of this turn, stamped after the fact by
|
||||
// stampLastTurn. 0 when nothing persisted, and then a spoken correction
|
||||
// still teaches the classifier: the durable label is the half that needs a
|
||||
// row to point at (V-636).
|
||||
traceID int64
|
||||
}
|
||||
|
||||
// repairWindow — how long a turn stays correctable. Long enough that he can
|
||||
@@ -54,6 +60,13 @@ const repairWindow = 5 * time.Minute
|
||||
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
|
||||
var repairMarkers = lexicon.RepairMarkers()
|
||||
|
||||
// repairNegatives — "she got it wrong" with no target. Matched against the whole
|
||||
// utterance, because these are complete sentences and the markers above are
|
||||
// fragments: "это не" needs an intent word after it, "не так поняла" does not.
|
||||
// Substring matching here would claim "не так" out of any sentence containing it
|
||||
// (V-636).
|
||||
var repairNegatives = lexicon.RepairNegatives()
|
||||
|
||||
// repairIntents — the words he uses for each intent, as dictionary forms. They
|
||||
// used to be prefixes ("заметк"), which is what a prefix list costs: "команд"
|
||||
// also matched "командировка", and "факт" matched "фактически". morph.SameWord
|
||||
@@ -147,14 +160,120 @@ func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
||||
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||
// stampLastTurn attaches the trace id to the turn a correction would point at.
|
||||
// It cannot be done in recordTurn: the trace is written when the turn ends, and
|
||||
// recordTurn runs in the middle of it.
|
||||
func (h *reactiveHandler) stampLastTurn(utterance string, traceID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
last := h.lastRouted
|
||||
// Taken, not read: one utterance is corrected once. Saying "нет, не так"
|
||||
// twice would otherwise redo the same request twice.
|
||||
if h.lastRouted == nil || h.lastRouted.utterance != utterance {
|
||||
return
|
||||
}
|
||||
h.lastRouted.traceID = traceID
|
||||
}
|
||||
|
||||
// takeLastTurnIf atomically claims the previous acted turn only when the
|
||||
// caller can actually handle it. A declined repair must not spend the pointer:
|
||||
// "нет, это заметка" may name the intent Maven already chose and be followed
|
||||
// immediately by the real correction. The older read-then-clear helper lost
|
||||
// the original before checking either that case or the repair window (V-573).
|
||||
func (h *reactiveHandler) takeLastTurnIf(accept func(*routedTurn) bool) *routedTurn {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.lastRouted == nil || !accept(h.lastRouted) {
|
||||
return nil
|
||||
}
|
||||
last := *h.lastRouted
|
||||
// A handled correction is still spent once. Returning a copy prevents a
|
||||
// later trace stamp from mutating the evidence after this resolver owns it.
|
||||
h.lastRouted = nil
|
||||
return last
|
||||
return &last
|
||||
}
|
||||
|
||||
// takeTargetedRepair atomically distinguishes the three outcomes a targeted
|
||||
// correction needs. A recent, differently-routed turn is claimed and spent; a
|
||||
// recent turn already carrying that intent is retained and reported as
|
||||
// already-correct; everything else declines. Treating the second case as a
|
||||
// generic decline lets runTurn route the correction words as a fresh turn and
|
||||
// record them over the very pointer this helper was meant to preserve.
|
||||
func (h *reactiveHandler) takeTargetedRepair(now time.Time, corrected router.Intent) (last *routedTurn, already bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.lastRouted == nil || now.Sub(h.lastRouted.at) > repairWindow {
|
||||
return nil, false
|
||||
}
|
||||
if h.lastRouted.intent == corrected {
|
||||
return nil, true
|
||||
}
|
||||
copy := *h.lastRouted
|
||||
h.lastRouted = nil
|
||||
return ©, false
|
||||
}
|
||||
|
||||
// suspendClarifyForRepair makes a correction an aside to any question already
|
||||
// parked in this dialogue. It is called only after a repair has actually found
|
||||
// a target, so an ordinary utterance that merely resembles one changes no
|
||||
// dialogue state. If the redo itself needs a question, askClarify sees the
|
||||
// suspended flag and pushes that question instead of overwriting the older
|
||||
// request.
|
||||
func (h *reactiveHandler) suspendClarifyForRepair(ctx context.Context) {
|
||||
if h.clarifyStore == nil {
|
||||
return
|
||||
}
|
||||
if q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now()); q != nil {
|
||||
h.noteSuspended(ctx, q)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveUntargetedRepair handles the cheap half of a spoken correction: he says
|
||||
// she got it wrong and does not say what it should have been (V-636).
|
||||
//
|
||||
// It is worth having on its own. V-630 made the target optional on the web for
|
||||
// the same reason: a turn marked wrong with no target is a usable negative, and
|
||||
// requiring the target would cost the correction he was willing to give. Voice
|
||||
// needs it more than the web does — naming an intent aloud means saying
|
||||
// "заметка" or "факт", which is Maven's vocabulary and not his.
|
||||
//
|
||||
// Nothing is redone and the classifier is not taught. There is no target, so
|
||||
// there is nothing to redo it as and nothing to teach. Only the label is written,
|
||||
// and she says so, because a correction he cannot see reads as one that was
|
||||
// dropped.
|
||||
func (h *reactiveHandler) resolveUntargetedRepair(ctx context.Context, text string) (string, bool) {
|
||||
if !isRepairNegative(text) {
|
||||
return "", false
|
||||
}
|
||||
now := h.now()
|
||||
last := h.takeLastTurnIf(func(last *routedTurn) bool {
|
||||
return now.Sub(last.at) <= repairWindow && last.traceID != 0
|
||||
})
|
||||
if last == nil {
|
||||
// No row to point at, so there is no label to write and nothing this
|
||||
// resolver can do. Routing the words normally is the honest outcome.
|
||||
return "", false
|
||||
}
|
||||
h.suspendClarifyForRepair(ctx)
|
||||
h.labelCorrection(ctx, last, "")
|
||||
log.Printf("voice: repair — %q marked wrong, no target given", last.utterance)
|
||||
return phraser.A(phraser.RepairNoted, nil), true
|
||||
}
|
||||
|
||||
// isRepairNegative matches the whole utterance, minus a leading "нет" and any
|
||||
// trailing punctuation. "нет, не так" is the shortest one he says.
|
||||
func isRepairNegative(utterance string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||
s = strings.TrimRight(s, " .!?")
|
||||
for _, p := range []string{"нет,", "нет", "no,", "no"} {
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(s, p)); rest != s && rest != "" {
|
||||
s = rest
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, n := range repairNegatives {
|
||||
if s == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveRepair handles a spoken correction of the previous turn: teach the
|
||||
@@ -164,16 +283,20 @@ func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (strin
|
||||
if !ok || h.router == nil {
|
||||
return "", false
|
||||
}
|
||||
last := h.takeLastTurn()
|
||||
if last == nil || h.now().Sub(last.at) > repairWindow {
|
||||
return "", false
|
||||
}
|
||||
if last.intent == corrected {
|
||||
// She already did what he is asking for. Correcting the classifier
|
||||
// here would teach it the label it produced, and redoing the request
|
||||
// would file it twice.
|
||||
last, already := h.takeTargetedRepair(h.now(), corrected)
|
||||
if already {
|
||||
// This is still a correction turn, not slot material and not a fresh note.
|
||||
// Say why nothing ran, retain the original pointer, and keep any parked
|
||||
// question audible for the next breath.
|
||||
h.suspendClarifyForRepair(ctx)
|
||||
return "это уже " + say + " — ничего не переделываю.", true
|
||||
}
|
||||
if last == nil {
|
||||
// Nothing recent to correct. Routing the words normally is the honest
|
||||
// outcome; an expired pointer cannot become usable again.
|
||||
return "", false
|
||||
}
|
||||
h.suspendClarifyForRepair(ctx)
|
||||
learned := true
|
||||
if err := h.router.CorrectMisroute(ctx, last.utterance, corrected); err != nil {
|
||||
// The redo is still worth doing: he asked for something and it did not
|
||||
@@ -182,6 +305,7 @@ func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (strin
|
||||
learned = false
|
||||
}
|
||||
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
|
||||
h.labelCorrection(ctx, last, string(corrected))
|
||||
|
||||
dec := router.Decision{
|
||||
Utterance: last.utterance,
|
||||
@@ -195,7 +319,7 @@ func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (strin
|
||||
if dec.Slots.Text == "" && corrected != router.IntentReminder {
|
||||
dec.Slots.Text = last.utterance
|
||||
}
|
||||
return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true
|
||||
return repairLine(say, learned) + " " + h.finishRepaired(ctx, dec), true
|
||||
}
|
||||
|
||||
// repairLine — what she says before redoing it, so the correction is visible
|
||||
@@ -207,3 +331,24 @@ func repairLine(say string, learned bool) string {
|
||||
}
|
||||
return "поняла, это " + say + " — запомнила."
|
||||
}
|
||||
|
||||
// labelCorrection promotes a spoken correction into routing_labels, the same
|
||||
// table the /chat gesture writes (V-630, V-636).
|
||||
//
|
||||
// Two sinks and not one, because they keep different things. CorrectMisroute
|
||||
// appends a classifier seed, which is what makes the NEXT turn better today.
|
||||
// The label is what a fitted head trains on later, it survives the 14-day
|
||||
// transcript, and until now only the web produced any. A sample that only ever
|
||||
// held typed turns would skew to whatever he happens to be at a keyboard for,
|
||||
// and voice is where the hard cases are.
|
||||
//
|
||||
// Best-effort and silent. He has already been told the correction landed, and a
|
||||
// second sink failing is not his problem to hear about.
|
||||
func (h *reactiveHandler) labelCorrection(ctx context.Context, last *routedTurn, shouldBe string) {
|
||||
if h.api == nil || last == nil || last.traceID == 0 {
|
||||
return
|
||||
}
|
||||
if err := h.api.CorrectTurn(ctx, last.traceID, shouldBe); err != nil {
|
||||
log.Printf("voice: repair: could not label trace %d: %v", last.traceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
+218
-4
@@ -6,7 +6,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||
@@ -92,7 +94,7 @@ func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRepairIsSpentOnce(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
h.recall.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||
@@ -102,8 +104,17 @@ func TestRepairIsSpentOnce(t *testing.T) {
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||
t.Fatal("the first correction was not handled")
|
||||
}
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||
t.Error("the same turn was corrected twice")
|
||||
before, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil || len(before) != 1 {
|
||||
t.Fatalf("first repair notes=%+v err=%v", before, err)
|
||||
}
|
||||
reply, handled := h.resolveRepair(ctx, "нет, это заметка")
|
||||
if !handled || !strings.Contains(reply, "уже") {
|
||||
t.Fatalf("the repeated correction was not acknowledged as already applied: handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
after, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil || len(after) != 1 {
|
||||
t.Fatalf("the same turn was redone twice: notes=%+v err=%v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +124,119 @@ func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||
h.recordTurn("купить хлеб", router.IntentNote)
|
||||
reply, handled := h.resolveRepair(context.Background(), "нет, это заметка")
|
||||
if !handled || !strings.Contains(reply, "уже") {
|
||||
t.Fatalf("a redundant correction must be acknowledged without redoing it: handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
// Acknowledging the redundant target must not spend the original. If this
|
||||
// resolver declines instead, runTurn routes the correction as a fresh turn
|
||||
// and recordTurn overwrites the pointer even though takeLastTurn retained it.
|
||||
if _, handled := h.resolveRepair(context.Background(), "нет, это факт"); !handled {
|
||||
t.Error("a redundant same-intent repair spent the original turn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairResumesQuestionParkedAfterTheCorrectedTurn(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
h.recall.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||||
router.Slots{Text: "позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected a parked reminder question")
|
||||
}
|
||||
|
||||
reply := h.runTurn(ctx, "нет, это был вопрос", sourceText)
|
||||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||
if !strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("the correction hid the still-live question: reply=%q want suffix=%q", reply, resumed)
|
||||
}
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil {
|
||||
t.Fatal("the correction dropped the parked question")
|
||||
}
|
||||
if q.Attempts != 1 || q.Suspends != 1 {
|
||||
t.Fatalf("the correction spent a retry instead of suspending the question: %+v", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairedClarifyCompletesWithoutDroppingTheOlderQuestion(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
h.recall.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||||
router.Slots{Text: "позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the older reminder question")
|
||||
}
|
||||
|
||||
if reply := h.runTurn(ctx, "нет, это было напоминание", sourceText); !strings.Contains(reply, "Когда") {
|
||||
t.Fatalf("the repaired reminder did not ask for its missing time: %q", reply)
|
||||
}
|
||||
if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 2 {
|
||||
t.Fatalf("the repaired question overwrote the older one: depth=%d want=2", depth)
|
||||
}
|
||||
|
||||
reply := h.runTurn(ctx, "сегодня в 15:00", sourceText)
|
||||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||
if !strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("completing the repaired request did not resume the older one: reply=%q", reply)
|
||||
}
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q == nil || !strings.Contains(q.Utterance, "маме") {
|
||||
t.Fatalf("the older question was lost after the top one completed: %+v", q)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
if err != nil || len(reminders) != 1 || !strings.Contains(reminders[0].Payload, "хлеб") {
|
||||
t.Fatalf("the repaired reminder did not land exactly once: reminders=%+v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairedClarifyGiveUpKeepsTheOlderQuestion(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
older := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime},
|
||||
Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL,
|
||||
}
|
||||
top := &dialogue.PendingQuestion{
|
||||
Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime},
|
||||
Utterance: "напомни купить хлеб", Asked: h.now(), TTL: clarifyTTL,
|
||||
Attempts: dialogue.DefaultMaxAttempts, MaxAttempts: dialogue.DefaultMaxAttempts,
|
||||
}
|
||||
h.clarifyStore.Push(voiceDialogueID, older)
|
||||
h.clarifyStore.Push(voiceDialogueID, top)
|
||||
|
||||
if reply := h.reaskOrGiveUp(ctx, top, top.Slots, "не знаю", ""); reply != clarifyGaveUp {
|
||||
t.Fatalf("reply=%q, want the explicit give-up line", reply)
|
||||
}
|
||||
if depth := h.clarifyStore.Depth(voiceDialogueID); depth != 1 {
|
||||
t.Fatalf("giving up on the top request erased the older flow: depth=%d", depth)
|
||||
}
|
||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||
if q != older {
|
||||
t.Fatalf("survivor=%+v, want the older parked question", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleRepairDoesNotSpendTheOriginal(t *testing.T) {
|
||||
h, _, now := newClarifyHandler(t)
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
*now = now.Add(repairWindow + time.Minute)
|
||||
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
|
||||
t.Error("a correction to the intent she already used was handled")
|
||||
t.Fatal("a stale correction was handled")
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.lastRouted == nil || h.lastRouted.utterance != "купить хлеб" {
|
||||
t.Fatal("a stale declined correction spent the original turn")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,3 +271,95 @@ func TestRepairIntentWordCollisions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// V-636. A spoken correction lands in the same table the /chat gesture writes,
|
||||
// so the sample is not limited to the turns he happened to type.
|
||||
func TestSpokenCorrectionWritesTheLabel(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
emb := router.NewHashEmbedder(256)
|
||||
h.recall.embedder = emb
|
||||
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||
ctx := context.Background()
|
||||
|
||||
id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{
|
||||
Ts: h.now(), Utterance: "купить хлеб", Intent: "fact", Source: "tap:voice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.recordTurn("купить хлеб", router.IntentFact)
|
||||
h.stampLastTurn("купить хлеб", id)
|
||||
|
||||
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||
t.Fatal("the correction was not handled")
|
||||
}
|
||||
labels, err := st.RoutingLabels(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 || labels[0].Was != "fact" || labels[0].ShouldBe != "note" {
|
||||
t.Fatalf("labels %+v: the spoken correction did not land as a pair", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// The cheap half, which voice needs more than the web does: naming an intent
|
||||
// aloud means saying "заметка", which is her vocabulary and not his.
|
||||
func TestUntargetedSpokenCorrection(t *testing.T) {
|
||||
h, st, now := newClarifyHandler(t)
|
||||
ctx := context.Background()
|
||||
seed := func(utterance string) int64 {
|
||||
id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{
|
||||
Ts: h.now(), Utterance: utterance, Intent: "query", Source: "tap:voice",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.recordTurn(utterance, router.IntentQuery)
|
||||
h.stampLastTurn(utterance, id)
|
||||
return id
|
||||
}
|
||||
|
||||
seed("поужинал")
|
||||
reply, handled := h.resolveUntargetedRepair(ctx, "нет, не так")
|
||||
if !handled {
|
||||
t.Fatal("«нет, не так» was not read as a correction")
|
||||
}
|
||||
if reply == "" {
|
||||
t.Error("a correction he cannot hear reads as one that was dropped")
|
||||
}
|
||||
labels, err := st.RoutingLabels(ctx, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 || labels[0].ShouldBe != "" || labels[0].Was != "query" {
|
||||
t.Fatalf("labels %+v: want one untargeted negative naming what she chose", labels)
|
||||
}
|
||||
|
||||
// Outside the window it is a fresh sentence, not a verdict.
|
||||
seed("поужинал ещё раз")
|
||||
*now = now.Add(repairWindow + time.Minute)
|
||||
if _, handled := h.resolveUntargetedRepair(ctx, "не так"); handled {
|
||||
t.Error("a correction outside the window was handled")
|
||||
}
|
||||
}
|
||||
|
||||
// Whole-utterance, never a substring. This is the difference between the
|
||||
// negatives and the markers, and getting it wrong would claim any sentence with
|
||||
// "не так" in it.
|
||||
func TestRepairNegativeIsTheWholeUtterance(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"не так поняла", "нет, не так", "ты ошиблась", "неправильно", "wrong", "no, that was wrong",
|
||||
} {
|
||||
if !isRepairNegative(s) {
|
||||
t.Errorf("%q is not read as a correction", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"это не важно", "напомни не так поздно", "а не завтра", "не так, а вот так — это заметка",
|
||||
"", "нет",
|
||||
} {
|
||||
if isRepairNegative(s) {
|
||||
t.Errorf("%q was read as a correction", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
|
||||
|
||||
// Reply never fails: a clarify, a model error and an unusable generation all
|
||||
// answer from the stub, which is what keeps a turn from breaking on the model.
|
||||
func (r *llmReplier) Reply(d router.Decision) string {
|
||||
func (r *llmReplier) Reply(ctx context.Context, d router.Decision) string {
|
||||
if d.Clarify {
|
||||
// The deck, not the stub's single sentence: a clarify she cannot turn
|
||||
// into a question is the line he hears most often when she misses him,
|
||||
@@ -31,14 +31,29 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
||||
// a generation to say something this small.
|
||||
return clarifyMissedLine(d)
|
||||
}
|
||||
out, err := r.p.PhraseReply(context.Background(), d)
|
||||
if d.Intent == router.IntentFact {
|
||||
// A captured fact is confirmed by echoing him, and the model is not
|
||||
// asked (V-592). It has nothing to phrase FROM: replyContext hands it
|
||||
// "записала факт: water \"drank\"", so every Russian word in the reply
|
||||
// was the model's own invention, and on 2026-08-06 that was "Проверила,
|
||||
// что ты выпел стакан воды" for "я выпил воды".
|
||||
return phraser.FactAck(d.Utterance)
|
||||
}
|
||||
if d.Intent == router.IntentNote {
|
||||
// A successful durable write needs no generation. The resident model
|
||||
// answered one live capture with masculine self-reference ("сохранил")
|
||||
// despite the prompt; the hand-written line is both faster and a hard
|
||||
// persona guarantee on the daemon's reply path (V-721).
|
||||
return phraser.Ack(phraser.AckNote, nil)
|
||||
}
|
||||
out, err := r.p.PhraseReply(ctx, d)
|
||||
if err != nil || out == "" {
|
||||
return r.stub.Reply(d)
|
||||
return r.stub.Reply(ctx, d)
|
||||
}
|
||||
// The persona checks, on the live path (personaguard.go). A reply that
|
||||
// leaks reasoning or calls him "вы" is worse than a flat one.
|
||||
if _, ok := guardSpoken("reply", out); !ok {
|
||||
return r.stub.Reply(d)
|
||||
return r.stub.Reply(ctx, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -20,29 +20,53 @@ type stubCompleter struct {
|
||||
|
||||
func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return s.out, s.err }
|
||||
|
||||
func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
||||
func TestLLMReplierPassesTheModelReplyThroughForOtherIntents(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
got := r.Reply(context.Background(), router.Decision{Intent: router.IntentReminder, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if got != "записала, кофе закончился" {
|
||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||
}
|
||||
}
|
||||
|
||||
type countingCompleter struct {
|
||||
out string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *countingCompleter) Complete(_ context.Context, _ llm.Req) (string, error) {
|
||||
c.calls++
|
||||
return c.out, nil
|
||||
}
|
||||
|
||||
func TestLLMReplierNoteUsesFixedFeminineAcknowledgement(t *testing.T) {
|
||||
c := &countingCompleter{out: `{"response":"Хорошо, сохранил.","mood":"neutral"}`}
|
||||
r := newLLMReplier(c, nil)
|
||||
got := r.Reply(context.Background(), router.Decision{
|
||||
Intent: router.IntentNote, Slots: router.Slots{Text: "запасной ключ лежит в синей коробке"},
|
||||
})
|
||||
if c.calls != 0 {
|
||||
t.Fatalf("note acknowledgement called the resident model %d time(s), want none", c.calls)
|
||||
}
|
||||
if got != "сохранила заметку." {
|
||||
t.Fatalf("note acknowledgement = %q, want the fixed feminine line", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentReminder}, phraser.AckReminder, "llm error")
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentReminder}, phraser.AckReminder, "empty llm")
|
||||
}
|
||||
|
||||
// A clarify never reaches the model, and since Vikunja #457 it is answered from
|
||||
// the clarify deck rather than the stub's single sentence.
|
||||
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
|
||||
got := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "мгм"})
|
||||
if got == "я всё поняла" {
|
||||
t.Fatal("a clarify must not be phrased by the model")
|
||||
}
|
||||
@@ -50,7 +74,7 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||
t.Errorf("on clarify: got %q, want %q", got, want)
|
||||
}
|
||||
// Two different misses do not sound identical.
|
||||
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||
if same := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
|
||||
}
|
||||
}
|
||||
@@ -60,14 +84,14 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||
// produce, which is the same claim without pinning one wording.
|
||||
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
|
||||
t.Helper()
|
||||
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
|
||||
if got := r.Reply(context.Background(), d); !phraser.IsAck(key, nil, got) {
|
||||
t.Errorf("on %s: got %q, want a %q line", what, got, key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||
t.Helper()
|
||||
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
||||
got, want := r.Reply(context.Background(), d), voice.NewStubReplier().Reply(context.Background(), d)
|
||||
if got != want {
|
||||
t.Errorf("on %s: got %q, want stub %q", what, got, want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// mavend/routingtrace.go — persisting the per-turn decision record (V-629).
|
||||
//
|
||||
// internal/decision keeps a 25-turn in-memory ring and persisted nothing, on the
|
||||
// argument that a turn record is read minutes later or never. The owner reversed
|
||||
// that on 06-08-2026, because the routing heads (V-546) cannot be fitted or
|
||||
// calibrated without real utterances and there is no other source of them. The
|
||||
// reversal is written down in docs/plans/21-persisting-the-routing-trace.md.
|
||||
//
|
||||
// The ring stays. It is what /trace reads, it is fast, and it is what a test that
|
||||
// wired no store still gets. This file is the second sink beside it, and it is
|
||||
// nil unless the daemon has a database — no store, no trace, no error.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// traceWriter is the seam the handler persists through. store.Store satisfies
|
||||
// it. nil ⇒ the ring is the only sink, which is the pre-V-629 behaviour exactly.
|
||||
type traceWriter interface {
|
||||
WriteRoutingTrace(ctx context.Context, tr store.RoutingTrace) (int64, error)
|
||||
}
|
||||
|
||||
// traceSink wraps the store, or returns nil when there is none. A typed nil
|
||||
// pointer assigned straight into the interface would be non-nil and would panic
|
||||
// on the first turn, which is the classic shape of this bug.
|
||||
func traceSink(s *store.Store) traceWriter {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// The trace id rides the context, the same seam querysource.go uses and for the
|
||||
// same reason: handleText answers every reach through one string, and threading
|
||||
// a second value through the whole action dispatch would change a signature the
|
||||
// mic, telegram and the web all share. A caller that wants the id asks for a
|
||||
// sink; the mic path does not, and pays nothing.
|
||||
type traceIDKey struct{}
|
||||
|
||||
type traceIDSink struct {
|
||||
mu sync.Mutex
|
||||
id int64
|
||||
}
|
||||
|
||||
func (s *traceIDSink) note(id int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.id = id
|
||||
}
|
||||
|
||||
// ID is the persisted trace for the turn, or 0 when nothing was persisted.
|
||||
func (s *traceIDSink) ID() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.id
|
||||
}
|
||||
|
||||
// withTraceIDSink returns a context that collects the persisted trace id, and
|
||||
// the sink to read after the turn has answered.
|
||||
func withTraceIDSink(ctx context.Context) (context.Context, *traceIDSink) {
|
||||
sink := &traceIDSink{}
|
||||
return context.WithValue(ctx, traceIDKey{}, sink), sink
|
||||
}
|
||||
|
||||
func noteTraceID(ctx context.Context, id int64) {
|
||||
if sink, ok := ctx.Value(traceIDKey{}).(*traceIDSink); ok {
|
||||
sink.note(id)
|
||||
}
|
||||
}
|
||||
|
||||
// pruneTracesOnStart enforces retention once at wiring time. Pruning on write
|
||||
// alone is not enough: a box that goes quiet for a month keeps every row until
|
||||
// the next sixty-fourth turn, and "kept for fourteen days" would then be true
|
||||
// only of a box in daily use. Called for its effect and never blocks a start.
|
||||
func pruneTracesOnStart(s *store.Store, now time.Time) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.PruneRoutingTraces(ctx, now.Add(-store.RoutingTraceRetention)); err != nil {
|
||||
log.Printf("routing trace: prune on start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// persistDecision writes one finished record. It takes the same *decision.Record
|
||||
// the ring takes, so the two sinks cannot disagree about what the turn did.
|
||||
//
|
||||
// Errors are logged and swallowed. A trace is diagnostic and training data, and
|
||||
// a failed insert must never change what the owner hears.
|
||||
func (h *reactiveHandler) persistDecision(turnCtx context.Context, rec *decision.Record, src turnSource) {
|
||||
ctx := turnCtx
|
||||
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
|
||||
return
|
||||
}
|
||||
// Detached from the turn's context, and bounded on its own. Two reasons, and
|
||||
// the first is the one that matters: the turn is over by the time this runs,
|
||||
// so a caller that hung up or timed out would cancel the insert, and the turn
|
||||
// he abandoned halfway is exactly the one worth having. The second is that a
|
||||
// write must not hold the reply, so it gets a second and no more.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
|
||||
defer cancel()
|
||||
claims, err := json.Marshal(rec.Claims)
|
||||
if err != nil {
|
||||
log.Printf("routing trace: marshal claims: %v", err)
|
||||
return
|
||||
}
|
||||
tr := store.RoutingTrace{
|
||||
Ts: rec.Ts,
|
||||
Utterance: rec.Utterance,
|
||||
Source: string(src),
|
||||
Winner: rec.Winner,
|
||||
Intent: wonIntent(rec),
|
||||
ClaimedBeforeHead: claimedBeforeHead(rec),
|
||||
EncoderID: h.encoderID,
|
||||
Outcome: wonAt(rec, decision.StageAction),
|
||||
Claims: claims,
|
||||
}
|
||||
id, err := h.traces.WriteRoutingTrace(ctx, tr)
|
||||
if err != nil {
|
||||
log.Printf("routing trace: write: %v", err)
|
||||
return
|
||||
}
|
||||
// The id goes back to whoever asked for it, so /chat can offer a correction
|
||||
// on the turn it is already showing (V-630). Noted on the ORIGINAL context,
|
||||
// not the detached one above: the sink belongs to the caller's turn.
|
||||
noteTraceID(turnCtx, id)
|
||||
// And the spoken path, which has no reply to hang a badge on: a correction
|
||||
// said out loud points at the previous turn, so it needs that turn's row
|
||||
// (V-636, repair.go).
|
||||
h.stampLastTurn(rec.Utterance, id)
|
||||
}
|
||||
|
||||
// wonIntent — what the winning claimant made the turn. Read from the claim
|
||||
// rather than from the route, because a pre-route resolver wins without routing
|
||||
// and its intent is the honest answer to "what was this turn".
|
||||
func wonIntent(rec *decision.Record) string {
|
||||
for _, c := range rec.Claims {
|
||||
if c.Outcome == decision.Won && c.Intent != "" {
|
||||
return c.Intent
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// wonAt — the claimant that won at one stage. The action stage is what actually
|
||||
// produced the reply, which is a different question from what was routed: a
|
||||
// route that reached a gap and a route that ran are not the same turn.
|
||||
func wonAt(rec *decision.Record, stage string) string {
|
||||
for _, c := range rec.Claims {
|
||||
if c.Stage == stage && c.Outcome == decision.Won {
|
||||
return c.Claimant
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// claimedBeforeHead — a pre-route resolver or a stage-0 grammar answered, so the
|
||||
// turn teaches nothing about the classifier. Those are a large share of real
|
||||
// traffic, and fitting a head on them would fit it to the grammars rather than
|
||||
// to him. Recorded per turn rather than filtered on write, because which share
|
||||
// that is happens to be the number V-632 needs to know.
|
||||
func claimedBeforeHead(rec *decision.Record) bool {
|
||||
stage, _, ok := strings.Cut(rec.Winner, ":")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return stage == decision.StagePreRoute || stage == decision.StageZero
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// A real turn leaves a persisted trace, not only a ring entry. This is the whole
|
||||
// of V-629: without one there is nothing to fit the routing heads from.
|
||||
func TestTurnPersistsTrace(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.traces = traceSink(h.dataStore)
|
||||
h.encoderID = "hash-1024"
|
||||
|
||||
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("persisted %d traces, want 1", len(got))
|
||||
}
|
||||
tr := got[0]
|
||||
if tr.Utterance != "сколько сейчас времени" {
|
||||
t.Errorf("utterance %q", tr.Utterance)
|
||||
}
|
||||
if tr.Source != string(sourceText) {
|
||||
t.Errorf("source %q, want %q", tr.Source, sourceText)
|
||||
}
|
||||
// A stage-0 clock rule answers this one, so the turn teaches the classifier
|
||||
// nothing and the trace has to say so.
|
||||
if !tr.ClaimedBeforeHead {
|
||||
t.Errorf("claimed_before_head false on winner %q", tr.Winner)
|
||||
}
|
||||
if tr.EncoderID != "hash-1024" {
|
||||
t.Errorf("encoder_id %q", tr.EncoderID)
|
||||
}
|
||||
if len(tr.Claims) < 3 {
|
||||
t.Errorf("claims %s: the losers and the never-asked are the point", tr.Claims)
|
||||
}
|
||||
}
|
||||
|
||||
// No store, no trace, and no panic. A typed nil pointer in the interface would
|
||||
// pass the nil check and die on the first turn.
|
||||
func TestNoStoreNoTrace(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.traces = traceSink(nil)
|
||||
|
||||
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
if len(ring.Recent(5)) != 1 {
|
||||
t.Error("the ring is still the first sink and must still hold the turn")
|
||||
}
|
||||
}
|
||||
|
||||
// An empty utterance writes nothing. A blank row carries no label and no
|
||||
// diagnosis, and it is his words the retention bound exists for.
|
||||
func TestEmptyUtteranceIsNotPersisted(t *testing.T) {
|
||||
h := traceHandler(t, decision.NewRing())
|
||||
h.traces = traceSink(h.dataStore)
|
||||
h.persistDecision(context.Background(), &decision.Record{Utterance: " "}, sourceText)
|
||||
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("persisted %d traces for a blank utterance", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
var _ traceWriter = (*store.Store)(nil)
|
||||
|
||||
// The trace id rides back to the caller, which is what makes a correction one
|
||||
// gesture: /chat already has the id, so saying "that was wrong" costs a button
|
||||
// and no lookup (V-630).
|
||||
func TestTurnHandsBackItsTraceID(t *testing.T) {
|
||||
h := traceHandler(t, decision.NewRing())
|
||||
h.traces = traceSink(h.dataStore)
|
||||
|
||||
ctx, sink := withTraceIDSink(context.Background())
|
||||
if reply := h.handleText(ctx, "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
id := sink.ID()
|
||||
if id == 0 {
|
||||
t.Fatal("no trace id came back, so /chat can offer no correction")
|
||||
}
|
||||
// And it names the turn that just ran, so the correction lands on the right
|
||||
// utterance.
|
||||
if err := h.dataStore.CorrectTurn(context.Background(), id, "query", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := h.dataStore.RoutingLabels(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 || labels[0].Utterance != "сколько сейчас времени" {
|
||||
t.Fatalf("labels %+v, want the turn that just ran", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// A turn nobody asked the id of costs nothing, which is the mic path.
|
||||
func TestTurnWithNoSinkStillPersists(t *testing.T) {
|
||||
h := traceHandler(t, decision.NewRing())
|
||||
h.traces = traceSink(h.dataStore)
|
||||
|
||||
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("persisted %d traces, want 1", len(got))
|
||||
}
|
||||
}
|
||||
+22
-10
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
@@ -73,22 +74,26 @@ func mentionsUnknownPlace(u string) bool {
|
||||
// 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.
|
||||
//
|
||||
// The weekday half was a list of STEMS matched with strings.Contains until
|
||||
// V-581 — "сред", "пятниц", "суббот". That is the hand-written Russian pattern
|
||||
// the sweep of 2026-08-04 took out, and it was wrong in the way such a pattern
|
||||
// always is: "среди", "средство" and "средний" all contain "сред", so a question
|
||||
// carrying any of them was answered with onlyNearDaysReply instead of the date.
|
||||
// Whole tokens now, and the weekday itself is router.WeekdayIndex, which reads
|
||||
// the lexicon and asks the dictionary about the case.
|
||||
func mentionsUnknownDay(u string) bool {
|
||||
for _, w := range dayWords {
|
||||
if strings.Contains(u, w) {
|
||||
for _, tok := range quietTokens(u) {
|
||||
if tok == "через" {
|
||||
return true
|
||||
}
|
||||
if _, ok := router.WeekdayIndex(tok); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -149,6 +154,11 @@ func hasDurationWords(u string) bool {
|
||||
// Used by the query handler when answering "когда я это сделал?"-style questions.
|
||||
func formatTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
// The argument is a fact's Ts, which the store hands back as UTC. Only the
|
||||
// last branch names a wall clock, and it named the store's until V-614: an
|
||||
// answer to "когда я это сделал?" read hours off, in the same sentence
|
||||
// shape the plan reads a day in.
|
||||
t = t.Local()
|
||||
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
|
||||
return "только что"
|
||||
}
|
||||
@@ -165,6 +175,8 @@ func formatTime(t time.Time) string {
|
||||
n := int(diff.Hours())
|
||||
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
||||
default:
|
||||
return t.Format("2 января 15:04")
|
||||
// Not t.Format("2 января …"): Go reads that as a literal, so every
|
||||
// fact older than a day used to read as January (Vikunja #507).
|
||||
return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// "когда я это сделал?" answers off a fact's Ts, which the store hands back as
|
||||
// UTC, and the branch that names a wall clock printed it in whatever zone it
|
||||
// arrived in (V-614). The instant here is built three hours off this machine's
|
||||
// zone, so the assertion holds under TZ=UTC as well.
|
||||
func TestFormatTimeReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Now().Add(-72 * time.Hour).In(away)
|
||||
|
||||
got := formatTime(stored)
|
||||
if want := stored.Local().Format("15:04"); !strings.Contains(got, want) {
|
||||
t.Errorf("formatTime = %q, want the hour on his clock (%s)", got, want)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
|
||||
t.Errorf("formatTime = %q reads the zone the fact arrived in (%s)", got, bad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
|
||||
// weekday half of this guard was a list of stems matched with strings.Contains,
|
||||
// so "среди", "средство" and "средний" all read as Wednesday and the question
|
||||
// was answered with onlyNearDaysReply instead of a date.
|
||||
//
|
||||
// The other half of the fix is coverage: a stem list stops at the forms whoever
|
||||
// wrote it thought of, and "воскресеньях" was not one of them.
|
||||
func TestMentionsUnknownDayReadsWordsNotStems(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"какое число в понедельник",
|
||||
"какое число в среду",
|
||||
"какое число в среде",
|
||||
"что там по воскресеньям",
|
||||
"what is the date on friday",
|
||||
"какое число через неделю",
|
||||
} {
|
||||
if !mentionsUnknownDay(u) {
|
||||
t.Errorf("mentionsUnknownDay(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range []string{
|
||||
"какое число в среднем",
|
||||
"сколько это в среднем",
|
||||
"какое сегодня средство",
|
||||
"какое число",
|
||||
} {
|
||||
if mentionsUnknownDay(u) {
|
||||
t.Errorf("mentionsUnknownDay(%q) = true; it names no day", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"regexp"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// A question about her — "что ты умеешь", "кто ты" — used to have no answer at
|
||||
// all (Vikunja #555). It reached the personal boundary, which claimed it as his
|
||||
// and said "не знаю — не нашла у тебя такой записи", because the boundary knows
|
||||
// two sides and this is neither: her own description is not his data and it is
|
||||
// not the world's either. Letting it past the boundary is no better, because
|
||||
// then SearXNG answers about somebody else's assistant.
|
||||
//
|
||||
// The description does NOT live in the note store. Notes are his. A note about
|
||||
// her sitting in his index would come back for "что я записал", would be fed to
|
||||
// the digestion worker as something he said, and would be recalled by vector
|
||||
// proximity for questions that are not about her at all. It is her own text, so
|
||||
// it lives here, in one place, and it is the only copy.
|
||||
//
|
||||
// This source sits ABOVE the boundary, because a question about her never had
|
||||
// an answer below it.
|
||||
|
||||
// selfDescription — what she is and what this box actually does. Frozen text,
|
||||
// and the one rule for editing it: name only what is really wired. Anything
|
||||
// that depends on config — the house, the LAN, the feeds, telegram, search — is
|
||||
// named as depending on what he allowed, never claimed outright. Inventing a
|
||||
// capability here is the same defect as inventing a fact, and it is worse than
|
||||
// silence because he would plan around it.
|
||||
//
|
||||
// Written in her own voice, feminine, addressing him informally, because it is
|
||||
// handed to the phraser as the evidence for the answer and the phraser will
|
||||
// keep the words it is given.
|
||||
const selfDescription = `Я Мэйвен, твоя помощница. Я живу на твоём сервере, ` +
|
||||
`и наружу уходит только поисковый запрос — больше ничего.
|
||||
|
||||
Что я делаю сама: запоминаю, что ты мне говоришь, и потом отвечаю на вопросы ` +
|
||||
`об этом; веду заметки; ставлю напоминания; читаю твой календарь и задачи; ` +
|
||||
`отвечаю на вопросы о мире — сначала поиском, а если сети нет, то по ` +
|
||||
`офлайновой энциклопедии.
|
||||
|
||||
Что зависит от того, что ты мне разрешил: дом, локальная сеть, ленты, ` +
|
||||
`список покупок, погода, телеграм. Если что-то из этого не настроено, я ` +
|
||||
`скажу об этом прямо, а не буду выдумывать ответ.
|
||||
|
||||
Говорю по-русски и по-английски.`
|
||||
|
||||
// selfSeeds — the questions this source claims. Scoring data like every other
|
||||
// topic set: editing one moves the recogniser and has to be re-measured against
|
||||
// TestONNXTopics.
|
||||
//
|
||||
// All of them are about HER — what she is, what she can do, who made her. The
|
||||
// neighbouring set is topicAttend, "что требует внимания", which asks about the
|
||||
// state of his things; the two share almost nothing but the second person.
|
||||
var selfSeeds = []string{
|
||||
"что ты умеешь",
|
||||
"что ты можешь делать",
|
||||
"кто ты такая",
|
||||
"расскажи о себе",
|
||||
"какие у тебя возможности",
|
||||
// Added after measuring: it won self by 0.0002, under the margin, and the
|
||||
// floor does not carry it — "способна" names no verb the floor matches.
|
||||
"на что ты способна",
|
||||
"чем ты можешь помочь",
|
||||
"what can you do",
|
||||
"who are you",
|
||||
}
|
||||
|
||||
// selfFloor — the offline floor, for a handler with no embedder or a turn whose
|
||||
// vector never got computed. Narrow on purpose, like every other floor here: it
|
||||
// answers only when the seeds cannot, and a broad guess made blind is worse
|
||||
// than a narrow one.
|
||||
//
|
||||
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the
|
||||
// Russian patterns spell the boundary out.
|
||||
var selfPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])ты\s+(умеешь|можешь)([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])кто\s+ты([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])(расскажи|поведай)\s+о\s+себе([^\p{L}\p{N}]|$)`),
|
||||
regexp.MustCompile(`(?i)\bwhat\s+can\s+you\s+do\b`),
|
||||
regexp.MustCompile(`(?i)\bwho\s+are\s+you\b`),
|
||||
}
|
||||
|
||||
func selfFloor(utterance string) bool {
|
||||
for _, re := range selfPatterns {
|
||||
if re.MatchString(utterance) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// querySelf answers a question about her from selfDescription. The description
|
||||
// goes through the phraser as evidence so the answer is shaped to what he
|
||||
// asked — "что ты умеешь" and "кто ты" want different halves of it — and falls
|
||||
// back to the text itself, which is already readable, if the model is down.
|
||||
func (h *reactiveHandler) querySelf(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
// Product help is self knowledge too (Vikunja V-720), but unlike the prose description it
|
||||
// must be exact: these examples name the grammar Maven actually accepts.
|
||||
// Answer them before topic scoring so a phrasing such as "как отменить
|
||||
// задачу" cannot leak to SearXNG as generic third-party instructions.
|
||||
switch router.LocalHelpTopic(t.dec.Utterance) {
|
||||
case router.HelpReminderCancel:
|
||||
return "Скажи, например: «отмени напоминание про молоко». Если совпадений несколько, я попрошу выбрать одно.", true
|
||||
case router.HelpTaskDrop:
|
||||
return "Скажи, например: «убери из задач настроить бэкапы». Я уберу задачу из активного списка, не отмечая её выполненной.", true
|
||||
}
|
||||
if !h.turnIsAbout(ctx, t, topicSelf, selfFloor) {
|
||||
return "", false
|
||||
}
|
||||
var reply string
|
||||
if h.phraser != nil {
|
||||
var err error
|
||||
reply, err = h.phraser.PhraseSelf(ctx, t.dec.Utterance, selfDescription)
|
||||
if err != nil {
|
||||
log.Printf("voice: phrase self: %v", err)
|
||||
}
|
||||
}
|
||||
if reply == "" {
|
||||
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": selfDescription})
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestSelfFloorClaimsAQuestionAboutHerAndNothingElse — the offline floor, which
|
||||
// is what answers with no embedder. Narrow on purpose, so the rows that must
|
||||
// NOT match are the point.
|
||||
func TestSelfFloorClaimsAQuestionAboutHerAndNothingElse(t *testing.T) {
|
||||
claimed := []string{
|
||||
"что ты умеешь",
|
||||
"что ты можешь",
|
||||
"а что ты умеешь?",
|
||||
"кто ты",
|
||||
"кто ты такая?",
|
||||
"расскажи о себе",
|
||||
"what can you do",
|
||||
"who are you",
|
||||
}
|
||||
for _, u := range claimed {
|
||||
if !selfFloor(u) {
|
||||
t.Errorf("%q is a question about her and the floor missed it", u)
|
||||
}
|
||||
}
|
||||
declined := []string{
|
||||
"что у меня сегодня",
|
||||
"расскажи про байкал",
|
||||
"кто изобрёл телефон",
|
||||
"что требует внимания",
|
||||
"запиши что я пил воду",
|
||||
// The floor spells its own word boundaries out, because Go's \b never
|
||||
// fires next to a Cyrillic letter. Without that these would match.
|
||||
"кто тыкал в розетку",
|
||||
"расскажи о себестоимости",
|
||||
}
|
||||
for _, u := range declined {
|
||||
if selfFloor(u) {
|
||||
t.Errorf("%q is not about her and the floor claimed it", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfSourceAnswersFromTheDescription — with no embedder the source falls
|
||||
// to the floor, and the answer has to be the description rather than silence.
|
||||
func TestSelfSourceAnswersFromTheDescription(t *testing.T) {
|
||||
h := personalHandler()
|
||||
reply, claimed := h.querySelf(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "что ты умеешь"},
|
||||
})
|
||||
if !claimed {
|
||||
t.Fatal("a question about her must be claimed above the boundary")
|
||||
}
|
||||
if !strings.Contains(reply, "напоминания") {
|
||||
t.Errorf("the answer must come from the description: %q", reply)
|
||||
}
|
||||
if _, claimed := h.querySelf(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Utterance: "почему небо синее"},
|
||||
}); claimed {
|
||||
t.Error("a world question must pass this source")
|
||||
}
|
||||
}
|
||||
|
||||
// V-720: asking how to operate Maven is never a third-party web-search query.
|
||||
func TestMavenHowToAnswersLocallyWithoutSearch(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
utterance string
|
||||
want string
|
||||
}{
|
||||
{"как отменить напоминание про молоко?", "отмени напоминание"},
|
||||
{"как отменить задачу настроить бэкапы?", "убери из задач"},
|
||||
{"можно ли отменить напоминание?", "отмени напоминание"},
|
||||
{"can I cancel a reminder?", "отмени напоминание"},
|
||||
{"could I cancel a task?", "убери из задач"},
|
||||
} {
|
||||
h, seen := searchHandler(t,
|
||||
`{"answers":["Инструкция стороннего приложения"],"results":[]}`,
|
||||
200)
|
||||
reply := h.actionQuery(context.Background(), router.Decision{
|
||||
Intent: router.IntentQuery,
|
||||
Utterance: testCase.utterance,
|
||||
Source: router.SourceSelf,
|
||||
SourceAnchored: true,
|
||||
})
|
||||
if !strings.Contains(reply, testCase.want) {
|
||||
t.Errorf("%q reply = %q, want local usage example containing %q", testCase.utterance, reply, testCase.want)
|
||||
}
|
||||
if *seen != "" {
|
||||
t.Errorf("%q leaked to search as %q", testCase.utterance, *seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfDescriptionHoldsThePersona — it is her own text and she reads it out,
|
||||
// so the same rules the phrasing eval enforces apply to it. Feminine
|
||||
// self-reference, informal address, no pet names.
|
||||
func TestSelfDescriptionHoldsThePersona(t *testing.T) {
|
||||
lower := strings.ToLower(selfDescription)
|
||||
for _, bad := range []string{"я рад ", "я готов ", "вы ", "ваш", "милый", "дорогой"} {
|
||||
if strings.Contains(lower, bad) {
|
||||
t.Errorf("the description breaks the persona on %q", bad)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"тво", "ты"} {
|
||||
if !strings.Contains(lower, want) {
|
||||
t.Errorf("the description must address him directly, missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfDescriptionClaimsNothingUnconditionally — the constraint that makes
|
||||
// this text safe to read out. Every capability that depends on config has to be
|
||||
// named as depending on it, and inventing one here is the same defect as
|
||||
// inventing a fact.
|
||||
func TestSelfDescriptionClaimsNothingUnconditionally(t *testing.T) {
|
||||
conditional := selfDescription[strings.Index(selfDescription, "Что зависит"):]
|
||||
for _, cap := range []string{"дом", "локальная сеть", "ленты", "список покупок", "погода", "телеграм"} {
|
||||
if !strings.Contains(conditional, cap) {
|
||||
t.Errorf("%q is configured, not wired — it must sit under the conditional half", cap)
|
||||
}
|
||||
}
|
||||
}
|
||||
+412
-16
@@ -12,6 +12,7 @@
|
||||
// what was SENT — every delivery.Sendable the dispatcher emitted
|
||||
// what ARRIVED — the unified intake journal from #283
|
||||
// what TOOLS were called — the recorded requests against fake Praxis/Nexis/Hexis
|
||||
// what is DURABLE — typed notes/tasks/reminders/facts store state
|
||||
// what did NOT happen — expect_no_send / expect_no_call, first-class
|
||||
//
|
||||
// The last one is the point. Maven's hard constraints are mostly negative —
|
||||
@@ -127,10 +128,14 @@ type toolRow struct {
|
||||
// Route and Reply are separate because the same model serves both contracts
|
||||
// (CLAUDE.md, "LLM output contract"): a grammar-constrained call is a routing
|
||||
// call and gets Route, an unconstrained one is a phrasing call and gets Reply.
|
||||
// HistoryContains makes a chat reply conditional on the transcript the daemon
|
||||
// supplied. It prevents a canned answer from making a continuity scenario pass
|
||||
// while the referent is still absent from the model input.
|
||||
type scriptEntry struct {
|
||||
Match string `json:"match"`
|
||||
Route string `json:"route,omitempty"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
Match string `json:"match"`
|
||||
Route string `json:"route,omitempty"`
|
||||
Reply string `json:"reply,omitempty"`
|
||||
HistoryContains []string `json:"history_contains,omitempty"`
|
||||
}
|
||||
|
||||
// step — one scripted moment. At is "HH:MM" or "HH:MM:SS", interpreted in the
|
||||
@@ -186,14 +191,81 @@ type step struct {
|
||||
|
||||
// --- assertions ---
|
||||
|
||||
ExpectReply []string `json:"expect_reply_contains,omitempty"`
|
||||
ExpectNotReply []string `json:"expect_reply_lacks,omitempty"`
|
||||
ExpectSent []string `json:"expect_sent_contains,omitempty"`
|
||||
ExpectNoSend bool `json:"expect_no_send,omitempty"`
|
||||
ExpectCalled []string `json:"expect_called,omitempty"`
|
||||
ExpectNotCalled []string `json:"expect_not_called,omitempty"`
|
||||
ExpectEvents []string `json:"expect_events,omitempty"`
|
||||
ExpectNoEvents bool `json:"expect_no_events,omitempty"`
|
||||
ExpectReply []string `json:"expect_reply_contains,omitempty"`
|
||||
ExpectNotReply []string `json:"expect_reply_lacks,omitempty"`
|
||||
ExpectSent []string `json:"expect_sent_contains,omitempty"`
|
||||
ExpectNoSend bool `json:"expect_no_send,omitempty"`
|
||||
ExpectCalled []string `json:"expect_called,omitempty"`
|
||||
ExpectNotCalled []string `json:"expect_not_called,omitempty"`
|
||||
ExpectEvents []string `json:"expect_events,omitempty"`
|
||||
ExpectNoEvents bool `json:"expect_no_events,omitempty"`
|
||||
ExpectStore *storeStateExpectation `json:"expect_store,omitempty"`
|
||||
}
|
||||
|
||||
// storeStateExpectation is a typed, exact read of Maven's four user-visible
|
||||
// durable stores. Reply assertions prove what she said; these prove what the
|
||||
// turn actually committed. Each selected collection can assert its total row
|
||||
// count and exact row identity independently, so a duplicate insert cannot be
|
||||
// hidden by finding one matching row.
|
||||
type storeStateExpectation struct {
|
||||
Notes *noteStateExpectation `json:"notes,omitempty"`
|
||||
Tasks *taskStateExpectation `json:"tasks,omitempty"`
|
||||
Reminders *reminderStateExpectation `json:"reminders,omitempty"`
|
||||
Facts *factStateExpectation `json:"facts,omitempty"`
|
||||
}
|
||||
|
||||
type noteStateExpectation struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
Rows []noteRowExpectation `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type noteRowExpectation struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
type taskStateExpectation struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
Rows []taskRowExpectation `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type taskRowExpectation struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ResolvedAt string `json:"resolved_at,omitempty"`
|
||||
ResolvedBy string `json:"resolved_by,omitempty"`
|
||||
}
|
||||
|
||||
type reminderStateExpectation struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
Rows []reminderRowExpectation `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type reminderRowExpectation struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
FireAt string `json:"fire_at,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type factStateExpectation struct {
|
||||
Count *int `json:"count,omitempty"`
|
||||
Rows []factRowExpectation `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
type factRowExpectation struct {
|
||||
ID int64 `json:"id,omitempty"`
|
||||
At string `json:"at,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Confidence *float64 `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
type signalStep struct {
|
||||
@@ -347,6 +419,68 @@ func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
||||
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
|
||||
}
|
||||
|
||||
// scriptedPhraser answers the chat path from the same script the router reads.
|
||||
//
|
||||
// It exists because actionChat calls h.phraser.PhraseChat, and the production
|
||||
// implementation posts raw HTTP to /v1/chat/completions rather than going
|
||||
// through the llm client scriptedLLM stands in for. So until this, no scenario
|
||||
// could script what she SAYS on a chat turn: the simulator wired phraser.NewStub()
|
||||
// and every chat reply came back as a pick from fallbacks_ru_v1.json, four
|
||||
// variants deep, which varied between two runs of one scenario (V-542 item 4).
|
||||
//
|
||||
// Everything except PhraseChat is the Stub's, by embedding. A nudge and a
|
||||
// reminder are phrased by the tick loop, which has its own phraser and its own
|
||||
// assertions; this seam is only about the conversation.
|
||||
type scriptedPhraser struct {
|
||||
*phraser.Stub
|
||||
entries []scriptEntry
|
||||
}
|
||||
|
||||
// PhraseChat returns the scripted reply for the utterance, or an error when the
|
||||
// scenario scripted none. The error rather than a fallback is deliberate and
|
||||
// matches scriptedLLM: actionChat logs it and falls back to ChatFallback(), so a
|
||||
// scenario that never meant to assert on a chat reply behaves exactly as it did
|
||||
// before, and one that DID means to is told its script has a hole.
|
||||
func (p *scriptedPhraser) PhraseChat(_ context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
||||
for _, e := range p.entries {
|
||||
if e.Reply == "" {
|
||||
continue
|
||||
}
|
||||
if e.Match != "" && !strings.Contains(strings.ToLower(utterance), strings.ToLower(e.Match)) {
|
||||
continue
|
||||
}
|
||||
for _, want := range e.HistoryContains {
|
||||
found := false
|
||||
for _, turn := range history {
|
||||
if containsFold(turn.Text, want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return "", fmt.Errorf("simulator: chat history for %q does not contain %q: %+v",
|
||||
truncateRunes(utterance, 60), want, history)
|
||||
}
|
||||
}
|
||||
return chatReplyText(e.Reply), nil
|
||||
}
|
||||
return "", fmt.Errorf("simulator: no scripted chat reply for %q", truncateRunes(utterance, 60))
|
||||
}
|
||||
|
||||
// chatReplyText reads a scripted reply in either shape the phrasing contract
|
||||
// allows: the {"response","mood"} object the model emits, or plain text.
|
||||
// LLMPhraser does this parse itself, so a scenario writes one thing and both
|
||||
// paths understand it.
|
||||
func chatReplyText(reply string) string {
|
||||
var out struct {
|
||||
Response string `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reply), &out); err == nil && out.Response != "" {
|
||||
return out.Response
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Building the world
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -374,6 +508,17 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
})
|
||||
tl := newTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules,
|
||||
time.Minute, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||
// Production upgrades the voice handler from the direct store adapter to
|
||||
// daemonAPI after the tick loop exists. Mirror that seam so a simulated
|
||||
// day-plan query reads the real store-backed plan instead of the direct
|
||||
// adapter's "not available" refusal. The fake clock is the one deliberate
|
||||
// difference from production's time.Now.
|
||||
api = &daemonAPI{
|
||||
CoreAPI: api,
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan {
|
||||
return tl.dayPlan(ctx, clock.Now())
|
||||
},
|
||||
}
|
||||
|
||||
scripted := &scriptedLLM{entries: sc.Script}
|
||||
|
||||
@@ -425,7 +570,8 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
// used to be built on a nil API, which meant any scenario that produced an
|
||||
// act panicked the moment the matcher was consulted.
|
||||
matcher := tool.NewMatcher(api)
|
||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted), nil)
|
||||
timeParser := router.NewPythonDateParser()
|
||||
|
||||
w.handler = &reactiveHandler{
|
||||
stt: simTranscriber{},
|
||||
@@ -440,14 +586,15 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
||||
api: api,
|
||||
matcher: matcher,
|
||||
tools: tool.NewExecutor(api, 5*time.Second),
|
||||
phraser: phraser.NewStub(),
|
||||
phraser: &scriptedPhraser{Stub: phraser.NewStub(), entries: sc.Script},
|
||||
replier: newLLMReplier(scripted, nil),
|
||||
now: clock.Now,
|
||||
dataStore: st,
|
||||
timeParser: router.StubDateTimeParser{},
|
||||
timeParser: timeParser,
|
||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||
clarifyMaxAttempts: dialogue.DefaultMaxAttempts,
|
||||
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
||||
ecosystem: eco,
|
||||
}
|
||||
return w
|
||||
@@ -533,7 +680,7 @@ func (w *simWorld) run(sc scenario) {
|
||||
eventsBefore := w.publishCount()
|
||||
|
||||
w.stimulate(ctx, s)
|
||||
w.assert(i, s, sendsBefore, callsBefore, eventsBefore)
|
||||
w.assert(ctx, i, s, sendsBefore, callsBefore, eventsBefore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,7 +881,7 @@ func (w *simWorld) callPaths() []string { return w.callPathsSince(nil) }
|
||||
// Assertions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (w *simWorld) assert(i int, s step, sendsBefore int, callsBefore []int, eventsBefore int) {
|
||||
func (w *simWorld) assert(ctx context.Context, i int, s step, sendsBefore int, callsBefore []int, eventsBefore int) {
|
||||
w.t.Helper()
|
||||
where := fmt.Sprintf("step %d (%s)", i+1, s.At)
|
||||
if s.Note != "" {
|
||||
@@ -798,6 +945,197 @@ func (w *simWorld) assert(i int, s step, sendsBefore int, callsBefore []int, eve
|
||||
fail("expected nothing to arrive, %d event(s) were published",
|
||||
w.publishCount()-eventsBefore)
|
||||
}
|
||||
if s.ExpectStore != nil {
|
||||
w.assertStoreState(ctx, *s.ExpectStore, fail)
|
||||
}
|
||||
}
|
||||
|
||||
const simStateReadLimit = 10_000
|
||||
|
||||
func (w *simWorld) assertStoreState(ctx context.Context, want storeStateExpectation, fail func(string, ...any)) {
|
||||
if want.Notes != nil {
|
||||
rows, err := w.store.RecentNotes(ctx, simStateReadLimit)
|
||||
if err != nil {
|
||||
fail("read notes for store assertion: %v", err)
|
||||
} else {
|
||||
assertStateCount("notes", want.Notes.Count, len(rows), fail)
|
||||
used := make([]bool, len(rows))
|
||||
for _, expected := range want.Notes.Rows {
|
||||
matched, matchErr := matchDistinct(rows, used, func(row store.Note) (bool, error) {
|
||||
return w.noteStateMatches(row, expected)
|
||||
})
|
||||
if matchErr != nil {
|
||||
fail("invalid note expectation %+v: %v", expected, matchErr)
|
||||
} else if !matched {
|
||||
fail("no distinct note matches %+v; notes: %+v", expected, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if want.Tasks != nil {
|
||||
rows, err := w.store.ListTasks(ctx, "")
|
||||
if err != nil {
|
||||
fail("read tasks for store assertion: %v", err)
|
||||
} else {
|
||||
if want.Tasks.Count != nil && *want.Tasks.Count > store.MaxTaskRows {
|
||||
fail("task count assertion %d exceeds the store read bound %d", *want.Tasks.Count, store.MaxTaskRows)
|
||||
} else {
|
||||
assertStateCount("tasks", want.Tasks.Count, len(rows), fail)
|
||||
}
|
||||
used := make([]bool, len(rows))
|
||||
for _, expected := range want.Tasks.Rows {
|
||||
matched, matchErr := matchDistinct(rows, used, func(row store.Task) (bool, error) {
|
||||
return w.taskStateMatches(row, expected)
|
||||
})
|
||||
if matchErr != nil {
|
||||
fail("invalid task expectation %+v: %v", expected, matchErr)
|
||||
} else if !matched {
|
||||
fail("no distinct task matches %+v; tasks: %+v", expected, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if want.Reminders != nil {
|
||||
rows, err := w.store.ListReminders(ctx, simStateReadLimit)
|
||||
if err != nil {
|
||||
fail("read reminders for store assertion: %v", err)
|
||||
} else {
|
||||
assertStateCount("reminders", want.Reminders.Count, len(rows), fail)
|
||||
used := make([]bool, len(rows))
|
||||
for _, expected := range want.Reminders.Rows {
|
||||
matched, matchErr := matchDistinct(rows, used, func(row store.Reminder) (bool, error) {
|
||||
return w.reminderStateMatches(row, expected)
|
||||
})
|
||||
if matchErr != nil {
|
||||
fail("invalid reminder expectation %+v: %v", expected, matchErr)
|
||||
} else if !matched {
|
||||
fail("no distinct reminder matches %+v; reminders: %+v", expected, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if want.Facts != nil {
|
||||
rows, err := w.store.RecentFacts(ctx, simStateReadLimit)
|
||||
if err != nil {
|
||||
fail("read facts for store assertion: %v", err)
|
||||
} else {
|
||||
assertStateCount("facts", want.Facts.Count, len(rows), fail)
|
||||
used := make([]bool, len(rows))
|
||||
for _, expected := range want.Facts.Rows {
|
||||
matched, matchErr := matchDistinct(rows, used, func(row store.Fact) (bool, error) {
|
||||
return w.factStateMatches(row, expected)
|
||||
})
|
||||
if matchErr != nil {
|
||||
fail("invalid fact expectation %+v: %v", expected, matchErr)
|
||||
} else if !matched {
|
||||
fail("no distinct fact matches %+v; facts: %+v", expected, rows)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertStateCount(kind string, want *int, got int, fail func(string, ...any)) {
|
||||
if want != nil && got != *want {
|
||||
fail("%s count = %d, want %d", kind, got, *want)
|
||||
}
|
||||
}
|
||||
|
||||
// matchDistinct prevents two expectations from being satisfied by the same
|
||||
// durable row. This is important for identity assertions where two records may
|
||||
// intentionally carry the same text but have different lifecycle states.
|
||||
func matchDistinct[T any](rows []T, used []bool, matches func(T) (bool, error)) (bool, error) {
|
||||
for i, row := range rows {
|
||||
if used[i] {
|
||||
continue
|
||||
}
|
||||
ok, err := matches(row)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
used[i] = true
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (w *simWorld) noteStateMatches(got store.Note, want noteRowExpectation) (bool, error) {
|
||||
if want.ID != 0 && got.ID != want.ID || want.Text != "" && got.Text != want.Text ||
|
||||
want.Source != "" && got.Source != want.Source {
|
||||
return false, nil
|
||||
}
|
||||
return w.stateTimeMatches(got.Ts, want.At)
|
||||
}
|
||||
|
||||
func (w *simWorld) taskStateMatches(got store.Task, want taskRowExpectation) (bool, error) {
|
||||
if want.ID != 0 && got.ID != want.ID || want.Text != "" && got.Text != want.Text ||
|
||||
want.Source != "" && got.Source != want.Source || want.Status != "" && got.Status != want.Status ||
|
||||
want.ResolvedBy != "" && got.ResolvedBy != want.ResolvedBy {
|
||||
return false, nil
|
||||
}
|
||||
if ok, err := w.stateTimeMatches(got.CreatedTs, want.CreatedAt); err != nil || !ok {
|
||||
return ok, err
|
||||
}
|
||||
if want.ResolvedAt == "" {
|
||||
return true, nil
|
||||
}
|
||||
if got.ResolvedTs == nil {
|
||||
return false, nil
|
||||
}
|
||||
return w.stateTimeMatches(*got.ResolvedTs, want.ResolvedAt)
|
||||
}
|
||||
|
||||
func (w *simWorld) reminderStateMatches(got store.Reminder, want reminderRowExpectation) (bool, error) {
|
||||
if want.ID != 0 && got.ID != want.ID || want.Text != "" && got.Text() != want.Text ||
|
||||
want.Status != "" && got.Status != want.Status {
|
||||
return false, nil
|
||||
}
|
||||
return w.stateTimeMatches(got.FireTs, want.FireAt)
|
||||
}
|
||||
|
||||
func (w *simWorld) factStateMatches(got store.Fact, want factRowExpectation) (bool, error) {
|
||||
if want.ID != 0 && got.ID != want.ID || want.Kind != "" && string(got.Kind) != want.Kind ||
|
||||
want.Key != "" && got.Key != want.Key || want.Value != "" && got.Value != want.Value ||
|
||||
want.Source != "" && got.Source != want.Source ||
|
||||
want.Confidence != nil && got.Confidence != *want.Confidence {
|
||||
return false, nil
|
||||
}
|
||||
return w.stateTimeMatches(got.Ts, want.At)
|
||||
}
|
||||
|
||||
func (w *simWorld) stateTimeMatches(got time.Time, raw string) (bool, error) {
|
||||
if raw == "" {
|
||||
return true, nil
|
||||
}
|
||||
want, err := w.stateTime(raw)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return got.Equal(want), nil
|
||||
}
|
||||
|
||||
// stateTime accepts either an absolute RFC3339 instant or the same local
|
||||
// HH:MM[:SS] shape scenario steps use. The latter keeps fixtures readable
|
||||
// while still comparing exact instants after the store normalises to UTC.
|
||||
func (w *simWorld) stateTime(raw string) (time.Time, error) {
|
||||
if strings.Contains(raw, "T") {
|
||||
return time.Parse(time.RFC3339, raw)
|
||||
}
|
||||
layout := "15:04"
|
||||
if strings.Count(raw, ":") == 2 {
|
||||
layout = "15:04:05"
|
||||
}
|
||||
hm, err := time.Parse(layout, raw)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("expected HH:MM[:SS] or RFC3339, got %q: %w", raw, err)
|
||||
}
|
||||
return time.Date(w.start.Year(), w.start.Month(), w.start.Day(),
|
||||
hm.Hour(), hm.Minute(), hm.Second(), 0, w.loc), nil
|
||||
}
|
||||
|
||||
func sendableTexts(sends []delivery.Sendable) []string {
|
||||
@@ -867,6 +1205,64 @@ func TestSimulatorScenarios(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSimulatorWorldMirrorsProductionConversationSeams pins the two daemon
|
||||
// constructor upgrades the continuous scenario needs. A bare store API cannot
|
||||
// answer DayPlan, and a nil handler extractor cannot complete a parked reminder
|
||||
// from the next turn; either drift would make the simulator exercise a smaller
|
||||
// system than production while still producing plausible replies.
|
||||
func TestSimulatorWorldMirrorsProductionConversationSeams(t *testing.T) {
|
||||
sc := scenario{SchemaVersion: 1, Name: "constructor-seams", Start: "2026-08-15T08:00:00+04:00"}
|
||||
w := newSimWorld(t, sc)
|
||||
if w.handler.api != w.api {
|
||||
t.Fatal("handler did not receive the simulator's upgraded daemon API")
|
||||
}
|
||||
plan, err := w.handler.api.DayPlan(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("simulator day-plan seam is unavailable: %v", err)
|
||||
}
|
||||
planY, planM, planD := plan.Date.In(w.loc).Date()
|
||||
wantY, wantM, wantD := w.start.Date()
|
||||
if plan.Date.IsZero() || planY != wantY || planM != wantM || planD != wantD {
|
||||
t.Fatalf("day plan date = %v, want the fake-clock day %v", plan.Date, w.start)
|
||||
}
|
||||
if w.handler.extractor.Time == nil || w.handler.timeParser == nil {
|
||||
t.Fatal("simulator left the clarify time parser unwired")
|
||||
}
|
||||
slots := w.handler.extractor.Extract(context.Background(), router.IntentReminder,
|
||||
"сегодня в 10:00", w.clock.Now())
|
||||
if !slots.HasTime || !slots.Time.Equal(w.timeOf("10:00")) {
|
||||
t.Fatalf("clarify extractor parsed %+v, want the fake-clock day at 10:00", slots)
|
||||
}
|
||||
}
|
||||
|
||||
// The reported duplicate transcript was a diagnostic artefact: two adjacent
|
||||
// sed ranges both included boundary line 620. Source had one log call. Keep an
|
||||
// executable exact-count assertion so a real duplicate cannot be introduced
|
||||
// later and mistaken for another display artefact.
|
||||
func TestSimulatorTranscriptRecordsEachSpokenTurnOnce(t *testing.T) {
|
||||
sc := scenario{
|
||||
SchemaVersion: 1,
|
||||
Name: "transcript-count",
|
||||
Start: "2026-08-15T08:00:00+04:00",
|
||||
Script: []scriptEntry{{
|
||||
Match: "привет", Route: `[{"intent":"chat","text":"привет"}]`,
|
||||
Reply: `{"response":"Привет.","mood":"happy"}`,
|
||||
}},
|
||||
}
|
||||
w := newSimWorld(t, sc)
|
||||
w.stimulate(context.Background(), step{Say: "привет"})
|
||||
want := "08:00:00 он: привет"
|
||||
count := 0
|
||||
for _, line := range w.transcript {
|
||||
if line == want {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("owner transcript line occurred %d times, want exactly once: %v", count, w.transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func loadScenario(t *testing.T, path string) scenario {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(path)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/stt"
|
||||
)
|
||||
|
||||
// A box with no workstation.stt block transcribes exactly as it did before the
|
||||
// seam existed: the floor is handed back untouched, and nothing probes.
|
||||
func TestSttSeamWithNoBlockIsTheFloor(t *testing.T) {
|
||||
floor := stt.NewStub()
|
||||
got, pair := sttSeam(&config.Config{}, floor)
|
||||
if pair != nil {
|
||||
t.Fatal("no block must build no pair")
|
||||
}
|
||||
if got != stt.Transcriber(floor) {
|
||||
t.Fatal("no block must hand back the floor itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSttSeamPrefersTheWorkstation(t *testing.T) {
|
||||
cfg := &config.Config{Workstation: &config.WorkstationConfig{
|
||||
URL: "http://127.0.0.1:1",
|
||||
Stt: &config.WorkstationSttConfig{
|
||||
URL: "http://127.0.0.1:2/transcribe",
|
||||
Health: "http://127.0.0.1:2/health",
|
||||
},
|
||||
}}
|
||||
got, pair := sttSeam(cfg, stt.NewStub())
|
||||
if pair == nil {
|
||||
t.Fatal("a configured block must build a pair")
|
||||
}
|
||||
defer pair.Stop()
|
||||
if got != stt.Transcriber(pair) {
|
||||
t.Fatal("the pair is what callers must transcribe through")
|
||||
}
|
||||
// Nothing answers on port 2, so the seam is the floor until it does.
|
||||
if pair.Available() {
|
||||
t.Fatal("an unreachable workstation must not be available")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// mavend/telegramintake.go — wiring the inbound telegram poller (V-637).
|
||||
//
|
||||
// The poller reaches the daemon through ipc.CoreAPI and nothing else, so a
|
||||
// telegram turn takes exactly the path the web's POST /api/chat takes: Chat
|
||||
// returns the reply and the persisted trace id, and CorrectTurn writes the
|
||||
// label. Nothing in internal/delivery knows what a handler is.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// wireTelegramIntake starts the poller, or returns having done nothing. It is
|
||||
// nil-safe in every argument, because it is called from both boot paths — the
|
||||
// unlocked start and the passkey unlock — and telegram must behave the same on
|
||||
// either.
|
||||
//
|
||||
// A sink that will not build is logged rather than fatal here. The push half
|
||||
// already failed the boot in wireDispatcher for the same config, so a second
|
||||
// hard failure would only lose that message.
|
||||
func wireTelegramIntake(ctx context.Context, wg *sync.WaitGroup, api ipc.CoreAPI, cfg *config.Config) {
|
||||
if cfg == nil || cfg.Telegram == nil || cfg.Telegram.Disabled || !cfg.Telegram.Intake || api == nil {
|
||||
return
|
||||
}
|
||||
sink, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
log.Printf("telegram intake: %v", err)
|
||||
return
|
||||
}
|
||||
poller, err := telegramsink.NewPoller(sink, chatTurnFn(api), api.CorrectTurn)
|
||||
if err != nil {
|
||||
log.Printf("telegram intake: %v", err)
|
||||
return
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
poller.Run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// chatTurnFn adapts ipc.Chat to the poller's Turn. The trace id comes back on
|
||||
// the reply because the daemon's Chat collects it off the context (V-630), so
|
||||
// the chat can offer the same correction the web does without a second op.
|
||||
func chatTurnFn(api ipc.CoreAPI) telegramsink.Turn {
|
||||
return func(ctx context.Context, conversation, text string) (string, int64, error) {
|
||||
reply, err := api.Chat(ctx, conversation, text)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return reply.Reply, reply.TraceID, nil
|
||||
}
|
||||
}
|
||||
+521
@@ -0,0 +1,521 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "personal_boundary_v1",
|
||||
"notes": [
|
||||
"Held-out personal-boundary fixture for V-702. Cases are not production seeds and the fixture test enforces that after case folding and punctuation removal.",
|
||||
"The matrix is balanced: three cases for every stratum × language × expected side cell (6 × 2 × 2 × 3 = 72).",
|
||||
"Personal means that only the owner's local data can answer. World means that an outside knowledge source can answer even when the wording uses first person or possession.",
|
||||
"Public subjects are deliberately varied. No case repeats the Baikal regression or changes only its place name."
|
||||
],
|
||||
"cases": [
|
||||
{
|
||||
"id": "pb-ru-remembered-personal-01",
|
||||
"utterance": "что я раньше говорил насчёт бессонницы?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-remembered-personal-02",
|
||||
"utterance": "помнишь, какие причины увольнения я называл?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-remembered-personal-03",
|
||||
"utterance": "упоминал ли я, куда хочу переехать?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-remembered-world-01",
|
||||
"utterance": "что Чехов говорил о краткости?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-remembered-world-02",
|
||||
"utterance": "какие причины кризиса называли экономисты?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-remembered-world-03",
|
||||
"utterance": "что свидетели рассказывали об извержении Кракатау?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-remembered-personal-01",
|
||||
"utterance": "what was it I told you about changing careers?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-remembered-personal-02",
|
||||
"utterance": "have I ever mentioned where I studied?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-remembered-personal-03",
|
||||
"utterance": "do you remember which camera I said I preferred?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-remembered-world-01",
|
||||
"utterance": "what did Marie Curie write about radium?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-remembered-world-02",
|
||||
"utterance": "which causes of inflation do economists usually mention?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-remembered-world-03",
|
||||
"utterance": "what did the Apollo astronauts report about lunar dust?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "remembered_speech"
|
||||
},
|
||||
|
||||
{
|
||||
"id": "pb-ru-possession-personal-01",
|
||||
"utterance": "какой номер у моего страхового полиса?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-possession-personal-02",
|
||||
"utterance": "где я оставил свои запасные ключи?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-possession-personal-03",
|
||||
"utterance": "до какого числа действует мой абонемент?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-possession-world-01",
|
||||
"utterance": "как убрать царапину с моего стола?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-possession-world-02",
|
||||
"utterance": "почему у меня запотевают окна зимой?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-possession-world-03",
|
||||
"utterance": "чем зарядить мой телефон в поездке?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-possession-personal-01",
|
||||
"utterance": "when does my library card expire?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-possession-personal-02",
|
||||
"utterance": "where did I put my passport copy?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-possession-personal-03",
|
||||
"utterance": "what size are my hiking boots?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-possession-world-01",
|
||||
"utterance": "how can I descale my kettle safely?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-possession-world-02",
|
||||
"utterance": "why does my laptop fan get loud under load?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "possession"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-possession-world-03",
|
||||
"utterance": "which adapter should I use for my phone abroad?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "possession"
|
||||
},
|
||||
|
||||
{
|
||||
"id": "pb-ru-narrative-personal-01",
|
||||
"utterance": "напомни историю о том, как я познакомился с Антоном",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-narrative-personal-02",
|
||||
"utterance": "расскажи, что со мной случилось в первый день на новой работе",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-narrative-personal-03",
|
||||
"utterance": "восстанови по моим заметкам историю поездки в Казань",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-narrative-world-01",
|
||||
"utterance": "опиши восхождение на Эверест",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-narrative-world-02",
|
||||
"utterance": "расскажи историю создания языка Rust",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-narrative-world-03",
|
||||
"utterance": "объясни, как возникли кольца Сатурна",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-narrative-personal-01",
|
||||
"utterance": "retell the story of how I met Lena from what I told you",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-narrative-personal-02",
|
||||
"utterance": "walk me through what happened on my first day at university",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-narrative-personal-03",
|
||||
"utterance": "reconstruct my Prague trip from my notes",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-narrative-world-01",
|
||||
"utterance": "tell me the story of the first Moon landing",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-narrative-world-02",
|
||||
"utterance": "describe how the printing press spread through Europe",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-narrative-world-03",
|
||||
"utterance": "explain how the Panama Canal was built",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "narrative"
|
||||
},
|
||||
|
||||
{
|
||||
"id": "pb-ru-preamble-personal-01",
|
||||
"utterance": "если помнишь наш разговор, что я решил насчёт переезда?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-preamble-personal-02",
|
||||
"utterance": "как я уже упоминал, когда мне продлевать страховку?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-preamble-personal-03",
|
||||
"utterance": "возвращаясь к тому, что я рассказывал, какую модель велосипеда я выбрал?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-preamble-world-01",
|
||||
"utterance": "как я уже говорил, почему самолёты оставляют белый след?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-preamble-world-02",
|
||||
"utterance": "возвращаясь к моему вопросу, из чего состоит базальт?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-preamble-world-03",
|
||||
"utterance": "я, возможно, повторяюсь, но когда построили Колизей?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-preamble-personal-01",
|
||||
"utterance": "as I mentioned earlier, which dentist did I choose?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-preamble-personal-02",
|
||||
"utterance": "coming back to what I told you, when am I taking leave?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-preamble-personal-03",
|
||||
"utterance": "I may have said this already, which Linux distro did I settle on?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-preamble-world-01",
|
||||
"utterance": "as I was saying, why do tides happen?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-preamble-world-02",
|
||||
"utterance": "coming back to my question, how are auroras formed?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-preamble-world-03",
|
||||
"utterance": "I may be repeating myself, when was Machu Picchu built?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "first_person_preamble"
|
||||
},
|
||||
|
||||
{
|
||||
"id": "pb-ru-advice-personal-01",
|
||||
"utterance": "что из моих дел нужно закончить до пятницы?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-advice-personal-02",
|
||||
"utterance": "какое лекарство врач велел мне принимать утром?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-advice-personal-03",
|
||||
"utterance": "сколько денег я потратил на продукты в этом месяце?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-advice-world-01",
|
||||
"utterance": "как безопасно заменить розетку?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-advice-world-02",
|
||||
"utterance": "какая сейчас версия Debian stable?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-advice-world-03",
|
||||
"utterance": "что сегодня происходит на мировых рынках?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-advice-personal-01",
|
||||
"utterance": "which of my tasks is due before Friday?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-advice-personal-02",
|
||||
"utterance": "what dosage did my doctor tell me to take at breakfast?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-advice-personal-03",
|
||||
"utterance": "how much did I spend on groceries this month?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-advice-world-01",
|
||||
"utterance": "how should I clean a cast-iron pan?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-advice-world-02",
|
||||
"utterance": "what is the current stable release of PostgreSQL?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-advice-world-03",
|
||||
"utterance": "which major elections are happening this month?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "advice_current_info"
|
||||
},
|
||||
|
||||
{
|
||||
"id": "pb-ru-proper-personal-01",
|
||||
"utterance": "что я записал после доклада Линуса Торвальдса?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-proper-personal-02",
|
||||
"utterance": "какое мнение я высказал о фильмах Куросавы?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-proper-personal-03",
|
||||
"utterance": "когда у меня билеты на концерт Земфиры?",
|
||||
"lang": "ru",
|
||||
"want": "personal",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-proper-world-01",
|
||||
"utterance": "кто такой Алан Тьюринг?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-proper-world-02",
|
||||
"utterance": "чем известна Фрида Кало?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-ru-proper-world-03",
|
||||
"utterance": "когда родился Юрий Гагарин?",
|
||||
"lang": "ru",
|
||||
"want": "world",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-proper-personal-01",
|
||||
"utterance": "what notes did I make after Grace Hopper's talk?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-proper-personal-02",
|
||||
"utterance": "which David Bowie album did I say I liked most?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-proper-personal-03",
|
||||
"utterance": "when are my tickets for the Radiohead show?",
|
||||
"lang": "en",
|
||||
"want": "personal",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-proper-world-01",
|
||||
"utterance": "who was Katherine Johnson?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-proper-world-02",
|
||||
"utterance": "what is Antoni Gaudí famous for?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "public_proper_nouns"
|
||||
},
|
||||
{
|
||||
"id": "pb-en-proper-world-03",
|
||||
"utterance": "when was Nelson Mandela born?",
|
||||
"lang": "en",
|
||||
"want": "world",
|
||||
"stratum": "public_proper_nouns"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "assistant_workday",
|
||||
"description": "One continuous, deterministic workday through Maven's real conversation pipeline. It proves note capture and grounded high-overlap recall; a reminder that remains uncommitted while Maven clarifies its day, then survives a reported-action no-op, is cancelled exactly once, and stays cancelled on a repeated command; task capture, listing, completion, and a second live task; and the same calendar fact read through both agenda and composed day-plan sources. Store assertions are primary: every mutation and no-op pins exact row count, identity, lifecycle state, provenance, and fake-clock time.",
|
||||
"start": "2026-08-15T08:00:00+04:00",
|
||||
"script": [
|
||||
{
|
||||
"match": "запомни: запасной ключ лежит",
|
||||
"route": "[{\"intent\":\"note\",\"text\":\"запомни: запасной ключ лежит в синей коробке\"}]"
|
||||
},
|
||||
{
|
||||
"match": "запасной ключ лежит в синей коробке",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"запасной ключ лежит в синей коробке?\",\"source\":\"recall\"}]"
|
||||
},
|
||||
{
|
||||
"match": "я отменил напоминание",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"я отменил напоминание про молоко\"}]",
|
||||
"reply": "{\"response\":\"Поняла.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"\"}]",
|
||||
"reply": "{\"response\":\"Поняла.\",\"mood\":\"neutral\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "08:00",
|
||||
"note": "Capture only the dictated body as one durable note: the command frame is not memory, and no task, reminder, or fact is created.",
|
||||
"say": "запомни: запасной ключ лежит в синей коробке",
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"notes": { "count": 1, "rows": [{ "id": 1, "at": "08:00", "text": "запасной ключ лежит в синей коробке", "source": "tap:voice" }] },
|
||||
"tasks": { "count": 0 },
|
||||
"reminders": { "count": 0 },
|
||||
"facts": { "count": 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:01",
|
||||
"note": "Recall reads the stored note and does not create a second row.",
|
||||
"say": "запасной ключ лежит в синей коробке?",
|
||||
"expect_reply_contains": ["синей коробке"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"notes": { "count": 1, "rows": [{ "id": 1, "text": "запасной ключ лежит в синей коробке", "source": "tap:voice" }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:02",
|
||||
"note": "A clock without a day is not a committed reminder. Maven asks, and the reminder table remains empty.",
|
||||
"say": "напомни купить молоко в 10:00",
|
||||
"expect_reply_contains": ["В какой день"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"reminders": { "count": 0 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:03",
|
||||
"note": "The clarification completes the parked request against the fake clock and creates exactly one pending reminder.",
|
||||
"say": "сегодня",
|
||||
"expect_reply_contains": ["10:00"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"reminders": { "count": 1, "rows": [{ "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "pending" }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:04",
|
||||
"note": "A first-person report is not another cancellation command and cannot mutate the pending row.",
|
||||
"say": "я отменил напоминание про молоко",
|
||||
"expect_reply_contains": ["Поняла"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"reminders": { "count": 1, "rows": [{ "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "pending" }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:05",
|
||||
"note": "The addressed imperative cancels that exact durable reminder in place.",
|
||||
"say": "отмени напоминание про молоко",
|
||||
"expect_reply_contains": ["отменила напоминание", "купить молоко"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"reminders": { "count": 1, "rows": [{ "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "cancelled" }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:06",
|
||||
"note": "Repeating the cancellation is an explicit no-op: no replacement row and no resurrection.",
|
||||
"say": "отмени напоминание про молоко",
|
||||
"expect_reply_contains": ["ожидающих напоминаний нет"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"reminders": { "count": 1, "rows": [{ "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "cancelled" }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:07",
|
||||
"note": "An explicit task marker creates one open task, not a note.",
|
||||
"say": "добавь в задачи настроить бэкапы",
|
||||
"expect_reply_contains": ["настроить бэкапы"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"notes": { "count": 1 },
|
||||
"tasks": { "count": 1, "rows": [{ "id": 1, "created_at": "08:07", "text": "настроить бэкапы", "source": "tap:voice", "status": "open" }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:08",
|
||||
"note": "Listing is read-only and returns the live task without duplicating it.",
|
||||
"say": "какие у меня задачи?",
|
||||
"expect_reply_contains": ["настроить бэкапы"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"tasks": { "count": 1, "rows": [{ "id": 1, "text": "настроить бэкапы", "status": "open" }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:09",
|
||||
"note": "Naming the task moves the same row forward to done and records who resolved it.",
|
||||
"say": "закрой задачу настроить бэкапы",
|
||||
"expect_reply_contains": ["настроить бэкапы"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"tasks": { "count": 1, "rows": [{ "id": 1, "created_at": "08:07", "text": "настроить бэкапы", "source": "tap:voice", "status": "done", "resolved_at": "08:09", "resolved_by": "tap:voice" }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:10",
|
||||
"note": "A second task remains live for the rest of the workday while the completed row remains durable history.",
|
||||
"say": "добавь в задачи отправить отчёт",
|
||||
"expect_reply_contains": ["отправить отчёт"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"tasks": { "count": 2, "rows": [
|
||||
{ "id": 1, "text": "настроить бэкапы", "status": "done", "resolved_by": "tap:voice" },
|
||||
{ "id": 2, "created_at": "08:10", "text": "отправить отчёт", "source": "tap:voice", "status": "open" }
|
||||
] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:11",
|
||||
"note": "A fully specified reminder commits directly and coexists with the cancelled history row.",
|
||||
"say": "напомни сегодня в 12:00 размяться",
|
||||
"expect_reply_contains": ["12:00"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"reminders": { "count": 2, "rows": [
|
||||
{ "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "cancelled" },
|
||||
{ "id": 2, "fire_at": "12:00", "text": "размяться", "status": "pending" }
|
||||
] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:12",
|
||||
"note": "A calendar poll contributes one exact env fact at the event instant.",
|
||||
"arrive": {
|
||||
"source": "poll:caldav",
|
||||
"as_of": "11:00",
|
||||
"fact": {
|
||||
"key": "calendar_event_20260815_Планёрка",
|
||||
"value": "Планёрка @ 11:00-11:30",
|
||||
"kind": "env"
|
||||
}
|
||||
},
|
||||
"expect_events": ["calendar_event_20260815_Планёрка"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"facts": { "count": 1, "rows": [{ "id": 1, "at": "11:00", "kind": "env", "key": "calendar_event_20260815_Планёрка", "value": "Планёрка @ 11:00-11:30", "source": "poll:caldav", "confidence": 1.0 }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:13",
|
||||
"note": "The agenda source reads the calendar fact without changing any durable state.",
|
||||
"say": "что у меня сегодня?",
|
||||
"expect_reply_contains": ["Планёрка"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"facts": { "count": 1, "rows": [{ "id": 1, "key": "calendar_event_20260815_Планёрка", "source": "poll:caldav" }] },
|
||||
"tasks": { "count": 2 },
|
||||
"reminders": { "count": 2 }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:14",
|
||||
"note": "The daemon day-plan seam composes the same calendar fact with the still-pending reminder; cancelled reminders stay out.",
|
||||
"say": "какие планы на сегодня?",
|
||||
"expect_reply_contains": ["Планёрка", "размяться"],
|
||||
"expect_reply_lacks": ["купить молоко"],
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"notes": { "count": 1, "rows": [{ "id": 1, "text": "запасной ключ лежит в синей коробке" }] },
|
||||
"tasks": { "count": 2, "rows": [
|
||||
{ "id": 1, "text": "настроить бэкапы", "status": "done" },
|
||||
{ "id": 2, "text": "отправить отчёт", "status": "open" }
|
||||
] },
|
||||
"reminders": { "count": 2, "rows": [
|
||||
{ "id": 1, "text": "купить молоко", "status": "cancelled" },
|
||||
{ "id": 2, "text": "размяться", "status": "pending" }
|
||||
] },
|
||||
"facts": { "count": 1, "rows": [{ "id": 1, "key": "calendar_event_20260815_Планёрка", "value": "Планёрка @ 11:00-11:30" }] }
|
||||
}
|
||||
},
|
||||
{
|
||||
"at": "08:15",
|
||||
"note": "A normal tick after the session remains silent; durable assistant state does not authorize an unsolicited message.",
|
||||
"tick": true,
|
||||
"expect_no_send": true,
|
||||
"expect_store": {
|
||||
"notes": { "count": 1 },
|
||||
"tasks": { "count": 2 },
|
||||
"reminders": { "count": 2 },
|
||||
"facts": { "count": 1 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "conversation_anaphora",
|
||||
"description": "Five consecutive Russian turns about one object, replayed from the run that found V-542 on the box on 05-08-2026. He names a monitor once, then refers to it by pronoun or ellipsis for four turns.\n\nThe router decisions stay exactly as the box produced them: fact, query, query, chat, query. That is intentional. Routing sees one utterance; dialogue continuity owns what earlier turns make it mean. A grounded fact inside an explicit conversational opener is still stored, while its exact utterance also enters the transcript. Anaphoric queries are answered through chat with that transcript instead of walking note and world sources without their referent.\n\nEvery scripted chat answer below is conditional on history containing the original monitor turn. A canned reply therefore cannot make this scenario pass if session capture, cross-intent merge, history ordering or PhraseChat wiring loses the referent. The visible assertions require the answer to name the monitor, and the final tick remains the not-a-nag control.",
|
||||
"start": "2026-08-05T14:00:00+03:00",
|
||||
"script": [
|
||||
{
|
||||
"match": "купил новый монитор",
|
||||
"route": "[{\"intent\":\"fact\",\"key\":\"purchase\",\"value\":\"новый монитор\"}]",
|
||||
"reply": "{\"response\":\"записала: новый монитор.\",\"mood\":\"neutral\"}"
|
||||
},
|
||||
{
|
||||
"match": "он большой",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"а он большой?\"}]",
|
||||
"reply": "{\"response\":\"Ты про новый монитор; диагональ ты пока не называл.\",\"mood\":\"neutral\"}",
|
||||
"history_contains": ["купил новый монитор"]
|
||||
},
|
||||
{
|
||||
"match": "сколько он примерно стоит",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"сколько он примерно стоит по-твоему?\"}]",
|
||||
"reply": "{\"response\":\"Новый монитор без модели и диагонали я честно не оценю.\",\"mood\":\"thinking\"}",
|
||||
"history_contains": ["купил новый монитор"]
|
||||
},
|
||||
{
|
||||
"match": "переплатил",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"мне кажется я переплатил\"}]",
|
||||
"reply": "{\"response\":\"Про новый монитор поняла; цену лучше сравнить по точной модели.\",\"mood\":\"thinking\"}",
|
||||
"history_contains": ["купил новый монитор"]
|
||||
},
|
||||
{
|
||||
"match": "стоит его вернуть",
|
||||
"route": "[{\"intent\":\"query\",\"text\":\"стоит его вернуть?\"}]",
|
||||
"reply": "{\"response\":\"Новый монитор стоит вернуть, если сравнение подтвердит переплату или он тебе не подходит.\",\"mood\":\"neutral\"}",
|
||||
"history_contains": ["купил новый монитор"]
|
||||
},
|
||||
{
|
||||
"match": "",
|
||||
"route": "[{\"intent\":\"chat\",\"text\":\"\"}]",
|
||||
"reply": "{\"response\":\"я рада тебя слышать.\",\"mood\":\"happy\"}"
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"at": "14:00",
|
||||
"note": "A substantive statement inside an explicit conversational opener remains a grounded fact, and the exact same utterance becomes dialogue context. Conversation is session state, not a competing storage intent.",
|
||||
"say": "давай поболтаем: я вчера купил новый монитор",
|
||||
"expect_events": ["purchase"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:01",
|
||||
"note": "The query route cannot see earlier turns. The dialogue merge sees the anaphora and answers through chat with the transcript, which names the monitor.",
|
||||
"say": "а он большой?",
|
||||
"expect_reply_contains": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:02",
|
||||
"note": "The referent survives a second routed-query boundary; the previous contextual turn did not replace the transcript anchor.",
|
||||
"say": "сколько он примерно стоит по-твоему?",
|
||||
"expect_reply_contains": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:03",
|
||||
"note": "A native chat route reads the same cross-intent transcript, in chronological order, without receiving the current utterance twice.",
|
||||
"say": "мне кажется я переплатил",
|
||||
"expect_reply_contains": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:04",
|
||||
"note": "The fifth turn proves the oldest retained turn still supplies the referent after fact, query and chat crossings.",
|
||||
"say": "стоит его вернуть?",
|
||||
"expect_reply_contains": ["монитор"],
|
||||
"expect_no_send": true
|
||||
},
|
||||
{
|
||||
"at": "14:05",
|
||||
"note": "Control: session continuity is reactive state only. A tick during the conversation sends nothing unprompted.",
|
||||
"tick": true,
|
||||
"expect_no_send": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -24,8 +24,8 @@
|
||||
"at": "21:00",
|
||||
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||
"audio": "ru_fact",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
|
||||
"expect_reply_contains": ["записала", "выпил воды"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш", "стакан"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
+5
-5
@@ -74,17 +74,17 @@
|
||||
},
|
||||
{
|
||||
"at": "08:50",
|
||||
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
|
||||
"note": "he asks what he missed, and Praxis holds one unresolved item — the morning medicine — so she reads that back. This step pinned \"не знаю\" until 05-08-2026, and that was the keyword floor's blind spot rather than a rule: isAttentionQuery does not match \"что я пропустил\", while the topicAttend seeds carry \"что важное я пропустил\" almost verbatim. The seeds only started deciding when turnVector fixed the empty query vector every topic source was reading (V-547). Reading a surfaced item aloud is not inventing a morning summary, so the no-hallucination floor still holds; what moved is which source answers. Note what the persona check here is and is not: the reply is a constant in the Go source, so expect_reply_lacks pins that constant, not anything the model wrote. The step below is the one that reads model output.",
|
||||
"say": "что я пропустил?",
|
||||
"expect_reply_contains": ["не знаю"],
|
||||
"expect_reply_contains": ["требует внимания", "morning_medicine"],
|
||||
"expect_reply_lacks": ["рад ", "милый", "ваш"]
|
||||
},
|
||||
{
|
||||
"at": "08:55",
|
||||
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
|
||||
"note": "stating a fact writes it and says so, in the feminine, and in his own words. The reply no longer comes from the model at all (V-592): a 1.7B asked to restate «я выпил воды» answered «Проверила, что ты выпел стакан воды», so the confirmation is now a deck frame with his sentence in it. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\".",
|
||||
"say": "я выпил воды",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
|
||||
"expect_reply_contains": ["записала", "я выпил воды"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "стакан"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
+240
-16
@@ -10,10 +10,13 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -160,6 +163,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
log.Printf("tick: gather: %v", err)
|
||||
return
|
||||
}
|
||||
t.savePresence(ctx, state, now)
|
||||
|
||||
// proactive: at most one candidate, max severity.
|
||||
cand, trace := loop.ExplainTick(state, t.rules)
|
||||
@@ -224,18 +228,11 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
// 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.
|
||||
// reminders: gate-bypassing class. The presentation and retry clock live on
|
||||
// the reminder occurrence, so a transport outage neither spends the model
|
||||
// every tick nor changes what the reminder says after a restart.
|
||||
for _, d := range loop.RemindDecisions(state, due) {
|
||||
pr, err := t.phraser.PhraseReminder(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase reminder %d: %v", d.Reminder.ID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := t.dispatcher.DispatchReminder(ctx, pr, now); err != nil {
|
||||
log.Printf("tick: dispatch reminder %d: %v", d.Reminder.ID, err)
|
||||
}
|
||||
t.deliverReminder(ctx, d, now)
|
||||
}
|
||||
|
||||
// sev4-away repeats: re-send un-acked telegram nudges per repeatInterval.
|
||||
@@ -249,6 +246,7 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
return
|
||||
}
|
||||
keys = t.repeatableRules(keys)
|
||||
keys = t.stopFinishedAlarms(ctx, keys, state, now)
|
||||
if len(keys) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -260,6 +258,224 @@ func (t *tickLoop) tick(ctx context.Context, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// deliverReminder advances one due reminder (or collapsed bundle) through the
|
||||
// durable delivery state. A phrase is cached before the first external send;
|
||||
// every definite failure advances the persisted bounded backoff.
|
||||
func (t *tickLoop) deliverReminder(ctx context.Context, d loop.ReminderDecision, now time.Time) {
|
||||
originals := reminderOriginals(d.Reminder)
|
||||
pr, cached := cachedReminderPhrase(d, originals)
|
||||
if !cached {
|
||||
var err error
|
||||
pr, err = t.phraser.PhraseReminder(ctx, d)
|
||||
if err == nil && pr.Body == "" {
|
||||
err = errors.New("phraser returned an empty reminder body")
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("tick: phrase reminder %d: %v", d.Reminder.ID, err)
|
||||
t.scheduleReminderRetry(ctx, originals, now)
|
||||
return
|
||||
}
|
||||
if pr.Mood == "" {
|
||||
pr.Mood = "neutral"
|
||||
}
|
||||
group := reminderDeliveryGroup(originals)
|
||||
if err := t.store.CacheReminderPhrase(
|
||||
ctx, originals, group, pr.Body, pr.Summary, pr.Mood,
|
||||
); err != nil {
|
||||
// A cancellation or another completion can win while phrasing. Do
|
||||
// not send a presentation that no longer owns every original.
|
||||
log.Printf("tick: cache reminder %d phrase: %v", d.Reminder.ID, err)
|
||||
return
|
||||
}
|
||||
// The store now owns the phrase, but this tick's value predates that
|
||||
// write. Stamp the exact persisted occurrence identity onto the value
|
||||
// handed to the dispatcher so its outbox row can suppress an ambiguous
|
||||
// crash for both a real reminder and a synthetic collapsed bundle.
|
||||
for i := range originals {
|
||||
originals[i].DeliveryGroup = group
|
||||
originals[i].PhraseBody = pr.Body
|
||||
originals[i].PhraseSummary = pr.Summary
|
||||
originals[i].PhraseMood = pr.Mood
|
||||
}
|
||||
if d.Reminder.ID == 0 {
|
||||
d.Reminder.Collapsed = originals
|
||||
} else {
|
||||
d.Reminder = originals[0]
|
||||
}
|
||||
}
|
||||
// A phraser is not allowed to substitute the reminder decision. In
|
||||
// particular, the durable group stamped above must reach the outbox.
|
||||
pr.Decision = d
|
||||
|
||||
if _, err := t.dispatcher.DispatchReminder(ctx, pr, now); err != nil {
|
||||
log.Printf("tick: dispatch reminder %d: %v", d.Reminder.ID, err)
|
||||
t.scheduleReminderRetry(ctx, originals, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *tickLoop) scheduleReminderRetry(ctx context.Context, originals []store.Reminder, now time.Time) {
|
||||
if err := t.store.ScheduleReminderRetry(ctx, originals, now); err != nil {
|
||||
log.Printf("tick: schedule reminder retry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// reminderOriginals converts the synthetic ID=0 bundle back to real store
|
||||
// rows. Keeping this in one helper makes it impossible to accidentally persist
|
||||
// retry state against reminder zero.
|
||||
func reminderOriginals(r store.Reminder) []store.Reminder {
|
||||
if r.ID == 0 {
|
||||
return append([]store.Reminder(nil), r.Collapsed...)
|
||||
}
|
||||
return []store.Reminder{r}
|
||||
}
|
||||
|
||||
// cachedReminderPhrase reconstructs a PhrasedReminder only when every original
|
||||
// agrees on one persisted group and presentation. That agreement is what lets
|
||||
// a collapsed bundle survive a restart without being re-phrased.
|
||||
func cachedReminderPhrase(d loop.ReminderDecision, originals []store.Reminder) (delivery.PhrasedReminder, bool) {
|
||||
if len(originals) == 0 || !originals[0].HasDeliveryPhrase() {
|
||||
return delivery.PhrasedReminder{}, false
|
||||
}
|
||||
first := originals[0]
|
||||
for _, r := range originals[1:] {
|
||||
if !r.HasDeliveryPhrase() ||
|
||||
r.DeliveryGroup != first.DeliveryGroup ||
|
||||
r.PhraseBody != first.PhraseBody ||
|
||||
r.PhraseSummary != first.PhraseSummary ||
|
||||
r.PhraseMood != first.PhraseMood {
|
||||
return delivery.PhrasedReminder{}, false
|
||||
}
|
||||
}
|
||||
mood := first.PhraseMood
|
||||
if mood == "" {
|
||||
mood = "neutral"
|
||||
}
|
||||
return delivery.PhrasedReminder{
|
||||
Decision: d,
|
||||
Body: first.PhraseBody,
|
||||
Summary: first.PhraseSummary,
|
||||
Mood: mood,
|
||||
}, true
|
||||
}
|
||||
|
||||
// reminderDeliveryGroup deterministically names one occurrence or collapsed
|
||||
// set. The next-fire instant is part of the identity so a recurring reminder's
|
||||
// later occurrence can never inherit the previous occurrence's phrase.
|
||||
func reminderDeliveryGroup(originals []store.Reminder) string {
|
||||
ordered := append([]store.Reminder(nil), originals...)
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
if ordered[i].ID == ordered[j].ID {
|
||||
return ordered[i].NextFireTs.Before(ordered[j].NextFireTs)
|
||||
}
|
||||
return ordered[i].ID < ordered[j].ID
|
||||
})
|
||||
h := sha256.New()
|
||||
for _, r := range ordered {
|
||||
_, _ = fmt.Fprintf(h, "%d:%d;", r.ID, r.NextFireTs.UnixMilli())
|
||||
}
|
||||
sum := h.Sum(nil)
|
||||
return fmt.Sprintf("reminder:%x", sum[:12])
|
||||
}
|
||||
|
||||
// savePresence writes back the bucket GatherState just resolved.
|
||||
//
|
||||
// It lives here and not in GatherState because that method holds a read-only
|
||||
// transaction on purpose — one consistent snapshot per tick — and a write
|
||||
// inside it would either break that guarantee or quietly upgrade the
|
||||
// transaction. The tick is the layer that already owns writes.
|
||||
//
|
||||
// Nothing wrote this row before (Vikunja #532), and the row is the whole
|
||||
// mechanism, so two things were broken at once. Hysteresis was dead: lastBucket
|
||||
// read the cold-start Away on every tick, so store.Resolve only ever took the
|
||||
// `last == Away` arm and demanded a full PresenceEnter score to say he is
|
||||
// there. The 0.30-0.55 hold band the function exists to provide never applied
|
||||
// once. And every readout lied: /dash and ipc.Presence read this row, so they
|
||||
// showed "away — score 0.00 (never)" while desk_active facts were arriving
|
||||
// every sixty seconds.
|
||||
//
|
||||
// A failure logs and the tick continues. The gate reads the in-memory bucket,
|
||||
// which is why nudge routing kept working through all of this — losing the
|
||||
// write costs the next tick's hysteresis, not this tick's decisions.
|
||||
func (t *tickLoop) savePresence(ctx context.Context, state loop.State, now time.Time) {
|
||||
if err := t.store.SavePresenceState(ctx, state.Presence, state.PresenceScore, now); err != nil {
|
||||
log.Printf("tick: save presence state: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// maxAlarmAge — how long one un-acked telegram alarm may keep repeating.
|
||||
//
|
||||
// This is the floor brake and it applies to every rule, including one that
|
||||
// says nothing about its own condition (Vikunja #535). Nothing in the tree can
|
||||
// ack a telegram nudge: MarkAcked has no caller outside internal/store, and the
|
||||
// only ack that exists is a voice "готово" on a box that runs no voice loop. So
|
||||
// "repeat until acked" meant "repeat forever", and it did — every five minutes
|
||||
// for over two hours.
|
||||
//
|
||||
// Two hours at the five-minute default is about 24 messages, which is already
|
||||
// past the point of being read. An alarm nobody answered in two hours is not
|
||||
// one more repeat away from being answered, and the right move is to stop
|
||||
// talking, not to talk louder.
|
||||
const maxAlarmAge = 2 * time.Hour
|
||||
|
||||
// stopFinishedAlarms returns the keys that may still repeat, and closes the
|
||||
// rest.
|
||||
//
|
||||
// Two ways an alarm ends without him. The condition cleared, which the rule
|
||||
// answers through StillTrue — deliberately NOT Predicate, which is
|
||||
// edge-triggered and reads false one tick after the alarm is raised, so using
|
||||
// it would cancel every alarm immediately. Or the alarm simply got old, which
|
||||
// is the bound that does not need the rule's cooperation.
|
||||
//
|
||||
// A rule with no StillTrue is not treated as resolved. Silence about the
|
||||
// condition is not evidence the condition cleared, so those keys only ever stop
|
||||
// on age.
|
||||
func (t *tickLoop) stopFinishedAlarms(ctx context.Context, keys []string, state loop.State, now time.Time) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := t.rulesByName()
|
||||
live := keys[:0:0]
|
||||
for _, key := range keys {
|
||||
outcome := ""
|
||||
switch r := byName[key]; {
|
||||
case r.StillTrue != nil && !r.StillTrue(state):
|
||||
outcome = store.NudgeResolved
|
||||
case t.alarmIsOlderThan(ctx, key, maxAlarmAge, now):
|
||||
// Not "resolved": nothing says the thing got better. This is her
|
||||
// giving up on being answered, and /notifications should say so.
|
||||
outcome = store.NudgeIgnored
|
||||
}
|
||||
if outcome == "" {
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
n, err := t.store.ResolvePendingTelegram(ctx, key, outcome, now)
|
||||
if err != nil {
|
||||
// Could not close it, so do not drop it either: repeating is the
|
||||
// lesser fault against losing the alarm entirely.
|
||||
log.Printf("tick: stop alarm %s: %v", key, err)
|
||||
live = append(live, key)
|
||||
continue
|
||||
}
|
||||
log.Printf("tick: alarm %s ended (%s), %d pending nudge(s) closed", key, outcome, n)
|
||||
}
|
||||
return live
|
||||
}
|
||||
|
||||
// alarmIsOlderThan reports whether the oldest un-acked send for this rule is
|
||||
// past the cap. A read failure answers false: an alarm that repeats one more
|
||||
// time is better than one silenced by a transient store error.
|
||||
func (t *tickLoop) alarmIsOlderThan(ctx context.Context, rule string, age time.Duration, now time.Time) bool {
|
||||
oldest, err := t.store.OldestPendingTelegram(ctx, rule)
|
||||
if err != nil {
|
||||
if !errors.Is(err, store.ErrNudgeNotFound) {
|
||||
log.Printf("tick: oldest pending %s: %v", rule, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return now.Sub(oldest) >= age
|
||||
}
|
||||
|
||||
// repeatableRules drops keys whose rule is not wired any more.
|
||||
//
|
||||
// The repeat path reads the nudges table, not the rule set: any sev4 telegram
|
||||
@@ -276,19 +492,27 @@ func (t *tickLoop) repeatableRules(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
wired := make(map[string]bool, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
wired[r.Name] = true
|
||||
}
|
||||
wired := t.rulesByName()
|
||||
out := keys[:0:0]
|
||||
for _, k := range keys {
|
||||
if wired[k] {
|
||||
if _, ok := wired[k]; ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rulesByName indexes the wired rule set by name, for the two lookups above
|
||||
// that only care whether a key is still wired (repeatableRules) or need the
|
||||
// rule itself (stopFinishedAlarms).
|
||||
func (t *tickLoop) rulesByName() map[string]loop.Rule {
|
||||
byName := make(map[string]loop.Rule, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
return byName
|
||||
}
|
||||
|
||||
// cachePhrase keeps the latest phrased nudge per rule for the sev4-repeat
|
||||
// path. writing under a mutex; the repeat path reads under the same. the
|
||||
// cache is bounded by the rule count (≤ ~30 per spec) so eviction is not a
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user