Compare commits
552 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05f791735d | |||
| bdd79ad585 | |||
| 66f06796cb | |||
| 06adc4702d | |||
| 1d02ba8936 | |||
| 66c578a6f4 | |||
| 356766bce1 | |||
| 6a402bf556 | |||
| f6d7b05161 | |||
| 025f81e961 | |||
| 064747f192 | |||
| a55d90954a | |||
| 87a3b163e7 | |||
| 2f338a1ab6 | |||
| be062b2d48 | |||
| 7f804b84e7 | |||
| bae81b66c8 | |||
| 8153e5eaa5 | |||
| 41c97bba8d | |||
| f9b0a96d9d | |||
| 40ec0c0d4b | |||
| af6e6c9979 | |||
| 3cced9a2e9 | |||
| bc1ef0f57f | |||
| f002ce0e9c | |||
| 3adfc3e0f9 | |||
| b6666196c1 | |||
| 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 |
@@ -11,14 +11,18 @@ started with an agent that inferred the goal instead of stating it back.
|
||||
## 0. Get on the branch
|
||||
|
||||
```sh
|
||||
task start <vikunja-id>
|
||||
task start <vikunja-id> # with an id
|
||||
git checkout -b task/<slug> # without one
|
||||
```
|
||||
|
||||
`~/.local/bin/task` owns the branch, the identity and the PR. It cuts
|
||||
`task/<id>-<slug>` off `origin/master` and sets the commit author to the `claude`
|
||||
gitea user. It writes `TASK.md` from the Vikunja task, and pulls any waiting
|
||||
An id is optional (owner's call, 2026-08-25). With one, `~/.local/bin/task` owns
|
||||
the branch, the identity and the PR. It cuts `task/<id>-<slug>` off
|
||||
`origin/master` and sets the commit author to the `claude` gitea user. It writes `TASK.md` from the Vikunja task, and pulls any waiting
|
||||
review comments into `.task/review-comments.md`. Do not hand-roll any of that.
|
||||
|
||||
Without an id, branch by hand and skip `TASK.md`. The user's own brief is then
|
||||
the goal, and step 4 restates it back to him instead.
|
||||
|
||||
`TASK.md` is the brief and it is immutable. If it says a PR already exists, this
|
||||
is a review-fix session and not new work. Read the comments first.
|
||||
|
||||
@@ -34,9 +38,12 @@ If there is no handoff, that is normal. It means the last session closed clean.
|
||||
|
||||
In this order, and stop as soon as you have enough:
|
||||
|
||||
- The Vikunja task, by id. Project Maven is ID 2, MCP at `http://localhost:9100/mcp`.
|
||||
The task description and its comments hold the goal, the constraints, and the
|
||||
assumption ledger. This outranks the handoff on every conflict.
|
||||
- The Vikunja task, if there is one. Project Maven is ID 2, MCP at
|
||||
`http://localhost:9100/mcp`, reachable from workpc only through
|
||||
`ssh -N -f -L 9100:127.0.0.1:9100 kami@192.168.1.104`. A refused connection is
|
||||
the missing tunnel, not an outage. The task description and its comments hold
|
||||
the goal, the constraints, and the assumption ledger. This outranks the handoff
|
||||
on every conflict.
|
||||
- `CLAUDE.md`, the section that covers the area you are about to touch.
|
||||
- The one file under `docs/` that owns the area. Check its `Last verified` line.
|
||||
If the sha is behind the code you are reading, say so in step 4 and trust the code.
|
||||
@@ -44,8 +51,8 @@ In this order, and stop as soon as you have enough:
|
||||
Do not read the dated files under `docs/evals/`. They are measurements from one day,
|
||||
never updated. Read one only when you need the number it recorded.
|
||||
|
||||
If no task id is known, ask for one before doing anything else. Work without a task
|
||||
is work nobody can resume.
|
||||
With no task id, do not ask for one and do not stall. State it in step 4 as
|
||||
`Task: unfiled` and carry on.
|
||||
|
||||
## 3. Look at the ground
|
||||
|
||||
@@ -58,7 +65,7 @@ Write at most five bullets and stop. Do not write code, do not open files to "ch
|
||||
one thing first", do not start with a small safe change.
|
||||
|
||||
```
|
||||
Task: V-359, one line.
|
||||
Task: V-359, one line. `unfiled` when there is no id.
|
||||
Done: what is already on the branch.
|
||||
Next: the one thing this session does.
|
||||
Constraints: what would make this wrong.
|
||||
@@ -67,8 +74,9 @@ Assuming: the beliefs that, if false, waste the session.
|
||||
|
||||
Then ask: is this right? Wait for the answer.
|
||||
|
||||
A corrected assumption goes into the Vikunja task as a comment, not into the handoff.
|
||||
The handoff dies tonight. The task does not.
|
||||
A corrected assumption goes into the Vikunja task as a comment where there is a
|
||||
task, because the handoff dies tonight and the task does not. Unfiled, it goes
|
||||
into the handoff and nowhere else.
|
||||
|
||||
## 5. Then begin
|
||||
|
||||
|
||||
@@ -36,11 +36,13 @@ a commit message, not into a comment in the code.
|
||||
Under 300 changed lines per commit in non-markdown files, enforced by `.githooks/pre-commit`.
|
||||
Markdown is exempt and may land as one batch.
|
||||
|
||||
Each commit is one idea, subject in the repo's voice, lowercase area prefix, and it
|
||||
ends with the Vikunja ref:
|
||||
Each commit is one idea, subject in the repo's voice, lowercase area prefix. A
|
||||
Vikunja ref is welcome where a task exists and is required nowhere: the
|
||||
`commit-msg` hook that demanded it was deleted on 2026-08-25.
|
||||
|
||||
```
|
||||
router: narrow the single-token rule (V-359)
|
||||
router: narrow the single-token rule
|
||||
```
|
||||
|
||||
If a change genuinely cannot split under 300 lines, say why in the commit body before
|
||||
@@ -56,13 +58,17 @@ It refuses a dirty tree, pushes, opens or refreshes the PR against the repo defa
|
||||
branch, labels the Vikunja task in-review, comments the PR url on it, and pushes an
|
||||
ntfy. Do not push by hand and do not call `tea` yourself.
|
||||
|
||||
`task pr` needs an id. On a hand-cut branch with no task, push the branch and open
|
||||
the PR by hand, and skip step 5.
|
||||
|
||||
## 5. Record what `task pr` cannot know
|
||||
|
||||
Comment on the Vikunja task: what you measured, what is still open. List every
|
||||
assumption that turned out to be wrong. If the session found new work, create a task
|
||||
for it now rather than describing it in prose.
|
||||
|
||||
This step is what makes the handoff disposable.
|
||||
This step is what makes the handoff disposable. With no task, it cannot run, so the
|
||||
handoff carries that content instead and stops being disposable. Say so in it.
|
||||
|
||||
## 6. Leave the handoff, or leave none
|
||||
|
||||
@@ -75,7 +81,7 @@ resume, and no history:
|
||||
```markdown
|
||||
# Handoff — <date>
|
||||
|
||||
Task: V-359 <one line>
|
||||
Task: V-359 <one line>, or `unfiled`
|
||||
Branch: task/359-<slug>, cut from master
|
||||
|
||||
## Where I stopped
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Every commit names the Vikunja task it belongs to.
|
||||
#
|
||||
# router: narrow the single-token rule (V-359)
|
||||
#
|
||||
# V- and not #, because Gitea autolinks #359 to a Gitea issue, which is a
|
||||
# different tracker and a wrong link.
|
||||
#
|
||||
# Exempt: merges, reverts, fixup/squash, and the initial commit.
|
||||
|
||||
msg_file=$1
|
||||
subject=$(sed -n '1p' "$msg_file")
|
||||
|
||||
case "$subject" in
|
||||
Merge\ *|Revert\ *|fixup!\ *|squash!\ *|amend!\ *) exit 0 ;;
|
||||
esac
|
||||
|
||||
if [ -f "$(git rev-parse --git-dir)/MERGE_HEAD" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if printf '%s' "$subject" | grep -qE '\(V-[0-9]+\)$'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "commit-msg: subject must end with a Vikunja task ref." >&2
|
||||
echo " got: $subject" >&2
|
||||
echo " want: router: narrow the single-token rule (V-359)" >&2
|
||||
echo " No task yet? Create one. Work without a task is work nobody can resume." >&2
|
||||
exit 1
|
||||
+21
@@ -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,20 @@ coverage.out
|
||||
|
||||
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
|
||||
.env
|
||||
# silero-vad, downloaded (see AGENTS.md)
|
||||
/models/vad/
|
||||
|
||||
# Go build cache and GOPATH from the containerised e2eprobe build. Created by
|
||||
# the command in docs/capabilities/README.md, which runs as root in a container
|
||||
# and so cannot share the host cache. Multi-GB, entirely reproducible.
|
||||
/.cache/
|
||||
|
||||
# docs/architecture/ derived output. The sources, findings.md and the inventory
|
||||
# JSON are tracked; these rebuild from them with pack_evidence.sh and are large.
|
||||
/docs/architecture/index.html
|
||||
/docs/architecture/anchors.md
|
||||
/docs/architecture/architecture-evidence.txt
|
||||
/docs/architecture/tree.txt
|
||||
/docs/architecture/docker-compose.redacted.yml
|
||||
/docs/architecture/diagrams/*.svg
|
||||
/maven-evidence.zip
|
||||
|
||||
@@ -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` on homesrv. **`vikunja-mcp` publishes to
|
||||
`127.0.0.1:9100` only, so the LAN address never answers from workpc.** A
|
||||
refused connection is that, not an outage: three sessions read it as "Vikunja
|
||||
is down" and filed nothing. Tunnel first, then use `localhost`:
|
||||
`ssh -N -f -L 9100:127.0.0.1:9100 kami@192.168.1.104`.
|
||||
- **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.
|
||||
- **`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.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Handoff
|
||||
|
||||
Master is at `5cae33a`, pushed, tree clean apart from this file. Working on master
|
||||
raw by the owner's call: no branch, `--no-verify` on every commit with the reason
|
||||
in the body.
|
||||
|
||||
## Landed this session
|
||||
|
||||
Twelve commits pushed. Nine were the previous session's tree, already described in
|
||||
the commit log. Three are new:
|
||||
|
||||
- `78a9c61` `docs/spec.md`, 51 capabilities with a DoD each.
|
||||
- `02e3d27` `docs/roadmap.md`, seven milestones, plus both pointer rows in `CLAUDE.md`.
|
||||
- `5cae33a` honesty split into three milestones, five capabilities deferred past v1.
|
||||
|
||||
**Read `docs/spec.md` and `docs/roadmap.md` before anything else.** Every decision
|
||||
from this session is in them. This file holds only what they do not.
|
||||
|
||||
## The two documents
|
||||
|
||||
`docs/spec.md` is the union of the 39 audited rows
|
||||
(`docs/evals/2026-08-13-capability-audit.md`) and the owner's 18-item v1 list.
|
||||
Twelve of his items had no audit row, so the file has 51 entries. Each entry
|
||||
carries three parts. State is a reference to the living doc that owns it. DoD is
|
||||
a plain list observable on the running box. Scenario names a file in
|
||||
`cmd/mavend/testdata/scenarios/`.
|
||||
|
||||
`docs/roadmap.md` orders them into nine milestones. Honesty, then reach, then
|
||||
breadth. Not ordered by code work, because none of the four broken capabilities is
|
||||
a code defect.
|
||||
|
||||
## Decided, do not re-ask
|
||||
|
||||
- **v1 is a voice assistant, minimum viable.** Each DoD is written at
|
||||
"voice-reachable and honest", not "feature-complete".
|
||||
- **Honesty splits into three.** M1 the turn path, M2 memory he cannot correct,
|
||||
M3 step-up. M1 and M2 touch different code and owe different docs. Step-up is
|
||||
configuration, not honesty, and sits before M4 because M4 is what first makes
|
||||
acts real.
|
||||
- **Five capabilities deferred past v1** (owner's call, 2026-08-15): speaker
|
||||
recognition, smart home, bluetooth control, model swap, self-update. Bluetooth
|
||||
was on the v1 list and came off it. Their spec entries keep their DoD.
|
||||
- **A milestone closes its own doc gaps and writes its own scenarios.** Neither
|
||||
becomes a milestone of its own. Otherwise the 17 missing docs and 46 missing
|
||||
scenario files collect at the end.
|
||||
- **Learning means behavioral, not weights.** Stored outcomes only. No adapter, no
|
||||
training set.
|
||||
- **Email and calendar need the product decision before deploying.** Both are
|
||||
built and neither is in `docker-compose.yml`. That is M8.
|
||||
|
||||
## Findings the spec pass produced
|
||||
|
||||
- **Recurring reminders do not exist on the spoken path.** `store.Reminder` carries
|
||||
`Cron` and `ipc.CreateReminder` takes one. `grep "Cron:" --include='*.go'`
|
||||
outside tests returns only `internal/ipc/client.go`, `internal/ipc/storeapi.go`
|
||||
and `cmd/mavend/tick_routines.go`, and the last is routines, a separate
|
||||
mechanism. Pills, the dog, the vet and the kibble are unbuilt on top of finished
|
||||
storage and delivery. This is M6.
|
||||
- **Seventeen capabilities have no living doc.** Memory is the worst cluster:
|
||||
facts, notes and the digestion worker have no owning document at all.
|
||||
- **Webhooks barely exist.** The only one in the tree is
|
||||
`internal/delivery/telegramsink/intake.go`, Telegram's own inbound hook.
|
||||
- **Command chaining does not exist.** The `chain` in `internal/router` is the
|
||||
world chain and the source chain.
|
||||
- **Desk notifications are inbound only.** `ambient:notif` reads his desktop.
|
||||
There is no outbound desk reach, and which direction he meant is undecided.
|
||||
- **Only 5 of 51 spec entries cite a scenario that exists.**
|
||||
|
||||
## Not filed, and this is the risk
|
||||
|
||||
Vikunja returned 503 across this session and the last, so **none of this has a
|
||||
task id**. The three commits above are tagged `V-719`, which is the
|
||||
reminder-cancellation task, not this work. Retag or file when Vikunja is back.
|
||||
|
||||
Unfiled, listed again at the end of `docs/roadmap.md`:
|
||||
|
||||
1. The capability audit itself, headed "unfiled".
|
||||
2. Remember-versus-query misroute, two of seven audit probes.
|
||||
3. The masculine reply on the wire, caught live while `CheckFeminine` passed.
|
||||
4. Recurring reminders having no caller.
|
||||
5. The seventeen capabilities with no living doc.
|
||||
6. The 46 scenario files the spec names and does not have.
|
||||
|
||||
## Next
|
||||
|
||||
Open a session on M1, which is three gate items on the turn path and owes no new
|
||||
doc. If Vikunja is up, file the six above first and give M1 a real id.
|
||||
|
||||
One command still outstanding from the last session, cheap and unrelated:
|
||||
|
||||
```sh
|
||||
docker compose up -d --force-recreate mavsttd mavttsd mavpoll
|
||||
```
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// resolveAction produces an ActionCandidate from a routing decision. It is the
|
||||
// single boundary between routing and action execution: everything downstream
|
||||
// (refusesCommand, task-status, Praxis, Hexis, proposeGap, tool.Executor.Exec)
|
||||
// consumes the candidate rather than re-resolving the function.
|
||||
//
|
||||
// Delegates to router.ResolveActionCandidate for the resolution logic, then
|
||||
// records the outcome in the decision trace.
|
||||
func (h *reactiveHandler) resolveAction(ctx context.Context, dec router.Decision) router.ActionCandidate {
|
||||
candidate := router.ResolveActionCandidate(dec, h.matcher)
|
||||
|
||||
// Record the resolution outcome in the decision trace.
|
||||
if dec.Intent == router.IntentAct {
|
||||
if candidate.ActionResolved() {
|
||||
noteActionResolution(ctx, string(candidate.Source), candidate.Fn, true)
|
||||
} else {
|
||||
noteActionResolution(ctx, "matcher", "", false)
|
||||
}
|
||||
}
|
||||
|
||||
return candidate
|
||||
}
|
||||
|
||||
// noteActionResolution records the action resolution outcome in the decision
|
||||
// trace. A nil recorder is the normal case in tests.
|
||||
func noteActionResolution(ctx context.Context, source, fn string, resolved bool) {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
outcome := decision.Declined
|
||||
reason := "no match"
|
||||
if resolved {
|
||||
outcome = decision.Won
|
||||
reason = "resolved via " + source
|
||||
if fn != "" {
|
||||
reason += ": " + fn
|
||||
}
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-resolve",
|
||||
Outcome: outcome,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
// noteActionValidation records the structural validation outcome in the
|
||||
// decision trace. Five outcomes: unresolved (matcher miss), valid
|
||||
// (structurally admissible), invalid_argument, missing_argument, or
|
||||
// ambiguous_target (structurally malformed).
|
||||
func noteActionValidation(ctx context.Context, v router.ActionValidationResult) {
|
||||
rec := decision.From(ctx)
|
||||
if rec == nil {
|
||||
return
|
||||
}
|
||||
switch v.Status {
|
||||
case router.ActionUnresolved:
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-validation",
|
||||
Outcome: decision.Declined,
|
||||
Reason: "unresolved",
|
||||
})
|
||||
case router.ActionValid:
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-validation",
|
||||
Outcome: decision.Won,
|
||||
Reason: "valid",
|
||||
})
|
||||
default:
|
||||
reason := string(v.Status)
|
||||
if len(v.Issues) > 0 {
|
||||
reason = string(v.Status) + ":" + v.Issues[0].Reason
|
||||
}
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageAction,
|
||||
Claimant: "action-validation",
|
||||
Outcome: decision.Declined,
|
||||
Reason: reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// newActHandler builds a handler with the act path wired: a matcher over
|
||||
// whatever tools the test enabled, no model, no ecosystem.
|
||||
func newActHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
matcher := tool.NewMatcher(api)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
tools: tool.NewExecutor(api, 2*time.Second),
|
||||
matcher: matcher,
|
||||
now: func() time.Time { return time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestActRouteSource_NoMatcherInvoke pins that an act with HasFn=true
|
||||
// produces a candidate from the route and does not invoke the matcher.
|
||||
func TestActRouteSource_NoMatcherInvoke(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Enable a tool so the matcher has something to match against.
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act with HasFn=true: the candidate must come from the route.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("route-sourced act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActMatcherSource_FallbackMatch pins that an act without Fn invokes
|
||||
// the matcher and produces a matcher-sourced candidate.
|
||||
func TestActMatcherSource_FallbackMatch(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Enable a tool so the matcher can find it.
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act without HasFn: the matcher must resolve "status" from the text.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "check status",
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("matcher-sourced act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActMatcherMiss_ProposeGap pins that a matcher miss produces the
|
||||
// same propose-gap behavior as before.
|
||||
func TestActMatcherMiss_ProposeGap(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Enable one tool so the matcher has an allowlist, but not the one asked for.
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act without HasFn and text that doesn't match any tool.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "deploy the thing",
|
||||
Slots: router.Slots{Text: "deploy the thing"},
|
||||
})
|
||||
if !strings.Contains(strings.ToLower(reply), "предлож") {
|
||||
t.Errorf("matcher miss replied %q; want propose-gap behavior", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActDestructive_ConfirmationUnchanged pins that a destructive tool
|
||||
// still triggers the confirmation flow.
|
||||
func TestActDestructive_ConfirmationUnchanged(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart",
|
||||
Slots: router.Slots{Fn: "restart", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("destructive act replied %q; want a confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActTaskStatus_InterceptUnchanged pins that task_status is intercepted
|
||||
// before reaching the tool executor.
|
||||
func TestActTaskStatus_InterceptUnchanged(t *testing.T) {
|
||||
h, _ := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "task status",
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true,
|
||||
Text: "task status"},
|
||||
})
|
||||
// task_status is intercepted by resolveTaskStatus, which returns a
|
||||
// status phrase. The exact reply depends on the store state, but it
|
||||
// must not be a tool execution result.
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Errorf("task_status was not intercepted, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActStage0_SameResult pins that a stage-0 act (grammar match with
|
||||
// HasFn=true) produces the same tool execution as before.
|
||||
func TestActStage0_SameResult(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Stage: 0,
|
||||
Confidence: 1.0,
|
||||
Utterance: "maven, restart nginx",
|
||||
Slots: router.Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
Producer: router.RouteProducerGrammar,
|
||||
})
|
||||
if !strings.Contains(reply, "сделала") && !strings.Contains(reply, "готово") {
|
||||
t.Errorf("stage-0 act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActLearnedRouter_NoFn_FallbackMatch pins that a learned-router act
|
||||
// without Fn falls through to the matcher and produces the same result.
|
||||
func TestActLearnedRouter_NoFn_FallbackMatch(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// LLM routed the act but did not fill Fn (common when the model returns
|
||||
// the verb in Text but not in Fn).
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Stage: 1,
|
||||
Confidence: 0.85,
|
||||
Utterance: "could you restart nginx",
|
||||
Slots: router.Slots{Text: "restart nginx"},
|
||||
Producer: router.RouteProducerLLM,
|
||||
})
|
||||
if !strings.Contains(reply, "сделала") && !strings.Contains(reply, "готово") {
|
||||
t.Errorf("learned-router act replied %q; want it to have run", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAction_CandidateSource_Verified pins the candidate source
|
||||
// for both route-resolved and matcher-resolved actions.
|
||||
func TestResolveAction_CandidateSource_Verified(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Route-resolved: HasFn=true.
|
||||
c1 := h.resolveAction(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
if c1.Source != router.ActionSourceRoute {
|
||||
t.Errorf("route candidate source = %q, want route", c1.Source)
|
||||
}
|
||||
if c1.Fn != "status" {
|
||||
t.Errorf("route candidate Fn = %q, want status", c1.Fn)
|
||||
}
|
||||
|
||||
// Matcher-resolved: no Fn, text matches.
|
||||
c2 := h.resolveAction(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if c2.Source != router.ActionSourceMatcher {
|
||||
t.Errorf("matcher candidate source = %q, want matcher", c2.Source)
|
||||
}
|
||||
if c2.Fn != "status" {
|
||||
t.Errorf("matcher candidate Fn = %q, want status", c2.Fn)
|
||||
}
|
||||
|
||||
// Matcher miss: no Fn, text doesn't match.
|
||||
c3 := h.resolveAction(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "deploy everything"},
|
||||
})
|
||||
if c3.ActionResolved() {
|
||||
t.Errorf("miss candidate resolved = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// --- structural validation integration tests ---
|
||||
|
||||
// TestActValidation_MalformedCandidate_BlankFn pins that a resolved
|
||||
// candidate with a blank (whitespace-only) Fn does not execute and
|
||||
// produces a failure response.
|
||||
func TestActValidation_MalformedCandidate_BlankFn(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Simulate a malformed candidate by writing a blank Fn into Slots
|
||||
// after resolution. This tests that the validation layer catches
|
||||
// structurally invalid candidates.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: " ", HasFn: true},
|
||||
})
|
||||
// The blank Fn should not reach tool execution. It either hits
|
||||
// the validation gate (ActFail) or the existing error paths.
|
||||
if reply == "" {
|
||||
t.Error("expected a response, got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_UnresolvedCandidate_ProposeGap pins that an unresolved
|
||||
// candidate (matcher miss) still flows to proposeGap, unchanged.
|
||||
func TestActValidation_UnresolvedCandidate_ProposeGap(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "deploy everything",
|
||||
Slots: router.Slots{Text: "deploy everything"},
|
||||
})
|
||||
if !strings.Contains(strings.ToLower(reply), "предлож") {
|
||||
t.Errorf("unresolved candidate replied %q; want propose-gap behavior", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_DestructiveValid_StillConfirms pins that a destructive
|
||||
// valid action still reaches the confirmation path through validation.
|
||||
func TestActValidation_DestructiveValid_StillConfirms(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart",
|
||||
Slots: router.Slots{Fn: "restart", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("destructive valid act replied %q; want confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_IrreversibleValid_NeedsAuthedSurface pins that an
|
||||
// irreversible valid action still reaches ErrNeedsAuthedSurface.
|
||||
func TestActValidation_IrreversibleValid_NeedsAuthedSurface(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
// Register an irreversible tool: cmd containing "drop" triggers the
|
||||
// irreversible tier via RiskOf → isIrreversible.
|
||||
if err := st.EnableTool(ctx, "drop_table", []string{"drop"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "drop_table",
|
||||
Slots: router.Slots{Fn: "drop_table", HasFn: true},
|
||||
})
|
||||
// Irreversible tools return ErrNeedsAuthedSurface, which produces
|
||||
// a specific phraser response.
|
||||
if !strings.Contains(reply, "выполню") && !strings.Contains(reply, "запусти") {
|
||||
t.Errorf("irreversible valid act replied %q; want authed-surface response", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_ValidationTracing pins that validation outcomes are
|
||||
// recorded in the decision trace.
|
||||
func TestActValidation_ValidationTracing(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
now := h.now()
|
||||
|
||||
// Valid candidate: trace should show action-validation:won.
|
||||
ctx, rec := decision.With(context.Background(), "status", "tap:text")
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
records := rec.Claims
|
||||
found := false
|
||||
for _, c := range records {
|
||||
if c.Claimant == "action-validation" && c.Outcome == decision.Won {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected action-validation:won in trace, got %v", records)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActExecutionFromCandidateNotSlots pins that downstream execution reads
|
||||
// resolved action data from ActionCandidate, not from Decision.Slots. The
|
||||
// decision has empty Fn/Args/HasFn — the bridge used to copy candidate values
|
||||
// back into these fields. After the bridge removal, execution must still
|
||||
// succeed because the candidate carries the resolved function.
|
||||
func TestActExecutionFromCandidateNotSlots(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Act without any Fn/Args/HasFn in Slots — the matcher resolves from Text.
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "check status",
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("execution from candidate replied %q; want tool success", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// --- validation status boundary tests ---
|
||||
|
||||
// TestActValidation_StatusValidRoute pins that a route-resolved valid action
|
||||
// produces ActionValid status and reaches execution.
|
||||
func TestActValidation_StatusValidRoute(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: "status", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("valid route act replied %q; want tool success", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_StatusValidMatcher pins that a matcher-resolved valid
|
||||
// action produces ActionValid status and reaches execution.
|
||||
func TestActValidation_StatusValidMatcher(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "check status",
|
||||
Slots: router.Slots{Text: "status"},
|
||||
})
|
||||
if !strings.Contains(reply, "готово") {
|
||||
t.Errorf("valid matcher act replied %q; want tool success", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_StatusUnresolved pins that an unresolved candidate
|
||||
// produces ActionUnresolved status and flows to proposeGap.
|
||||
func TestActValidation_StatusUnresolved(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "deploy everything",
|
||||
Slots: router.Slots{Text: "deploy everything"},
|
||||
})
|
||||
if !strings.Contains(strings.ToLower(reply), "предлож") {
|
||||
t.Errorf("unresolved act replied %q; want propose-gap", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_StatusInvalid pins that a structurally invalid candidate
|
||||
// produces ActionInvalidArgument status and refuses execution.
|
||||
func TestActValidation_StatusInvalid(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: " ", HasFn: true},
|
||||
})
|
||||
if reply == "" {
|
||||
t.Error("expected a response for invalid candidate")
|
||||
}
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Error("invalid candidate should not reach tool execution")
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_DestructiveValidStatus pins that a destructive but
|
||||
// structurally valid action still produces ActionValid status and reaches
|
||||
// the confirmation path (not validation failure).
|
||||
func TestActValidation_DestructiveValidStatus(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"true"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart",
|
||||
Slots: router.Slots{Fn: "restart", HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("destructive valid act replied %q; want confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_ConfirmationUnchanged pins that the confirmation flow
|
||||
// is unchanged by validation.
|
||||
func TestActValidation_ConfirmationUnchanged(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "restart", []string{"echo", "ok"}, true, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "restart nginx",
|
||||
Slots: router.Slots{Fn: "restart", Args: []string{"nginx"}, HasFn: true},
|
||||
})
|
||||
if !strings.Contains(reply, "да или нет") {
|
||||
t.Errorf("confirmation act replied %q; want confirm turn", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_TaskStatusInterceptUnchanged pins that task_status
|
||||
// interception is unchanged by validation.
|
||||
func TestActValidation_TaskStatusInterceptUnchanged(t *testing.T) {
|
||||
h, _ := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "task status",
|
||||
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Text: "task status"},
|
||||
})
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Errorf("task_status was not intercepted, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestActValidation_NoExecutionOnFailure pins that validation failure
|
||||
// prevents downstream execution.
|
||||
func TestActValidation_NoExecutionOnFailure(t *testing.T) {
|
||||
h, st := newActHandler(t)
|
||||
ctx := context.Background()
|
||||
now := h.now()
|
||||
|
||||
if err := st.EnableTool(ctx, "status", []string{"true"}, false, "test", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
reply := h.actionAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Utterance: "status",
|
||||
Slots: router.Slots{Fn: " ", HasFn: true},
|
||||
})
|
||||
if strings.Contains(reply, "готово") {
|
||||
t.Error("validation failure should not reach tool execution")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+59
-20
@@ -11,47 +11,86 @@ import (
|
||||
"github.com/kami/maven/internal/tool"
|
||||
)
|
||||
|
||||
// actionAct handles router.IntentAct: match a verb to an enabled tool, offer
|
||||
// it to the ecosystems first, and run it behind the confirm gate and the
|
||||
// allowlist. proposeGap and the confirm gate itself live in confirm.go.
|
||||
// actionAct handles router.IntentAct: resolve the action, offer it to the
|
||||
// ecosystems first, and run it behind the confirm gate and the allowlist.
|
||||
// proposeGap and the confirm gate itself live in confirm.go.
|
||||
//
|
||||
// Action resolution happens in resolveAction (actionresolve.go) — a single
|
||||
// boundary that produces an ActionCandidate before execution. This function
|
||||
// consumes the candidate; it no longer decides which function/tool the user
|
||||
// meant.
|
||||
func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string {
|
||||
// tool executor: run the matched fn against the enabled allowlist.
|
||||
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
|
||||
// didn't go through the stage-0 act grammar).
|
||||
if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil {
|
||||
if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok {
|
||||
dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
// Resolve the action: produce an ActionCandidate from the routing
|
||||
// decision. The candidate carries the resolved function, its arguments,
|
||||
// and where the resolution came from (route or matcher).
|
||||
candidate := h.resolveAction(ctx, dec)
|
||||
|
||||
// Structural validation: is this candidate complete enough to proceed?
|
||||
// Unresolved (Fn empty) flows to proposeGap; invalid (Fn present but
|
||||
// malformed) is refused; valid proceeds to execution.
|
||||
validation := router.ValidateActionCandidate(candidate)
|
||||
noteActionValidation(ctx, validation)
|
||||
|
||||
if !validation.Unresolved() && !validation.Valid() {
|
||||
// Resolved but structurally malformed: refuse execution.
|
||||
return phraser.A(phraser.ActFail, nil)
|
||||
}
|
||||
|
||||
// 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 candidate.Fn == router.TaskStatusFn {
|
||||
return h.resolveTaskStatus(ctx, dec, candidate)
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
if h.ecosystem != nil && h.ecosystem.praxis != nil && candidate.ActionResolved() {
|
||||
if reply := h.handlePraxisAct(ctx, dec, candidate); reply != "" {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
|
||||
// Hexis ecosystem action: if ecosystem is configured and we have a verb
|
||||
// + entity text, try to resolve the entity and execute via Hexis.
|
||||
if h.ecosystem != nil && h.ecosystem.hexis != nil && dec.Slots.Text != "" {
|
||||
if reply := h.handleHexisAct(ctx, dec); reply != "" {
|
||||
if h.ecosystem != nil && h.ecosystem.hexis != nil && router.ActHasEntityTarget(dec) {
|
||||
if reply := h.handleHexisAct(ctx, dec, candidate); reply != "" {
|
||||
return reply
|
||||
}
|
||||
}
|
||||
|
||||
// HasFn still false ⇒ no allowlist match: scaffold a 'proposed' tool
|
||||
// Unresolved candidate ⇒ no allowlist match: scaffold a 'proposed' tool
|
||||
// the user can enable on the authed surface ("earn the right to ask").
|
||||
if !dec.Slots.HasFn {
|
||||
if !candidate.ActionResolved() {
|
||||
return h.proposeGap(ctx, dec)
|
||||
}
|
||||
out, err := h.tools.Exec(ctx, dec.Slots.Fn, dec.Slots.Args, false)
|
||||
out, err := h.tools.Exec(ctx, candidate.Fn, candidate.Args, false)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, tool.ErrNeedsConfirm):
|
||||
// destructive: park it and ask. The next utterance answers.
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
phrase := actPhrase(candidate.Fn, candidate.Args)
|
||||
h.park(candidate.Fn, candidate.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,
|
||||
@@ -74,7 +113,7 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
// where a human types them.
|
||||
return phraser.A(phraser.ActNeedsArgs, nil)
|
||||
}
|
||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||
log.Printf("voice: tool %s: %v", candidate.Fn, err)
|
||||
if out != "" {
|
||||
return phraser.A(phraser.ActFailOut, map[string]string{"out": firstLine(out)})
|
||||
}
|
||||
|
||||
@@ -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, candidate router.ActionCandidate) 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: "молоко"},
|
||||
}, routeCandidate(router.TaskStatusFn))
|
||||
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},
|
||||
}, routeCandidate(router.TaskStatusFn))
|
||||
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: "продлить домен"},
|
||||
}, routeCandidate(router.TaskStatusFn))
|
||||
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"), routeCandidate("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"), routeCandidate("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"), routeCandidate("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"), routeCandidate("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"), routeCandidate("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)
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,9 @@ func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (str
|
||||
Utterance: t.dec.Utterance,
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "list_attention", HasFn: true},
|
||||
}, router.ActionCandidate{
|
||||
Fn: "list_attention",
|
||||
Source: router.ActionSourceRoute,
|
||||
})
|
||||
if reply == "" {
|
||||
return "", false
|
||||
|
||||
@@ -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,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// TestTextAndVoiceConvergeOnNormalizedInput — both entry points construct a
|
||||
// NormalizedInput and pass it to runTurn. The same utterance produces the same
|
||||
// route intent regardless of whether it arrived as text or voice.
|
||||
func TestTextAndVoiceConvergeOnNormalizedInput(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
ctx := context.Background()
|
||||
|
||||
utterance := "который час"
|
||||
voiceCtx := withDialogueID(ctx, dialogueIDFor(sourceVoice, ""))
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
|
||||
voiceReply := h.runTurn(voiceCtx, router.NormalizedInput{Text: utterance, Source: sourceVoice})
|
||||
textReply := h.runTurn(textCtx, router.NormalizedInput{Text: utterance, Source: sourceText})
|
||||
|
||||
// Both paths should produce the same kind of reply (time answer).
|
||||
for _, pair := range []struct {
|
||||
label, reply string
|
||||
}{
|
||||
{"voice", voiceReply},
|
||||
{"text", textReply},
|
||||
} {
|
||||
if !strings.Contains(pair.reply, "час") && !strings.Contains(pair.reply, "время") {
|
||||
t.Errorf("%s reply %q does not look like a time answer", pair.label, pair.reply)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizedInputSourcePreserved — the source survives into the decision
|
||||
// record so a trace can tell voice from text.
|
||||
func TestNormalizedInputSourcePreserved(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
ctx := context.Background()
|
||||
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
h.runTurn(textCtx, router.NormalizedInput{Text: "привет", Source: sourceText})
|
||||
|
||||
recs := h.decisions.Recent(1)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("no decision record")
|
||||
}
|
||||
if recs[0].InputSource != string(sourceText) {
|
||||
t.Errorf("InputSource = %q, want %q", recs[0].InputSource, sourceText)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouteProducerOnDecisionRecord — the producer is carried from the router
|
||||
// decision into the decision record for observability.
|
||||
func TestRouteProducerOnDecisionRecord(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
ctx := context.Background()
|
||||
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
h.runTurn(textCtx, router.NormalizedInput{Text: "который час", Source: sourceText})
|
||||
|
||||
recs := h.decisions.Recent(1)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("no decision record")
|
||||
}
|
||||
// A time query is a stage-0 grammar match.
|
||||
if recs[0].RouteProducer != string(router.RouteProducerGrammar) {
|
||||
t.Errorf("RouteProducer = %q, want %q", recs[0].RouteProducer, router.RouteProducerGrammar)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreRouteClaimHasNoRouteProducer — a turn claimed by a pre-route resolver
|
||||
// never reaches the router, so the record's RouteProducer must be empty.
|
||||
func TestPreRouteClaimHasNoRouteProducer(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
// Park a confirm so the next "да" is consumed before routing.
|
||||
// newRoutingClarifyHandler uses a fixed clock at 2026-07-31 09:00 UTC.
|
||||
h.pending = &pendingAct{
|
||||
fn: "test",
|
||||
phrase: "delete everything",
|
||||
expiry: time.Date(2026, 7, 31, 9, 1, 0, 0, time.UTC),
|
||||
}
|
||||
ctx := context.Background()
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
h.runTurn(textCtx, router.NormalizedInput{Text: "да", Source: sourceText})
|
||||
|
||||
recs := h.decisions.Recent(1)
|
||||
if len(recs) == 0 {
|
||||
t.Fatal("no decision record")
|
||||
}
|
||||
if recs[0].RouteProducer != "" {
|
||||
t.Errorf("RouteProducer = %q, want empty (pre-route claimed the turn)", recs[0].RouteProducer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStage0ProducerUnchanged — grammars still produce the exact same intents
|
||||
// at confidence 1.0. This pins stage-0 behavior through the new boundary.
|
||||
func TestStage0ProducerUnchanged(t *testing.T) {
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
h.decisions = decision.NewRing()
|
||||
ctx := context.Background()
|
||||
textCtx := withDialogueID(ctx, dialogueIDFor(sourceText, "test"))
|
||||
|
||||
cases := []struct {
|
||||
utterance string
|
||||
intent router.Intent
|
||||
}{
|
||||
{"напомни позвонить маме завтра", router.IntentReminder},
|
||||
{"который час", router.IntentSystem},
|
||||
}
|
||||
for _, c := range cases {
|
||||
reply := h.runTurn(textCtx, router.NormalizedInput{Text: c.utterance, Source: sourceText})
|
||||
_ = reply // behavior unchanged; we test the record, not the reply text.
|
||||
|
||||
recs := h.decisions.Recent(1)
|
||||
if len(recs) == 0 {
|
||||
t.Errorf("%s: no decision record", c.utterance)
|
||||
continue
|
||||
}
|
||||
rec := recs[0]
|
||||
if rec.RouteProducer != string(router.RouteProducerGrammar) {
|
||||
t.Errorf("%s: RouteProducer = %q, want %q", c.utterance, rec.RouteProducer, router.RouteProducerGrammar)
|
||||
}
|
||||
// Clear the ring for the next case.
|
||||
h.decisions = decision.NewRing()
|
||||
}
|
||||
}
|
||||
@@ -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, router.NormalizedInput{Text: "купить хлеб", Source: 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
|
||||
|
||||
+247
-59
@@ -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
|
||||
@@ -94,7 +107,7 @@ var praxisCapabilities = []praxisCapability{
|
||||
// handlePraxisAct — dispatches ecosystem tool acts through the Praxis tools API.
|
||||
// Returns "" when the act is not a Praxis verb (the caller falls through to the
|
||||
// system command executor). Returns a reply string otherwise.
|
||||
func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision) string {
|
||||
func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decision, candidate router.ActionCandidate) string {
|
||||
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||
return ""
|
||||
}
|
||||
@@ -105,9 +118,16 @@ 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 {
|
||||
if alias == candidate.Fn {
|
||||
return capability.handle(ctx, h, px, dec)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -594,7 +657,13 @@ func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []st
|
||||
// handleHexisAct — resolves entity references through Nexus and executes
|
||||
// matching capabilities through Hexis. Returns a reply string when handled,
|
||||
// or "" to fall through to the system command executor.
|
||||
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string {
|
||||
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision, candidate router.ActionCandidate) 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)})
|
||||
@@ -660,7 +721,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
// Match the user's verb to a capability by name/description. Collect all
|
||||
// matches: more than one is itself ambiguous, so we ask rather than pick
|
||||
// the first (ecosystem invariant: no arbitrary target for mutation).
|
||||
verb := dec.Slots.Fn
|
||||
verb := candidate.Fn
|
||||
if verb == "" {
|
||||
verb = dec.Slots.Text
|
||||
}
|
||||
@@ -671,7 +732,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
// round then: the phrase is the haystack and the capability name is what we
|
||||
// look for in it (Vikunja #476). Only when the fn slot is empty — a matched
|
||||
// fn is a single verb and containment already means what it says.
|
||||
loose := !dec.Slots.HasFn
|
||||
loose := !candidate.ActionResolved()
|
||||
var matches []*hexisclient.Capability
|
||||
for i, c := range caps {
|
||||
name := strings.ToLower(c.Name)
|
||||
@@ -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,127 @@ 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)
|
||||
// Resolve the action candidate. Use the matcher when available; when the
|
||||
// handler has no matcher (ecosystem-only test harnesses), build an
|
||||
// unresolved candidate directly — the matcher would not have matched either.
|
||||
var candidate router.ActionCandidate
|
||||
if h.matcher != nil {
|
||||
candidate = h.resolveAction(ctx, dec)
|
||||
} else {
|
||||
candidate = router.ResolveActionCandidate(dec, nil)
|
||||
}
|
||||
return h.handleHexisAct(ctx, dec, candidate)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
||||
// store is the one thing the Praxis path could inherit it through.
|
||||
nexus.SetFault(503)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); actRan(reply) {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||
@@ -104,7 +104,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a recorded nexus failure must not degrade the praxis digest, got %q", reply)
|
||||
}
|
||||
@@ -114,10 +114,10 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
||||
|
||||
// And the reverse: a Praxis outage mid-session leaves the Hexis path whole.
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
||||
}
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestEcosystem_OneEndpointDownDoesNotMuteTheService(t *testing.T) {
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 503)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("a downed surface endpoint must not mute the digest, got %q", reply)
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" {
|
||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
||||
}
|
||||
@@ -172,7 +172,7 @@ func TestEcosystem_RejectedCredentialSaysSo(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(status)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "токен") {
|
||||
t.Fatalf("http %d must read as a credential problem, got %q", status, reply)
|
||||
}
|
||||
@@ -193,7 +193,7 @@ func TestEcosystem_MalformedPraxisBodyDegrades(t *testing.T) {
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetBody(`[{"title":`)
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("a malformed praxis body must not answer with silence")
|
||||
}
|
||||
@@ -211,7 +211,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" || actRan(reply) {
|
||||
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||
}
|
||||
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
||||
nexus := newFakeNexus(t, body)
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||
}
|
||||
})
|
||||
@@ -249,7 +249,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" || actRan(reply) {
|
||||
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||
}
|
||||
@@ -267,7 +267,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecutionFailed("exec_1", "unit not found"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if actRan(reply) {
|
||||
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||
}
|
||||
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
exec := tracesFor(t, h, "hexis", "execute")
|
||||
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
if len(traces(t, h)) == 0 {
|
||||
@@ -339,7 +339,7 @@ func TestEcosystem_AmbiguousTargetBlocksExecution(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous resolve must list candidates, got %q", reply)
|
||||
}
|
||||
@@ -360,7 +360,7 @@ func TestEcosystem_NoAutonomousPraxisToHexis(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
_ = h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if hexis.Count("", "/api/v1") != 0 {
|
||||
t.Fatal("attention digest must not contact hexis on its own")
|
||||
}
|
||||
@@ -378,7 +378,7 @@ func TestEcosystem_MutatingCapabilityWaitsForConfirmation(t *testing.T) {
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("restart"))
|
||||
reply := h.handleHexisAct(ctx, actDec("restart"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "restart") || !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
@@ -404,7 +404,7 @@ func TestEcosystem_SurfaceFailureStillDelivers(t *testing.T) {
|
||||
praxis.SetRouteFault("/api/v1/tools/surface", 500)
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("failed surface must not swallow the digest, got %q", reply)
|
||||
}
|
||||
@@ -426,10 +426,10 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, praxis, hexis)
|
||||
|
||||
for name, reply := range map[string]string{
|
||||
"hexis act": h.handleHexisAct(ctx, actDec("muzick indexer")),
|
||||
"attention": h.handlePraxisAct(ctx, praxisActDec("list_attention")),
|
||||
"changes": h.handlePraxisAct(ctx, praxisActDec("list_changes")),
|
||||
"acknowledge": h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1")),
|
||||
"hexis act": h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")),
|
||||
"attention": h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")),
|
||||
"changes": h.handlePraxisAct(ctx, praxisActDec("list_changes"), routeCandidate("list_changes")),
|
||||
"acknowledge": h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"), routeCandidate("acknowledge_item")),
|
||||
} {
|
||||
if reply == "" {
|
||||
t.Errorf("%s: total outage must not answer with silence", name)
|
||||
@@ -458,11 +458,11 @@ func TestEcosystem_RecoveryAfterOutageNeedsNoRestart(t *testing.T) {
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
praxis.SetFault(503)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); strings.Contains(reply, "disk") {
|
||||
t.Fatalf("outage must not serve content, got %q", reply)
|
||||
}
|
||||
praxis.SetFault(0)
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("recovery must work on the next turn, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"), routeCandidate("restart"))
|
||||
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"), routeCandidate("restart"))
|
||||
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"), routeCandidate("restart"))
|
||||
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"), routeCandidate("restart"))
|
||||
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"), routeCandidate("restart"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ func praxisActDec(fn string) router.Decision {
|
||||
return router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: fn, HasFn: true}}
|
||||
}
|
||||
|
||||
// routeCandidate builds an ActionCandidate matching a route-resolved Decision.
|
||||
func routeCandidate(fn string) router.ActionCandidate {
|
||||
return router.ActionCandidate{Fn: fn, Source: router.ActionSourceRoute}
|
||||
}
|
||||
|
||||
// praxisItemDec is praxisActDec for the lifecycle verbs, which need an item id
|
||||
// in the value slot. Without one they answer "which item?" and never reach
|
||||
// Praxis at all, which makes them useless for testing a Praxis outage.
|
||||
@@ -46,7 +51,7 @@ func TestPraxisAttention_HappyPathSurfacesItems(t *testing.T) {
|
||||
praxis := newFakePraxis(t, items)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("expected attention digest to mention the item, got %q", reply)
|
||||
}
|
||||
@@ -77,7 +82,7 @@ func TestPraxisAttention_DegradedFailsClosedNotEmpty(t *testing.T) {
|
||||
praxis.SetFault(500)
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"))
|
||||
reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("praxis outage must not produce an empty reply")
|
||||
}
|
||||
@@ -104,14 +109,94 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
}
|
||||
|
||||
nexus.SetFault(503)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if actRan(reply) {
|
||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||
}
|
||||
|
||||
nexus.SetFault(0)
|
||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !actRan(reply) {
|
||||
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"},
|
||||
}, routeCandidate("entity_attention"))
|
||||
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"), routeCandidate("resolve_item")); 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"), routeCandidate("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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
||||
caps := `[{"id":"cap_restart","name":"restart","read_only":false,"risk":"high"}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating cap should ask to confirm, got %q", reply)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestHexisConfirmNoDoesNotExecute(t *testing.T) {
|
||||
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
reply, handled := h.resolveConfirm(ctx, "нет")
|
||||
if !handled || !strings.Contains(reply, "отменила") {
|
||||
t.Fatalf("no should cancel, got handled=%v reply=%q", handled, reply)
|
||||
@@ -119,7 +119,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
||||
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !*executed {
|
||||
t.Fatal("read-only cap should execute without confirmation")
|
||||
}
|
||||
@@ -136,7 +136,7 @@ func TestHexisAmbiguousAsksClarification(t *testing.T) {
|
||||
ambiguous := `{"status":"ambiguous","candidates":[{"entity_id":"ent_muzick","display_name":"Muzick indexer"},{"entity_id":"ent_manga","display_name":"Manga indexer"}]}`
|
||||
h, executed := newHexisTestHandler(t, ambiguous, `[]`)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("the indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("the indexer"), routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Manga indexer") {
|
||||
t.Fatalf("ambiguous should list candidates, got %q", reply)
|
||||
}
|
||||
@@ -154,7 +154,7 @@ func TestHexisResolveFlatShapeAccepted(t *testing.T) {
|
||||
caps := `[{"id":"cap_status","name":"restart","read_only":true}]`
|
||||
h, executed := newHexisTestHandler(t, flat, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !*executed {
|
||||
t.Fatalf("flat-shaped resolved entity should still execute, got reply %q", reply)
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
|
||||
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
|
||||
}
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" {
|
||||
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
@@ -216,7 +216,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
|
||||
ecosystem: stubEcosystem(nexus.URL, hexis.URL),
|
||||
}
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if reply == "" {
|
||||
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
|
||||
notFound := `{"status":"not_found"}`
|
||||
h, executed := newHexisTestHandler(t, notFound, `[]`)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("turn off the lights"))
|
||||
reply := h.handleHexisAct(ctx, actDec("turn off the lights"), routeCandidate("restart"))
|
||||
if reply != "" {
|
||||
t.Fatalf("not_found resolution should fall through with empty reply, got %q", reply)
|
||||
}
|
||||
@@ -264,7 +264,7 @@ func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) {
|
||||
caps := `[{"id":"cap_wipe","name":"restart","read_only":false,"risk":"irreversible","requires_confirmation":true}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if *executed {
|
||||
t.Fatal("an irreversible capability ran from the voice path")
|
||||
}
|
||||
@@ -284,7 +284,7 @@ func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) {
|
||||
caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if !*executed {
|
||||
t.Fatal("a capability Hexis calls safe should run")
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func TestHexisUndeclaredTierStillConfirms(t *testing.T) {
|
||||
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
||||
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
if *executed {
|
||||
t.Fatal("a mutating capability ran without a confirm")
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart")); !actRan(reply) {
|
||||
t.Fatalf("setup: expected success, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestEcosystemTrace_OneCorrelationIDPerPraxisAction(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention"), routeCandidate("list_attention")); !strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("setup: expected the digest, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -218,7 +218,7 @@ func TestEcosystemTrace_FailuresAreTracedToo(t *testing.T) {
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
nexus.SetFault(401)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
@@ -242,7 +242,7 @@ func TestEcosystemTrace_UnreachableIsNotRefused(t *testing.T) {
|
||||
h := ecoHandler(t, nil, nil, nil)
|
||||
h.ecosystem.nexus = newNexusClient("http://127.0.0.1:1")
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick indexer"), routeCandidate("restart"))
|
||||
|
||||
d := findTrace(t, h, "nexus", "resolve")
|
||||
if d == nil {
|
||||
@@ -263,7 +263,7 @@ func TestEcosystemTrace_RedactsTheUtterance(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusNotFound())
|
||||
h := ecoHandler(t, nexus, nil, nil)
|
||||
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"))
|
||||
_ = h.handleHexisAct(ctx, actDec("перезапусти кофемашину"), routeCandidate("restart"))
|
||||
|
||||
recorded := traces(t, h)
|
||||
if len(recorded) == 0 {
|
||||
@@ -295,7 +295,7 @@ func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
))
|
||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, ambig, nil, hexis)
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick"))
|
||||
_ = h.handleHexisAct(ctx, actDec("muzick"), routeCandidate("restart"))
|
||||
if d := findTrace(t, h, "nexus", "resolve"); d == nil || d.Status != traceAmbig {
|
||||
t.Fatalf("ambiguous resolve must be traced as such, got %+v", d)
|
||||
}
|
||||
@@ -303,7 +303,7 @@ func TestEcosystemTrace_AmbiguityAndConfirmationAreRecorded(t *testing.T) {
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
mutating := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
h2 := ecoHandler(t, nexus, nil, newFakeHexis(t, mutating, fixtureHexisExecuted("exec_1", "succeeded")))
|
||||
_ = h2.handleHexisAct(ctx, actDec("restart"))
|
||||
_ = h2.handleHexisAct(ctx, actDec("restart"), routeCandidate("restart"))
|
||||
d := findTrace(t, h2, "hexis", "confirmation")
|
||||
if d == nil || d.Status != tracePending {
|
||||
t.Fatalf("a parked confirmation must be traced, got %+v", d)
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
|
||||
}
|
||||
h.handleHexisAct(ctx, dec)
|
||||
h.handleHexisAct(ctx, dec, routeCandidate("restart"))
|
||||
|
||||
reqs := nexus.Requests()
|
||||
if len(reqs) == 0 {
|
||||
@@ -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.
|
||||
@@ -184,7 +226,7 @@ func TestTwoResolvedNamesAsk(t *testing.T) {
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||
}
|
||||
reply := h.handleHexisAct(ctx, dec)
|
||||
reply := h.handleHexisAct(ctx, dec, routeCandidate("restart"))
|
||||
if !strings.Contains(reply, "nginx") || !strings.Contains(reply, "Muzick indexer") {
|
||||
t.Fatalf("reply = %q, want both names she found", reply)
|
||||
}
|
||||
@@ -209,7 +251,7 @@ func TestTheNameNexusKnowsWins(t *testing.T) {
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||
}
|
||||
reply := h.handleHexisAct(ctx, dec)
|
||||
reply := h.handleHexisAct(ctx, dec, routeCandidate("restart"))
|
||||
if reply == "" {
|
||||
t.Fatal("the resolvable name must carry the act")
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestEntityAttention_ScopesPraxisByCanonicalID(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("expected the scoped item in the reply, got %q", reply)
|
||||
}
|
||||
@@ -70,7 +70,7 @@ func TestEntityAttention_FoldsInLocalFactsForSameEntity(t *testing.T) {
|
||||
t.Fatalf("ResolveFactEntity: %v", err)
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "descaled in june") {
|
||||
t.Fatalf("expected entity-scoped local facts in the reply, got %q", reply)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func TestEntityAttention_UnscopedPraxisResponseIsRefused(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("an unscoped response must not be read back as entity-scoped, got %q", reply)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func TestEntityAttention_ForeignItemsAreDropped(t *testing.T) {
|
||||
praxis := newFakePraxis(t, mustJSON(mixed))
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "indexer queue is backing up") {
|
||||
t.Fatalf("the matching item must be spoken, got %q", reply)
|
||||
}
|
||||
@@ -140,7 +140,7 @@ func TestEntityAttention_TruncationIsNamed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("the espresso machine"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "и это не всё") {
|
||||
t.Fatalf("a truncated recall must say it is truncated, got %q", reply)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestEntityAttention_AmbiguousAsksInsteadOfGuessing(t *testing.T) {
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick"), routeCandidate("entity_attention"))
|
||||
if !strings.Contains(reply, "Muzick indexer") || !strings.Contains(reply, "Muzick web") {
|
||||
t.Fatalf("ambiguous subject must ask, got %q", reply)
|
||||
}
|
||||
@@ -173,13 +173,13 @@ func TestEntityAttention_MissingAndDegradedAreDistinct(t *testing.T) {
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
missing := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
missing := h.handlePraxisAct(ctx, entityAttentionDec("нечто"), routeCandidate("entity_attention"))
|
||||
if missing == "" {
|
||||
t.Fatal("an unknown entity must still get an answer")
|
||||
}
|
||||
|
||||
nexus.SetFault(503)
|
||||
degraded := h.handlePraxisAct(ctx, entityAttentionDec("нечто"))
|
||||
degraded := h.handlePraxisAct(ctx, entityAttentionDec("нечто"), routeCandidate("entity_attention"))
|
||||
if degraded == missing {
|
||||
t.Fatalf("outage and unknown-entity must not read the same: %q", degraded)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func TestEntityAttention_DelayedNexusDegradesNotHangs(t *testing.T) {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||
defer cancel()
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if reply == "" {
|
||||
t.Fatal("a delayed resolve must still answer")
|
||||
}
|
||||
@@ -213,7 +213,7 @@ func TestEntityAttention_WithoutNexusSaysSo(t *testing.T) {
|
||||
))
|
||||
h := ecoHandler(t, nil, praxis, nil)
|
||||
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"))
|
||||
reply := h.handlePraxisAct(ctx, entityAttentionDec("muzick indexer"), routeCandidate("entity_attention"))
|
||||
if strings.Contains(reply, "disk almost full") {
|
||||
t.Fatalf("without nexus, items must not be passed off as entity-scoped, got %q", reply)
|
||||
}
|
||||
|
||||
@@ -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, `{}`),
|
||||
|
||||
+114
-20
@@ -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.
|
||||
@@ -70,15 +72,16 @@ func dialogueIDOf(ctx context.Context) string {
|
||||
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
|
||||
func toDialogueSlots(s router.Slots) dialogue.Slots {
|
||||
return dialogue.Slots{
|
||||
Time: s.Time,
|
||||
HasTime: s.HasTime,
|
||||
Key: s.Key,
|
||||
Value: s.Value,
|
||||
HasKey: s.HasKey,
|
||||
Text: s.Text,
|
||||
Fn: s.Fn,
|
||||
Args: s.Args,
|
||||
HasFn: s.HasFn,
|
||||
Time: s.Time,
|
||||
HasTime: s.HasTime,
|
||||
Key: s.Key,
|
||||
Value: s.Value,
|
||||
HasKey: s.HasKey,
|
||||
Text: s.Text,
|
||||
Fn: s.Fn,
|
||||
Args: s.Args,
|
||||
HasFn: s.HasFn,
|
||||
ResolvedBy: string(s.ResolvedBy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,20 +91,25 @@ func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
||||
base.Key, base.Value, base.HasKey = d.Key, d.Value, d.HasKey
|
||||
base.Text = d.Text
|
||||
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
|
||||
base.ResolvedBy = router.ActionResolutionMethod(d.ResolvedBy)
|
||||
return base
|
||||
}
|
||||
|
||||
// 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 +119,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 +194,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"), routeCandidate("resolve_item"))
|
||||
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"), routeCandidate("acknowledge_item"))
|
||||
|
||||
h.ecosystem = &ecosystemWiring{praxis: newPraxisClient(unreachableURL)}
|
||||
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"), routeCandidate("acknowledge_item"))
|
||||
|
||||
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"), routeCandidate("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), routeCandidate("acknowledge_item"))
|
||||
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"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "4"), routeCandidate("resolve_item"))
|
||||
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"), routeCandidate("acknowledge_item"))
|
||||
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"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("pin_item", "item_zz"), routeCandidate("pin_item"))
|
||||
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"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"), routeCandidate("acknowledge_item"))
|
||||
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"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "this"), routeCandidate("acknowledge_item"))
|
||||
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"), routeCandidate("list_attention"))
|
||||
|
||||
praxis.ResetRequests()
|
||||
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this"), routeCandidate("resolve_item")); 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"), routeCandidate("resolve_item")); 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, router.NormalizedInput{Text: utterance, Source: 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, router.NormalizedInput{Text: "отмени напоминание про врача", Source: 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, router.NormalizedInput{Text: "нет, это был вопрос", Source: 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, router.NormalizedInput{Text: "нет, это было напоминание", Source: 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, router.NormalizedInput{Text: "сегодня в 15:00", Source: 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,179 @@
|
||||
// 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),
|
||||
RouteProducer: rec.RouteProducer,
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user