Compare commits
535 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 0793955896 | |||
| 71a9a59403 | |||
| d3c63e6493 | |||
| c586346a60 | |||
| 23d89b2831 | |||
| 06ddf41228 | |||
| 2e64c8ce94 | |||
| 7695620a96 | |||
| 758fb6a3f0 | |||
| 0e75245205 | |||
| 8d816f47e9 | |||
| 4425ba112b | |||
| a4d5155029 | |||
| c62c7034fa | |||
| 58b546a27e | |||
| 997f92f5c4 | |||
| d9ef9ecef2 | |||
| be3e5cea25 | |||
| 1da3aa39e8 | |||
| bee3ef80b4 | |||
| fc9538d07f | |||
| bb8bd608da | |||
| db0752223f | |||
| 97fc786acf | |||
| 54f13466c9 | |||
| 3bbffa4f37 | |||
| 8b76dc50d3 | |||
| a1324e679f | |||
| 82ef1b0110 | |||
| 897dcf847a | |||
| 8b9e8e9f4e | |||
| b338d9bb40 | |||
| 6878e12d37 | |||
| 8d46ee39e0 | |||
| 6eba79b332 | |||
| 3f60ec3994 | |||
| 14ea06712e | |||
| 42d3feadd2 | |||
| 2a2f706b74 | |||
| e082e06868 | |||
| 88dc4e1383 | |||
| 60759a991e | |||
| 9537346441 | |||
| c6be818f13 | |||
| b5575a9402 | |||
| fcda5e3d2c | |||
| b1420acb94 | |||
| 70fb7c030b | |||
| fe489dff6d | |||
| d8da529be0 | |||
| 0258a40b0d | |||
| f6a8752d00 | |||
| 5b7480ddaf | |||
| 8aa790cc48 | |||
| 0ade0ec734 | |||
| d960e211d3 | |||
| 6fba4d6931 | |||
| 12c18dcf65 | |||
| a286865fe5 | |||
| 28c0ff73bd | |||
| bdafc82e35 | |||
| c69023c310 | |||
| 761cf9f3e0 | |||
| 6a9d8a4dd5 | |||
| 4c95b200e4 | |||
| 6ae1312ff1 | |||
| 765ed36340 | |||
| feca776077 | |||
| d79b30a1a6 | |||
| c5e993fc55 | |||
| a2081d8227 | |||
| 82bd160c0d | |||
| 1fe03f7a51 | |||
| d773f1f72b | |||
| 80ac7579fb | |||
| bb6cb6d185 | |||
| a9067a5754 | |||
| 787cc56522 | |||
| 86dcd99de2 | |||
| 554181ccbd | |||
| 58635f1a69 | |||
| fd3d063e02 | |||
| ed491e23fc | |||
| 246db4e609 | |||
| b6abb19090 | |||
| 1f7fd476ec | |||
| 08512ad58b | |||
| ff71d981ef | |||
| 4d83f8c785 | |||
| a439117995 | |||
| 2689715c2d | |||
| 05f47aef4b | |||
| 45a5e37963 | |||
| 6d8a95095a | |||
| 7e21cd06b3 | |||
| 09c648b934 | |||
| 4f516657da | |||
| 8a21478f36 | |||
| 958d2a2fc8 | |||
| 7db139b83e | |||
| 0987dabfc4 | |||
| 947506c7b8 | |||
| 6c67e61962 | |||
| 0990f32808 | |||
| d41878c2b1 | |||
| e023638135 | |||
| 0d52344d27 | |||
| 5bd303788b | |||
| afac8fb670 | |||
| 77206f298e | |||
| a1f811d4c8 | |||
| 7ab38cd7f7 | |||
| 6a85e71077 | |||
| bf6c2bf1a6 | |||
| 1558233665 | |||
| 5afa2dfb38 | |||
| 0f361d2034 | |||
| 19242ef73b | |||
| 26d6d71588 | |||
| df58710141 | |||
| 442d3ec08e | |||
| e481ad4930 | |||
| 3d8224fb04 | |||
| 990a4a99e9 | |||
| 5b622389c5 | |||
| a820a95ebb | |||
| ea9c746852 | |||
| d60a51c9e7 | |||
| 9aabb01e2a | |||
| 2815adee03 | |||
| 9f51596e2f | |||
| 87d176153a | |||
| 7d4b4ad736 | |||
| 1c8a32c3fd | |||
| 96b474223d | |||
| 42d7a39c49 | |||
| bad3fa4035 | |||
| d819fc09f0 | |||
| c35979d9f9 | |||
| f3c0540b42 | |||
| 5b4192acb5 | |||
| 16d94894b7 | |||
| ae8d38fc31 | |||
| b2521988e1 | |||
| dae123adac | |||
| 1c9ddbbea2 | |||
| 3f2782f5b7 | |||
| 865623ef3e | |||
| 4fdce3ca2c | |||
| c47881106e | |||
| 9a70f7378b | |||
| b18f608594 | |||
| d1f8a734c5 |
@@ -32,14 +32,18 @@ See `docs/rearchitecture.md` for the target architecture, `docs/design.md` for t
|
|||||||
GPU and the workstation has 16GB of VRAM. So the resident model, STT and TTS become preferred
|
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
|
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
|
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. Read `docs/offload.md` before
|
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
|
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||||||
are the work.
|
are the work.
|
||||||
|
|
||||||
Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently
|
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
|
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
|
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
|
free — `worldGap` in `cmd/mavend/worldmodel.go`, which the owner hears instead of an invented
|
||||||
answer. A box with no `workstation` block behaves exactly as it did before the seam: naming
|
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.
|
a gap requires a gap. The offload table in `docs/offload.md` says which caller is which.
|
||||||
|
|
||||||
@@ -49,21 +53,32 @@ CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored to
|
|||||||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make build # all 9 binaries
|
make build # all 11 binaries
|
||||||
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
|
||||||
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
make test # go test -race across ./internal/... ./cmd/... with CGO env set
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a single test (must carry the CGO env for packages that touch STT/TTS/voice):
|
Run one package or one test with `make t`. **Do not hand-write the CGO preamble.**
|
||||||
|
Past sessions pasted it about 390 times. That is where the shell-quoting failures
|
||||||
|
came from. This box runs zsh, so an unquoted `-run Test*` or `--include=*.go`
|
||||||
|
dies on "no matches found" before `go` is ever reached.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
CGO_CFLAGS="-I$(pwd)/deps/include -I$(pwd)/deps/whisper.cpp/ggml/include" \
|
make t PKG=./internal/router/
|
||||||
CGO_LDFLAGS="-L$(pwd)/deps/lib -Wl,-rpath,$(pwd)/deps/lib" \
|
make t PKG=./cmd/mavend/ RUN=TestSimulator
|
||||||
LD_LIBRARY_PATH="$(pwd)/deps/lib" \
|
make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # V=1 for -v, RACE=0 to drop -race
|
||||||
deps/go/go/bin/go test -run TestName ./internal/router/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test ./pkg/`.
|
`t` carries `-race`, so a green `make t` cannot turn red under `make test`. It carries
|
||||||
|
`-count=1`, so a cached PASS from before your edit is never mistaken for a result.
|
||||||
|
|
||||||
|
It also sets `MAVEN_ONNX_LIB`, which the hand-written recipe did not. The four
|
||||||
|
`TestONNX*` measurements self-skip when that variable is unset. The run still prints
|
||||||
|
`ok`. So every targeted eval done the old way reported the hash ratchet while reading
|
||||||
|
as a real embedder score.
|
||||||
|
|
||||||
|
Pure-Go packages (`router`, `memory`, `mavweb`, …) also run under a plain `go test ./pkg/`,
|
||||||
|
but `make t` works everywhere and is one thing to remember.
|
||||||
|
|
||||||
## The daemons (`cmd/`)
|
## The daemons (`cmd/`)
|
||||||
|
|
||||||
@@ -73,16 +88,56 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
|||||||
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
||||||
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
||||||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||||||
| `mavwaked` | Wake-word / VAD gate. |
|
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
|
||||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
|
||||||
| `mavpoll` | Telegram long-poll reach. |
|
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`, not this. |
|
||||||
| `mavcaldav` | CalDAV calendar sync. |
|
| `mavcaldav` | CalDAV calendar sync. |
|
||||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||||
|
| `mavgpud` | GPU supervisor. **Runs on workpc, not homesrv** — own unit, `deploy/mavgpud.service`. Keeps llama-server loaded while the card is free (V-488). Maven never asks it for anything, it reads `/health` through `llm.Pair`. |
|
||||||
|
| `mavupdate` | Not a daemon. Operator CLI a human runs on the box to deploy a new build. |
|
||||||
|
|
||||||
|
Two more binaries have no Makefile target and are built with `go run` or `go build` when
|
||||||
|
they are needed. Neither is deployed.
|
||||||
|
|
||||||
|
| Binary | Role |
|
||||||
|
|---|---|
|
||||||
|
| `mavseal` | Recovery tool. Encrypts a live tmpfs working copy back to the ciphertext file when mavend was killed before `defer st.Close()` sealed it. |
|
||||||
|
| `labelgen` | Runs the stage 0 grammars over utterances and prints JSONL, the training data for the routing heads (V-546). |
|
||||||
|
|
||||||
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
|
||||||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||||||
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
||||||
|
|
||||||
|
**`docker-compose.yml` runs five: `mavend`, `mavsttd`, `mavttsd`, `mavweb`, `mavpoll`.**
|
||||||
|
Count against compose, not against the table. Four of the nine daemons are absent, and each
|
||||||
|
absence has a different reason.
|
||||||
|
|
||||||
|
`mavmaild` and `mavcaldav` are commented out in compose, each with the reason written
|
||||||
|
beside it: the first needs a mail account, the second a CalDAV account, and this box has
|
||||||
|
neither. `mavcaldav` used to appear nowhere at all, which was an oversight; it became a
|
||||||
|
recorded decision on 07-08-2026 (V-644). Two things ride on that absence and the block
|
||||||
|
names them. Agenda questions route to `IntentQuery` at stage 0 (V-498) and the `calendar`
|
||||||
|
query source then reads a table nobody writes. And `loop.State.CalendarBusy` is fed by the
|
||||||
|
same facts, so the gate's "do not nag mid-meeting" is permanently false. Its password is
|
||||||
|
read from a file (`-pass-file`, and `-render-pass-file` for the render collection), never
|
||||||
|
taken as a flag value, which is the rule `mavpoll` and `mavmaild` follow too.
|
||||||
|
|
||||||
|
**`mavwaked` and `mavenclient` are absent by decision, not 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 the owner is standing.
|
||||||
|
|
||||||
|
**That machine is workpc** (owner's correction, 2026-08-05). This section used to say no
|
||||||
|
such machine existed, which was written when the workstation was only a model host. It is
|
||||||
|
where he sits most of the day and it has the microphone. `ipc.Dial` already takes
|
||||||
|
`tcp://host:port?token=...` through the netaddr seam, so the two daemons need deploying,
|
||||||
|
not building. V-515 is that deployment.
|
||||||
|
|
||||||
|
Until they are deployed, **the wake word and the VAD gate are covered by unit tests and by
|
||||||
|
nothing else**, and push-to-talk through `/dash` is what QA actually covers. Note that
|
||||||
|
deploying them does not by itself prove a wake word: `mavwaked` gates on energy and has no
|
||||||
|
keyword model (V-487), so the loop runs open until that lands.
|
||||||
|
|
||||||
## The ecosystem: Nexus, Praxis, Hexis
|
## The ecosystem: Nexus, Praxis, Hexis
|
||||||
|
|
||||||
Maven is one of four services. It owns conversation and personal memory. It does not
|
Maven is one of four services. It owns conversation and personal memory. It does not
|
||||||
@@ -142,6 +197,19 @@ on in deploy** — this section used to say it was wired `nil`, which stopped be
|
|||||||
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
|
||||||
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
|
||||||
|
|
||||||
|
**A stage-0 decision is slot-extracted too, since 06-08-2026** (V-572). `fillMatchedSlots`
|
||||||
|
in `router.go` runs the stage-2 extractor over whatever a grammar built and fills only the
|
||||||
|
slots it left empty — a matched value always wins, because the rule read a literal pattern
|
||||||
|
and the extractor guesses. It did not run before, so `ReminderGrammar` handed the daemon
|
||||||
|
`HasTime: false` for "напомни в 11:00 позвонить маме" and `missingFor` read the silence as
|
||||||
|
absence and asked "Когда?". It applies to every grammar and is inert for all but the
|
||||||
|
reminder: `Extract` fills Time, Fn and Key and nothing else, and the query, clock, agenda,
|
||||||
|
feed, list, task and narrative rules all emit intents with no such slot. Benchmarked at
|
||||||
|
20000x, a stage-0 query costs 3.7µs against 3.9µs before. **`Slots.Text` is deliberately not
|
||||||
|
filled** — a grammar that left it empty meant it, and `agendaQueryBuild` hands the query
|
||||||
|
chain the utterance itself. Fixture unchanged at 64/91, with "slots deferred to daemon"
|
||||||
|
6 → 0.
|
||||||
|
|
||||||
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
|
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
|
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
|
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
|
||||||
@@ -152,12 +220,32 @@ that stood here until 2026-08-02 was contention, not the model.** See `docs/eval
|
|||||||
p50 825ms / p95 1.2s / max 3.0s and the full cascade at p50 0.80-1.04s. Do not plan latency
|
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.
|
work off the bakeoff table.
|
||||||
|
|
||||||
|
**Re-measured 2026-08-05 on the fixture as it now stands, 91 cases** (V-320 item 2,
|
||||||
|
`docs/evals/2026-08-05-routing-resident-model.md`): cascade + resident model scores
|
||||||
|
**75.8% full / 80.2% intent-only at p50 1.19s / p95 1.65s**. That is a new baseline and not
|
||||||
|
a movement, because 14 cases were added since the 77-case number above. The model alone
|
||||||
|
scores 37.4% full against 61.5% intent-only, and the gap is slots rather than routing: it
|
||||||
|
routes `reminder` and leaves the time to the daemon, which is what the contract asks. To
|
||||||
|
re-run it, start a **second** llama-server on a fixed host port — the resident one binds
|
||||||
|
`--port 0` inside the container and no host process can reach it.
|
||||||
|
|
||||||
**The numbers above are the homesrv floor, not the ceiling.** With the workstation up, routing
|
**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
|
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`,
|
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
|
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.
|
routing change against the classifier and the resident model, since those are what always answer.
|
||||||
|
|
||||||
|
**The intended third engine is not a generative model** (owner's call, 05-08-2026, V-546,
|
||||||
|
`docs/plans/18-routing-heads-on-e5-small.md`). Routing has a bounded output space, so it is
|
||||||
|
classification, and the 118M multilingual-e5-small is already resident. Three heads on one
|
||||||
|
forward pass: intent, mood, and BIO slot tags. Roughly 5e15 FLOPs to train, so 10 to 30
|
||||||
|
minutes on the workstation. A 100M decoder from scratch is 10 to 20 GPU hours. Two things
|
||||||
|
it buys that a decoder cannot. No grammar is needed, because a softmax cannot emit a value
|
||||||
|
that does not exist. And max softmax is a calibratable confidence, where `Confidence: 1.0`
|
||||||
|
was a hardcode. **Fine-tune a copy of the weights.** The resident embedder backs memory
|
||||||
|
recall. Training it in place couples routing accuracy to recall@1, with nothing in the
|
||||||
|
suite to name the trade.
|
||||||
|
|
||||||
`Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
`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
|
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
|
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||||||
@@ -189,21 +277,134 @@ fixture had said `query` since ru-query-019 was written. Measured: **full accura
|
|||||||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
||||||
explicit `(\s|[?!.]|$)`.
|
explicit `(\s|[?!.]|$)`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Praxis taken off the model, 05-08-2026 (V-516). `PraxisGrammars()`
|
||||||
|
(`internal/router/praxis.go`, wired in `buildRouter` before the capture marker because
|
||||||
|
"отметь" is a capture verb) fills `Slots.Fn` with a Praxis capability name.
|
||||||
|
**These grammars are the only path to Praxis, not a faster one.** Measured
|
||||||
|
2026-08-05 with the resident model as router (V-517,
|
||||||
|
`docs/evals/2026-08-05-reach-llm-router.md`): the model alone reaches Praxis
|
||||||
|
**0/12**, the same as the classifier alone, because nothing in the router
|
||||||
|
prompt names a Praxis capability and there is no string for it to write.
|
||||||
|
Through the cascade it is 11/12. Deleting these rules costs every point. Praxis reach
|
||||||
|
was **0/12 and structurally so**: `handlePraxisAct` compares `Slots.Fn` to a capability
|
||||||
|
alias, and that slot is filled from the deployment's enabled tool names, which no Praxis
|
||||||
|
alias is on. Measured **16/30 → 27/30 overall, praxis 0/12 → 11/12, lifecycle 0/5 → 5/5**
|
||||||
|
(`docs/evals/2026-08-05-praxis-reach.md`). Two rules to know before editing: a **stative**
|
||||||
|
lifecycle word ("готово", "принято") needs an item named beside it, while a bare
|
||||||
|
**imperative** ("закрывай") may ask which one. The bare arm additionally requires that
|
||||||
|
the sentence name no object of its own, or "закрой шторы в комнате" goes to Praxis instead
|
||||||
|
of the house. A demonstrative ("отметь это как сделанное") resolves against
|
||||||
|
`h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to
|
||||||
|
the cascade rather than transitioning the wrong item.
|
||||||
|
|
||||||
|
**Who claimed a turn is now recorded, and so is who did not** (V-564, umbrella
|
||||||
|
V-558). Arbitration between the claimants on the utterance stream is order,
|
||||||
|
hardcoded in the pre-route resolver ladder, in `buildRouter` and in
|
||||||
|
`querySources`. `internal/decision` records one `Record` per turn: every
|
||||||
|
claimant, what it would have made the turn, the score it reported, and whether
|
||||||
|
it won, declined, lost on score, was thinned by a gate or was **never asked**.
|
||||||
|
The record rides the context, the same seam `querysource.go` uses, so a claim
|
||||||
|
site cannot change a route and a context with no record costs nothing. It is
|
||||||
|
installed in `runTurn`, so the mic, telegram and the web all leave the same
|
||||||
|
trail. Storage is a 25-turn in-memory ring on the handler (`decision.Ring`),
|
||||||
|
read over `ipc.TurnDecisions` and rendered as the second table on `/trace`.
|
||||||
|
**It also persists, since 06-08-2026, and that reverses what this section used to
|
||||||
|
say** (V-629, `docs/plans/21-persisting-the-routing-trace.md`). The old rule was
|
||||||
|
that nothing persists, because a turn record is read minutes later or never. The
|
||||||
|
owner reversed it: the routing heads (V-546) cannot be fitted or calibrated
|
||||||
|
without real utterances, and 9 of the 31 modes in `internal/modes` have no seed
|
||||||
|
example at all. The ring did not move. It is still what `/trace` reads and still
|
||||||
|
what a test with no store gets. `cmd/mavend/routingtrace.go` is a second sink
|
||||||
|
beside it, writing `routing_traces` (migration #23). The utterance is stored in
|
||||||
|
clear, because a 384-dimension vector of a short sentence is substantially
|
||||||
|
recoverable and storing vectors instead would be a privacy claim we cannot
|
||||||
|
support. What makes it safe is the same thing that makes the fact store safe.
|
||||||
|
Retention is 14 days, enforced on write and again on start, so a box that goes
|
||||||
|
quiet does not keep every row. Nothing reads it outward, and the rule
|
||||||
|
that his notes and facts are never search input covers this table. `Store.Wipe`
|
||||||
|
deletes it with everything else. A correction (V-630) is promoted out into a
|
||||||
|
seed-shaped row in `routing_labels` (migration #24) and kept, because a label is
|
||||||
|
not a transcript. The transcript still expires. The gesture that writes one is
|
||||||
|
two buttons beside the reply on `/chat`, reached over `ipc.CorrectTurn` and the
|
||||||
|
trace id that now rides back on `ipc.ChatReply`. A turn marked wrong with no
|
||||||
|
target is a usable negative, so naming the intent is never required. The target
|
||||||
|
is one of the seven intents and never free text. **All three reaches offer it as
|
||||||
|
of 06-08-2026**, and this section used to say only `/chat` did. Voice is the
|
||||||
|
`repair` rung, which has read spoken corrections since V-455 and now writes the
|
||||||
|
durable label beside the classifier seed it always wrote; a spoken negative with
|
||||||
|
no target is its own rung, `repair-negative` (V-636, `docs/plans/22-correcting-a-turn.md`).
|
||||||
|
Telegram is an inline keyboard under the reply, and it needed the chat to become
|
||||||
|
readable first — **telegram is no longer outbound only** (V-637,
|
||||||
|
`docs/plans/23-inbound-telegram.md`). The poller is dark unless the `telegram`
|
||||||
|
block says `intake`, it long-polls because the box takes no inbound connections,
|
||||||
|
it accepts `chat_id` and no other sender, and it drops whatever queued while the
|
||||||
|
daemon was down. It reaches the daemon through `ipc.CoreAPI` alone, so a chat
|
||||||
|
turn takes the path `POST /api/chat` takes. Note that the turn source is still
|
||||||
|
`tap:text` for both, so provenance cannot tell a chat turn from a typed one.
|
||||||
|
Adding a rung to the ladder
|
||||||
|
in `runTurn` means adding its name to `preRouteLadder` in
|
||||||
|
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
|
||||||
|
|
||||||
## LLM output contract
|
## LLM output contract
|
||||||
|
|
||||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when
|
||||||
`internal/phraser/llmphraser.go`), with fallback to plain text and the legacy
|
the model skips the JSON. **One parser, `parseResponseMood` in
|
||||||
`{"body","summary"}`. Mood is a fixed enum. Router prompt is a separate contract:
|
`internal/phraser/parse.go`**, and every path reaches it: the six `LLMPhraser` methods,
|
||||||
|
`PhraseWorld`, and `Replier.PhraseReply`, which `cmd/mavend/replier_llm.go` wraps — that file
|
||||||
|
holds the stub fallback and no parsing of its own. The legacy `{"body","summary"}` fallback
|
||||||
|
was deleted on 2026-08-06 (V-397): it was the contract before `{"response","mood"}` replaced
|
||||||
|
it, no prompt asks for that shape, the GBNF cannot emit it, and no test covered it.
|
||||||
|
Mood is a fixed enum. Router prompt is a separate contract:
|
||||||
`[{"intent":<enum>, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder,
|
`[{"intent":<enum>, key?, value?, text?, verb?}, ...]`, 7 intents (`fact, reminder,
|
||||||
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
note, query, act, chat, system`). `llm/check_prompt_parity.py` in the training
|
||||||
workspace enforces that the Go and relabelling prompts remain identical.
|
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)
|
## Non-goals (hard constraints)
|
||||||
|
|
||||||
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
Not a nag, not autonomous. Maven's persona is **feminine** — Russian
|
||||||
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
self-reference must use feminine forms — `рада`, not `рад`; `поняла`, not `понял`. The owner
|
||||||
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
is male and is addressed informally: "ты", singular, never "вы"/"ваш" and never "он"/"его"
|
||||||
(she talks TO him, not about him). Pet names ("милый", "дорогой") are forbidden; his name
|
(she talks TO the owner, not about the owner). Pet names ("милый", "дорогой") are forbidden; the name
|
||||||
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
("Ками") is not. The eval enforces this: `CheckAddress`, `CheckFeminine` and `CheckCringe` in
|
||||||
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
`internal/phraser/eval/checks.go`, scored by `make eval-phrasing`.
|
||||||
|
|
||||||
@@ -213,24 +414,44 @@ 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
|
- **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.
|
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,
|
- **The owner's data first, then the world.** Every source that reads the owner's facts, notes, calendar,
|
||||||
tasks or house runs before anything outside, and the personal boundary sits between them.
|
tasks or house runs before anything outside, and the personal boundary sits between them.
|
||||||
Reading beats recalling for a small model.
|
Reading beats recalling for a small model.
|
||||||
- **In the world, live search leads and the ZIMs are the fallback** (owner's call,
|
- **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
|
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.
|
homesrv answer when the search is empty, unreachable, or the line is down.
|
||||||
|
**Verified with the line down on 2026-08-05** (V-508,
|
||||||
|
`docs/evals/2026-08-05-kiwix-offline-fallback.md`): a stopped SearXNG costs nothing,
|
||||||
|
the ZIM answers in the same turn budget. A blackholed host cost 8 seconds the owner waited
|
||||||
|
through. So the connect phase alone is capped at `dialTimeout` (1.5s), while a slow
|
||||||
|
instance that did connect keeps the full 8. **A Russian question reads
|
||||||
|
`wikipedia_ru_all_maxi_2026-02` verbatim** through `kiwix.book_ru`. The RU→EN rewriter
|
||||||
|
is the workaround for an English book and is skipped there. Kiwix catalog names come
|
||||||
|
from the filename, not the `<name>` field.
|
||||||
|
`Response.Empty()` is the whole gate and there is no quality threshold in front of it:
|
||||||
|
the three signals one could read were measured on 2026-08-05 and none of them separate a
|
||||||
|
real question from an invented one. Token overlap would cost "столица Франции" its
|
||||||
|
answer, because the answer is Париж and that word is not in the question. See
|
||||||
|
`docs/evals/2026-08-05-search-quality-signals.md` (V-539). **Which query source claimed
|
||||||
|
a turn is readable on `/chat`** as a badge beside the reply, carried on
|
||||||
|
`ipc.ChatReply.Source` and noted by `noteQuerySource` in `cmd/mavend/querysource.go`. It
|
||||||
|
rides the context, so `handleText` keeps the one string signature the mic, telegram and
|
||||||
|
the web share.
|
||||||
- **External search is allowed and off unless configured**, like the weather and telegram
|
- **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`
|
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
|
block (owner's call, 2026-08-02), so it is on for this box and deleting the block turns
|
||||||
it off again.
|
it off again.
|
||||||
- **His notes and facts are never search input.** Looking up why the sky is blue and sending
|
- **The owner's notes and facts are never search input.** Looking up why the sky is blue and
|
||||||
his stored personal notes to an upstream engine are different acts. Only the utterance goes
|
sending the owner's stored personal notes to an upstream engine are different acts. Only the utterance goes
|
||||||
out, never the persona block, history, or matched notes.
|
out, never the persona block, history, or matched notes.
|
||||||
|
|
||||||
## Web UI conventions
|
## Web UI conventions
|
||||||
|
|
||||||
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the `nav`
|
Server-rendered pages share `cmd/mavweb/static/ui.css` (served at `/ui.css`) and the shell
|
||||||
partial (`navHTML` in `cmd/mavweb/main.go`, `{{template "nav" "<active-page>"}}`). No
|
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
|
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`.
|
data pans on a phone. Local preview + headless screenshot recipe is in `AGENTS.md`.
|
||||||
|
|
||||||
@@ -243,6 +464,19 @@ Vikunja is the durable task store. A task holds the goal, the constraints and th
|
|||||||
assumption ledger. Work without a task id is work nobody can resume, so a session that
|
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.
|
has no id asks for one before it starts.
|
||||||
|
|
||||||
|
The MCP tool schemas are deferred, so load the four you actually use in ONE call at the
|
||||||
|
start of a session rather than one lookup per first use:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Close a finished task with `done: true` and nothing else** (owner's call, 07-08-2026).
|
||||||
|
Do not write a completion summary into the description on the way out. It is lost anyway,
|
||||||
|
and the durable record is the commit messages and the merged PR. Note that `update_task`
|
||||||
|
carrying a `description` resets `done` to false, which is why a write-up ever took two
|
||||||
|
calls.
|
||||||
|
|
||||||
## Session workflow
|
## Session workflow
|
||||||
|
|
||||||
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
|
`~/.local/bin/task` owns the branch, the commit identity and the PR. One task, one
|
||||||
|
|||||||
+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
|
# 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
|
# zone and time.Now() stays UTC, and mavend answers clock/date queries and
|
||||||
# evaluates quiet-hours in UTC.
|
# 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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 tzdata \
|
ca-certificates libvulkan1 mesa-vulkan-drivers libgomp1 tzdata \
|
||||||
python3 python3-pip && \
|
python3 python3-pip && \
|
||||||
pip3 install --no-cache-dir --break-system-packages 'dateparser==1.4.1' && \
|
pip3 install --no-cache-dir --break-system-packages 'dateparser==1.4.1' && \
|
||||||
apt-get purge -y --auto-remove python3-pip && \
|
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/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# runtime native libs: whisper/ggml (incl. vulkan) are real files in deps/lib.
|
# runtime native libs: whisper/ggml (incl. vulkan) are real files in deps/lib.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
|||||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
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 eval-router eval-recall eval-phrasing eval-models build-gpud
|
.PHONY: t audit 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
|
||||||
|
|
||||||
all: build
|
all: build
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ run-web: build-web
|
|||||||
# base.Tool(), which only stats pkg/tool and exits. So build them in once here.
|
# 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_TARBALL := go$(GO_VERSION).linux-amd64.tar.gz
|
||||||
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
GO_SHA256 := 9e9b755d63b36acf30c12a9a3fc379243714c1c6d3dd72861da637f336ebb35b
|
||||||
deps-go:
|
deps-go: deps-sentinel
|
||||||
@mkdir -p deps/go
|
@mkdir -p deps/go
|
||||||
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
cd deps/go && curl -fLO 'https://go.dev/dl/$(GO_TARBALL)'
|
||||||
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
cd deps/go && echo '$(GO_SHA256) $(GO_TARBALL)' | sha256sum -c -
|
||||||
@@ -84,6 +84,25 @@ deps-go:
|
|||||||
done
|
done
|
||||||
$(GO) version
|
$(GO) version
|
||||||
|
|
||||||
|
# deps/go.mod — the sentinel that stops the module walk at deps/ (Vikunja #454).
|
||||||
|
# The vendored toolchain lives inside the module tree, so `go mod tidy` walked
|
||||||
|
# Go's own compiler-error fixtures and died on files that are malformed on
|
||||||
|
# purpose ("unicode//utf8": double slash). A nested module is not part of the
|
||||||
|
# parent, so one three-line file ends the walk. deps/ is gitignored, so it is
|
||||||
|
# generated here rather than committed, and every target that populates deps/
|
||||||
|
# writes it.
|
||||||
|
deps-sentinel:
|
||||||
|
@mkdir -p deps
|
||||||
|
@printf 'module github.com/kami/maven/deps\n\ngo 1.21\n' > deps/go.mod
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# a re-vendor or the next build fails on "inconsistent vendoring".
|
||||||
|
tidy: deps-sentinel
|
||||||
|
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod tidy
|
||||||
|
GOTOOLCHAIN=local GOFLAGS=-mod=mod $(GO) mod vendor
|
||||||
|
|
||||||
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
|
# fmt-check fails if any file needs gofmt. docs/design.md has always said `make
|
||||||
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
# test` gates on gofmt and vet; it did not, so nine files quietly drifted.
|
||||||
# Run `gofmt -w` on whatever this prints.
|
# Run `gofmt -w` on whatever this prints.
|
||||||
@@ -109,6 +128,35 @@ test: fmt-check vet
|
|||||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||||
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
$(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).
|
# 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
|
# 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
|
# prod-representative baseline at the vendored runtime; override it or set it
|
||||||
@@ -119,6 +167,14 @@ MAVEN_ONNX_LIB ?= $(shell pwd)/deps/onnxruntime-linux-x64-1.26.0/lib/libonnxrunt
|
|||||||
eval-router:
|
eval-router:
|
||||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 ./internal/router/eval/
|
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(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
|
||||||
|
# at Praxis or Hexis", which routing accuracy alone does not say. Same
|
||||||
|
# MAVEN_ONNX_LIB deal as eval-router; without it only the deterministic hash
|
||||||
|
# ratchet runs. Vikunja #405.
|
||||||
|
eval-reach:
|
||||||
|
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" $(GO) test -v -count=1 -run 'Reach|Praxis' ./internal/router/eval/
|
||||||
|
|
||||||
# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval).
|
# eval-recall — score the held-out note-recall fixture (internal/memory/recalleval).
|
||||||
# Answers "can she find the note again when it matters": recall@1, recall@3,
|
# Answers "can she find the note again when it matters": recall@1, recall@3,
|
||||||
# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as
|
# false recall and the query_min_score sweep. Same MAVEN_ONNX_LIB deal as
|
||||||
@@ -162,6 +218,16 @@ eval-models:
|
|||||||
# scores the fixtures against ggml-small and self-skips when the model is
|
# scores the fixtures against ggml-small and self-skips when the model is
|
||||||
# absent, and TestGoldenFixturesAreCanonical, which checks the committed audio
|
# absent, and TestGoldenFixturesAreCanonical, which checks the committed audio
|
||||||
# and the manifest with no model at all.
|
# 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:
|
stt-fixtures:
|
||||||
./scripts/gen-stt-fixtures.sh
|
./scripts/gen-stt-fixtures.sh
|
||||||
|
|
||||||
@@ -178,7 +244,7 @@ run-tts: build-tts
|
|||||||
./mavttsd -socket /tmp/maven/tts.sock \
|
./mavttsd -socket /tmp/maven/tts.sock \
|
||||||
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
-piper $(PIPER_BIN) -model $(PIPER_MODEL) -espeak_data $(PIPER_ESPEAK)
|
||||||
|
|
||||||
deps: deps-whisper deps-piper
|
deps: deps-sentinel deps-whisper deps-piper
|
||||||
|
|
||||||
deps-whisper:
|
deps-whisper:
|
||||||
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
cd deps/whisper.cpp && cmake -B build -DCMAKE_BUILD_TYPE=Release \
|
||||||
@@ -197,7 +263,7 @@ deps-piper:
|
|||||||
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
|
# multilingual-e5-small: an asymmetric retrieval model. It is trained to match
|
||||||
# a short question against a longer passage, which is what note recall is.
|
# a short question against a longer passage, which is what note recall is.
|
||||||
# The quantized file is the one we download, deploy and measure — see
|
# The quantized file is the one we download, deploy and measure — see
|
||||||
# docs/evals/2026-07-31-recall.md.
|
# docs/evals/2026-08-04-recall-e5-small.md for what the swap bought.
|
||||||
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
EMBEDDER_DIR := $(shell pwd)/models/embedder/multilingual-e5-small
|
||||||
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
EMBEDDER_MODEL_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/onnx/model_quantized.onnx
|
||||||
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
EMBEDDER_TOKENIZER_URL := https://huggingface.co/Xenova/multilingual-e5-small/resolve/main/tokenizer.json
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// 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 mirrors buildRouter's order in cmd/mavend/voicewire.go. Order is
|
||||||
|
// load-bearing there and so it is here: the agenda rules must sit after the
|
||||||
|
// clock rules, Praxis before the capture marker, the narrative rules last.
|
||||||
|
func grammars() []router.Grammar {
|
||||||
|
var g []router.Grammar
|
||||||
|
g = append(g, router.SystemTimeDateGrammars()...)
|
||||||
|
g = append(g, router.AgendaQueryGrammars()...)
|
||||||
|
g = append(g, router.FeedQueryGrammar())
|
||||||
|
g = append(g, router.TaskListGrammar())
|
||||||
|
g = append(g, router.ListGrammars()...)
|
||||||
|
g = append(g, router.ReminderGrammar())
|
||||||
|
g = append(g, router.PraxisGrammars()...)
|
||||||
|
g = append(g, router.TaskCaptureGrammar())
|
||||||
|
g = append(g, router.NarrativeQueryGrammars()...)
|
||||||
|
return g
|
||||||
|
}
|
||||||
|
|
||||||
|
func match(gs []router.Grammar, utterance string) label {
|
||||||
|
out := label{Utterance: utterance}
|
||||||
|
for _, g := range gs {
|
||||||
|
m := g.Pattern.FindStringSubmatch(utterance)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
d, ok := g.Build(m)
|
||||||
|
if !ok {
|
||||||
|
continue // the rule saw its shape and declined it
|
||||||
|
}
|
||||||
|
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)")
|
socket := fs.String("socket", "", "core IPC socket path (required)")
|
||||||
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||||
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
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")
|
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)")
|
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")
|
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
|
||||||
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||||
@@ -63,13 +63,22 @@ func run(args []string) error {
|
|||||||
if *socket == "" {
|
if *socket == "" {
|
||||||
return fmt.Errorf("-socket is required")
|
return fmt.Errorf("-socket is required")
|
||||||
}
|
}
|
||||||
if *url == "" || *user == "" || *pass == "" {
|
if *url == "" || *user == "" || *passFile == "" {
|
||||||
return fmt.Errorf("-url, -user, -pass are required")
|
return fmt.Errorf("-url, -user, -pass-file are required")
|
||||||
}
|
}
|
||||||
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
|
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
|
||||||
return err
|
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)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
@@ -85,17 +94,20 @@ func run(args []string) error {
|
|||||||
http: hc,
|
http: hc,
|
||||||
url: strings.TrimRight(*url, "/"),
|
url: strings.TrimRight(*url, "/"),
|
||||||
user: *user,
|
user: *user,
|
||||||
pass: *pass,
|
pass: pass,
|
||||||
}
|
}
|
||||||
|
|
||||||
var rend *renderer
|
var rend *renderer
|
||||||
if *renderURL != "" {
|
if *renderURL != "" {
|
||||||
ru, rp := *renderUser, *renderPass
|
ru, rp := *renderUser, pass
|
||||||
if ru == "" {
|
if ru == "" {
|
||||||
ru = *user
|
ru = *user
|
||||||
}
|
}
|
||||||
if rp == "" {
|
if *renderPassFile != "" {
|
||||||
rp = *pass
|
rp, err = readSecret(*renderPassFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
||||||
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
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
|
// 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
|
// comment is about every calendar maven reads, and a second read target added
|
||||||
// later must not quietly fall outside the check.
|
// 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 {
|
func checkRenderTarget(readURLs []string, renderURL string) error {
|
||||||
if renderURL == "" {
|
if renderURL == "" {
|
||||||
return nil
|
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.
|
// 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) {
|
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||||
@@ -203,7 +235,7 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
|
|||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,38 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"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 {
|
type fakeCore struct {
|
||||||
ipc.UnimplementedCoreAPI
|
ipc.UnimplementedCoreAPI
|
||||||
facts map[string]ipc.Fact // composite key "key|source" → Fact
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,15 +31,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/kami/maven/internal/audio"
|
"github.com/kami/maven/internal/audio"
|
||||||
"github.com/kami/maven/internal/voice"
|
"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
|
// jsonUnmarshal — kept local rather than pulling encoding/json into main.go
|
||||||
// top-level space.
|
// top-level space.
|
||||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
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
|
|
||||||
|
|||||||
+3
-2
@@ -16,6 +16,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
|
|||||||
}
|
}
|
||||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
||||||
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||||
return "не получилось отметить.", true
|
return phraser.Ack(phraser.FailAck, nil), true
|
||||||
}
|
}
|
||||||
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||||
return "отлично, отметила.", true
|
return phraser.Ack(phraser.AckNudge, nil), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||||
|
|||||||
+10
-2
@@ -40,6 +40,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,11 +58,18 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r
|
|||||||
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||||
// Conversational: build history from dialogue session (prior user turns)
|
// Conversational: build history from dialogue session (prior user turns)
|
||||||
// and let the LLM respond from general knowledge + context.
|
// and let the LLM respond from general knowledge + context.
|
||||||
history := h.chatHistory()
|
if h.phraser == nil {
|
||||||
|
return "поговорили."
|
||||||
|
}
|
||||||
|
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)
|
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: chat: %v", err)
|
log.Printf("voice: chat: %v", err)
|
||||||
return "поговорили."
|
}
|
||||||
|
if reply == "" {
|
||||||
|
return phraser.ChatFallback()
|
||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/mcp"
|
"github.com/kami/maven/internal/mcp"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
)
|
)
|
||||||
@@ -23,6 +24,14 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The board is Maven's own store, so a spoken status change is answered here
|
||||||
|
// and never offered to an ecosystem client (Vikunja #512). First, because
|
||||||
|
// task_status is on no allowlist and no capability registry: reaching either
|
||||||
|
// of them would answer a turn about his own task list with a gap.
|
||||||
|
if dec.Slots.Fn == router.TaskStatusFn {
|
||||||
|
return h.resolveTaskStatus(ctx, dec)
|
||||||
|
}
|
||||||
|
|
||||||
// Praxis ecosystem tools: intercept before the system command executor.
|
// Praxis ecosystem tools: intercept before the system command executor.
|
||||||
if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn {
|
if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn {
|
||||||
if reply := h.handlePraxisAct(ctx, dec); reply != "" {
|
if reply := h.handlePraxisAct(ctx, dec); reply != "" {
|
||||||
@@ -50,31 +59,48 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
|||||||
// destructive: park it and ask. The next utterance answers.
|
// destructive: park it and ask. The next utterance answers.
|
||||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||||
return "выполнить «" + 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,
|
||||||
|
// the fuzzy allowlist match — is a guess, and a spoken "да" checks
|
||||||
|
// none of it. She names the gap instead.
|
||||||
|
return phraser.A(phraser.ActNeedsAuthedSurface, nil)
|
||||||
case errors.Is(err, tool.ErrNotEnabled):
|
case errors.Is(err, tool.ErrNotEnabled):
|
||||||
return h.proposeGap(ctx, dec)
|
return h.proposeGap(ctx, dec)
|
||||||
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
case errors.Is(err, tool.ErrNotConnected), errors.Is(err, mcp.ErrNotConnected), errors.Is(err, mcp.ErrNoServer):
|
||||||
// The row is enabled and the backend is gone. Drafting a proposal
|
// The row is enabled and the backend is gone. Drafting a proposal
|
||||||
// for it (the ErrNotEnabled path) would be answering the wrong
|
// for it (the ErrNotEnabled path) would be answering the wrong
|
||||||
// question.
|
// question.
|
||||||
return "этот инструмент включён, но сервер, который его выполняет, сейчас не подключён."
|
return phraser.A(phraser.ActServerDown, nil)
|
||||||
case errors.Is(err, mcp.ErrToolGone):
|
case errors.Is(err, mcp.ErrToolGone):
|
||||||
return "сервер больше не предлагает этот инструмент — я сняла его с разрешённых, посмотри на /tools."
|
return phraser.A(phraser.ActWithdrawn, nil)
|
||||||
case errors.Is(err, mcp.ErrNeedsArgs):
|
case errors.Is(err, mcp.ErrNeedsArgs):
|
||||||
// An MCP tool that wants named arguments a spoken verb cannot
|
// An MCP tool that wants named arguments a spoken verb cannot
|
||||||
// supply. Guessing them would be a wrong act, so she says so
|
// supply. Guessing them would be a wrong act, so she says so
|
||||||
// instead — the tool is still runnable from the authed surface,
|
// instead — the tool is still runnable from the authed surface,
|
||||||
// where a human types them.
|
// where a human types them.
|
||||||
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
|
return phraser.A(phraser.ActNeedsArgs, nil)
|
||||||
}
|
}
|
||||||
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
|
||||||
if out != "" {
|
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 != "" {
|
if out != "" {
|
||||||
return "готово: " + firstLine(out)
|
return phraser.A(phraser.ActDoneOut, map[string]string{"out": firstLine(out)})
|
||||||
}
|
}
|
||||||
return "готово."
|
return phraser.A(phraser.ActDone, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The act path speaks each tier (Vikunja #449): a safe row runs, a destructive
|
||||||
|
// one costs a confirm turn, an irreversible one is refused with the reason.
|
||||||
|
func TestActPathSpeaksTheTiers(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := h.now()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
cmd []string
|
||||||
|
destructive bool
|
||||||
|
}{
|
||||||
|
{"status", []string{"true"}, false},
|
||||||
|
{"restart", []string{"true"}, true},
|
||||||
|
{"wipe", []string{"rm", "-rf"}, true},
|
||||||
|
} {
|
||||||
|
if _, err := st.ProposeTool(ctx, tc.name, "test", "homelab", now); err != nil {
|
||||||
|
t.Fatalf("propose %s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if err := st.EnableTool(ctx, tc.name, tc.cmd, tc.destructive, "homelab", now); err != nil {
|
||||||
|
t.Fatalf("enable %s: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
act := func(fn string) string {
|
||||||
|
return h.actionAct(ctx, router.Decision{
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Utterance: fn,
|
||||||
|
Slots: router.Slots{Fn: fn, HasFn: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if reply := act("status"); !strings.HasPrefix(reply, "готово") {
|
||||||
|
t.Errorf("safe act replied %q; want it to have run", reply)
|
||||||
|
}
|
||||||
|
// PR 112's review cut «скажи «да» или «нет».» — he knows how to answer a
|
||||||
|
// yes/no question — so the confirm turn is recognised by the question.
|
||||||
|
if reply := act("restart"); !strings.Contains(reply, "да или нет") {
|
||||||
|
t.Errorf("destructive act replied %q; want a confirm turn", reply)
|
||||||
|
}
|
||||||
|
// Clear the confirm the destructive act parked, so what is pending after
|
||||||
|
// the irreversible one is only what the irreversible one parked.
|
||||||
|
h.mu.Lock()
|
||||||
|
h.pending = nil
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
reply := act("wipe")
|
||||||
|
if strings.Contains(reply, "да или нет") {
|
||||||
|
t.Fatalf("irreversible act asked for a confirm: %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не вернуть") {
|
||||||
|
t.Errorf("irreversible act replied %q; want it to name the reason", reply)
|
||||||
|
}
|
||||||
|
// Nothing was parked, so a later "да" cannot pick it up.
|
||||||
|
h.mu.Lock()
|
||||||
|
pending := h.pending
|
||||||
|
h.mu.Unlock()
|
||||||
|
if pending != nil {
|
||||||
|
t.Errorf("an irreversible act parked %+v", pending)
|
||||||
|
}
|
||||||
|
// And it is still an enabled row — refusing to run it from voice is not
|
||||||
|
// the same as taking it off the allowlist.
|
||||||
|
if got, err := st.LookupTool(ctx, "wipe"); err != nil || got.Status != "enabled" {
|
||||||
|
t.Errorf("wipe is %+v, %v; want it still enabled", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"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/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -14,7 +16,7 @@ import (
|
|||||||
// it for recall, and let pattern detection propose a routine.
|
// it for recall, and let pattern detection propose a routine.
|
||||||
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
||||||
if !dec.Slots.HasKey {
|
if !dec.Slots.HasKey {
|
||||||
return "не разобрала, что записать — попробуй иначе."
|
return phraser.Ack(phraser.FailFactUnparsed, nil)
|
||||||
}
|
}
|
||||||
// A question is never a fact about him (#470). "какая последняя версия
|
// A question is never a fact about him (#470). "какая последняя версия
|
||||||
// языка Go?" used to land here, and the value stored was whatever the
|
// языка Go?" used to land here, and the value stored was whatever the
|
||||||
@@ -38,6 +40,21 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
q.Slots.Value = ""
|
q.Slots.Value = ""
|
||||||
return h.actionQuery(ctx, q)
|
return h.actionQuery(ctx, q)
|
||||||
}
|
}
|
||||||
|
// A complaint is not a fact either (#481). "сеть какая-то медленная" and
|
||||||
|
// "интернет не работает" were stored as `self` rows at confidence 1.00, and
|
||||||
|
// recall reads a self row back later as if it were still true — the same
|
||||||
|
// class of row that outranked live search in #470. The sentence describes a
|
||||||
|
// moment, so she answers it and stores nothing. An explicit "запомни ..."
|
||||||
|
// and anything about him are both left alone by the test.
|
||||||
|
if router.IsTransientComplaint(dec.Utterance) {
|
||||||
|
log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat",
|
||||||
|
dec.Utterance, dec.Slots.Key)
|
||||||
|
c := dec
|
||||||
|
c.Intent = router.IntentChat
|
||||||
|
c.Slots.Key, c.Slots.HasKey = "", false
|
||||||
|
c.Slots.Value = ""
|
||||||
|
return h.actionChat(ctx, c)
|
||||||
|
}
|
||||||
now := h.now()
|
now := h.now()
|
||||||
req := ipc.WriteFactReq{
|
req := ipc.WriteFactReq{
|
||||||
Ts: now,
|
Ts: now,
|
||||||
@@ -62,7 +79,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
factID, err := h.api.WriteFact(ctx, req)
|
factID, err := h.api.WriteFact(ctx, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: write fact: %v", err)
|
log.Printf("voice: write fact: %v", err)
|
||||||
return "не получилось сохранить факт."
|
return phraser.Ack(phraser.FailFact, nil)
|
||||||
}
|
}
|
||||||
// Index the fact in long-term memory (best-effort, must not fail the fact
|
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||||
// write). Facts aren't in the notes table, so this is the only recall path
|
// write). Facts aren't in the notes table, so this is the only recall path
|
||||||
@@ -73,11 +90,19 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
// hears; storing the utterance meant recall answered with his own sentence
|
// hears; storing the utterance meant recall answered with his own sentence
|
||||||
// rather than the value. The utterance stays alongside as provenance —
|
// rather than the value. The utterance stays alongside as provenance —
|
||||||
// readable on /trace, never the answer and never embedded.
|
// readable on /trace, never the answer and never embedded.
|
||||||
if h.memStore != nil {
|
//
|
||||||
|
// 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)
|
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||||
if vec, err := router.EmbedPassage(ctx, h.embedder, text); err != nil {
|
if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil {
|
||||||
log.Printf("voice: embed fact for memory: %v", err)
|
log.Printf("voice: embed fact for memory: %v", err)
|
||||||
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
} else if err := h.recall.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "fact",
|
"type": "fact",
|
||||||
"text": text,
|
"text": text,
|
||||||
@@ -98,3 +123,29 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
|||||||
}
|
}
|
||||||
return "" // replier phrases the success reply
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Standing lists on the voice path (Vikunja #453).
|
||||||
|
//
|
||||||
|
// Three halves, mirroring what task capture already does: an add that runs at
|
||||||
|
// the top of actionNote, a read-back query source, and a crossing-off that runs
|
||||||
|
// on the same note path because "всё купил" is note-shaped.
|
||||||
|
//
|
||||||
|
// These read h.dataStore rather than the CoreAPI. A list is local to the core
|
||||||
|
// and nothing outside it writes one: the web UI has no list page, no reach
|
||||||
|
// files groceries, and the digestion worker does not read the table. When
|
||||||
|
// something outside mavend needs to add to a list, the ipc seam is what it
|
||||||
|
// grows through — the intake rules that CaptureTaskReq documents are about
|
||||||
|
// shared intake, and there is none here yet.
|
||||||
|
//
|
||||||
|
// Nothing here speaks unprompted. A list is answered when asked about.
|
||||||
|
|
||||||
|
// captureListFromNote claims the turn when the utterance adds to, clears, or
|
||||||
|
// crosses one item off a list. ("", false) hands the turn back to the note path.
|
||||||
|
func (h *reactiveHandler) captureListFromNote(ctx context.Context, dec router.Decision) (string, bool) {
|
||||||
|
if h.dataStore == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// Clearing is read before removing on purpose: "всё купил" and "купил
|
||||||
|
// молоко" start with the same word, and only the second one names an item.
|
||||||
|
if list, ok := router.ParseListClear(dec.Utterance); ok {
|
||||||
|
n, err := h.dataStore.ClearList(ctx, list, h.now())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: clear list: %v", err)
|
||||||
|
return "не получилось обновить список.", true
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return "в списке и так ничего не было.", true
|
||||||
|
}
|
||||||
|
return "вычеркнула всё, список пустой.", true
|
||||||
|
}
|
||||||
|
if cap, ok := router.ParseListRemove(dec.Utterance); ok {
|
||||||
|
if reply, ok := h.removeListItem(ctx, cap); ok {
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
// Nothing on the list by that name. "купил новый ноутбук" is a note and
|
||||||
|
// must stay one, so the turn goes back rather than claiming a removal
|
||||||
|
// that removed nothing.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
cap, ok := router.ParseListCapture(dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
res, err := h.dataStore.AddListItem(ctx, store.ListItem{
|
||||||
|
List: cap.List,
|
||||||
|
Item: cap.Item,
|
||||||
|
Source: "tap:voice",
|
||||||
|
CreatedTs: h.now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: add list item: %v", err)
|
||||||
|
return "не получилось добавить в список.", true
|
||||||
|
}
|
||||||
|
if !res.Created {
|
||||||
|
return cap.Item + " уже в списке.", true
|
||||||
|
}
|
||||||
|
return "добавила в список: " + cap.Item + ".", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeListItem crosses one named item off. It reports false when the list
|
||||||
|
// holds nothing by that name, which is what keeps the marker words from
|
||||||
|
// swallowing ordinary notes.
|
||||||
|
func (h *reactiveHandler) removeListItem(ctx context.Context, cap router.ListCapture) (string, bool) {
|
||||||
|
items, err := h.dataStore.ListItems(ctx, cap.List, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: list items: %v", err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
want := store.NormalizeTaskText(cap.Item)
|
||||||
|
for _, li := range items {
|
||||||
|
if store.NormalizeTaskText(li.Item) != want {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.dataStore.SetListItemStatus(ctx, li.ID, store.ListItemDone, h.now()); err != nil {
|
||||||
|
log.Printf("voice: cross off list item: %v", err)
|
||||||
|
return "не получилось обновить список.", true
|
||||||
|
}
|
||||||
|
return "вычеркнула: " + li.Item + ".", true
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// passes it on. It is before the recall sources for the reason every specific
|
||||||
|
// 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) {
|
||||||
|
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)
|
||||||
|
return "не получилось посмотреть список.", true
|
||||||
|
}
|
||||||
|
return formatListRU(list, items), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatListRU reads a list aloud. One sentence, comma-separated, because a
|
||||||
|
// shopping list is heard in a shop and a numbered recital is unusable there.
|
||||||
|
func formatListRU(list string, items []store.ListItem) string {
|
||||||
|
name := "списке " + listGenitive(list)
|
||||||
|
if len(items) == 0 {
|
||||||
|
return "в " + name + " пусто."
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(items))
|
||||||
|
for _, li := range items {
|
||||||
|
names = append(names, li.Item)
|
||||||
|
}
|
||||||
|
return "в " + name + ": " + strings.Join(names, ", ") + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
// listGenitive puts a list tag into the case "список <…>" needs. Russian
|
||||||
|
// declines the noun and she must not say "в списке покупки".
|
||||||
|
func listGenitive(list string) string {
|
||||||
|
switch list {
|
||||||
|
case "покупки":
|
||||||
|
return "покупок"
|
||||||
|
case "аптека":
|
||||||
|
return "аптеки"
|
||||||
|
case "хозяйство":
|
||||||
|
return "хозяйства"
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func listNow() time.Time { return time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC) }
|
||||||
|
|
||||||
|
func listHandler(t *testing.T) *reactiveHandler {
|
||||||
|
t.Helper()
|
||||||
|
return &reactiveHandler{dataStore: newTestStore(t), now: listNow}
|
||||||
|
}
|
||||||
|
|
||||||
|
func askList(t *testing.T, h *reactiveHandler, utterance string) (string, bool) {
|
||||||
|
t.Helper()
|
||||||
|
return h.captureListFromNote(context.Background(), router.Decision{
|
||||||
|
Intent: router.IntentNote, Utterance: utterance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCaptureAddsAndReadsBack(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
for _, u := range []string{"добавь в список покупок молоко", "добавь в список хлеб"} {
|
||||||
|
if reply, ok := askList(t, h, u); !ok {
|
||||||
|
t.Fatalf("%q was not claimed (reply %q)", u, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reply, ok := askList(t, h, "добавь в список покупок молоко"); !ok || !strings.Contains(reply, "уже") {
|
||||||
|
t.Errorf("second молоко replied %q, %v; want an already-there answer", reply, ok)
|
||||||
|
}
|
||||||
|
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что в списке покупок?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the list question was not claimed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(answer, "молоко") || !strings.Contains(answer, "хлеб") {
|
||||||
|
t.Errorf("answer %q; want both items", answer)
|
||||||
|
}
|
||||||
|
if strings.Contains(answer, "списке покупки") {
|
||||||
|
t.Errorf("answer %q declines the list name wrong", answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An utterance with no list marker is a note and must stay one, whichever half
|
||||||
|
// of the parser it brushes against.
|
||||||
|
func TestListCapturePassesOrdinaryNotes(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
for _, u := range []string{
|
||||||
|
"молоко закончилось",
|
||||||
|
"надо бы съездить в магазин",
|
||||||
|
"купил новый ноутбук",
|
||||||
|
"добавь в список покупок",
|
||||||
|
} {
|
||||||
|
if reply, ok := askList(t, h, u); ok {
|
||||||
|
t.Errorf("%q was claimed as a list turn: %q", u, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListCrossOffOneItemAndThenAll(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
for _, u := range []string{
|
||||||
|
"добавь в список покупок молоко",
|
||||||
|
"добавь в список покупок хлеб",
|
||||||
|
"добавь в список аптеки бинт",
|
||||||
|
} {
|
||||||
|
if _, ok := askList(t, h, u); !ok {
|
||||||
|
t.Fatalf("%q was not claimed", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reply, ok := askList(t, h, "вычеркни молоко")
|
||||||
|
if !ok || !strings.Contains(reply, "молоко") {
|
||||||
|
t.Fatalf("cross off replied %q, %v", reply, ok)
|
||||||
|
}
|
||||||
|
open, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(open) != 1 || open[0].Item != "хлеб" {
|
||||||
|
t.Fatalf("open list %+v; want only хлеб", open)
|
||||||
|
}
|
||||||
|
if reply, ok := askList(t, h, "всё купил"); !ok || !strings.Contains(reply, "пустой") {
|
||||||
|
t.Errorf("clear replied %q, %v", reply, ok)
|
||||||
|
}
|
||||||
|
open, err = h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(open) != 0 {
|
||||||
|
t.Errorf("%d items still open after всё купил", len(open))
|
||||||
|
}
|
||||||
|
// The other list is untouched, and it is read back on its own.
|
||||||
|
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "покажи список аптеки"},
|
||||||
|
})
|
||||||
|
if !ok || !strings.Contains(answer, "бинт") {
|
||||||
|
t.Errorf("аптека answer %q, %v; want бинт", answer, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueryListSaysWhenItIsEmpty(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
answer, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что мне купить?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the list question was not claimed")
|
||||||
|
}
|
||||||
|
if !strings.Contains(answer, "пусто") {
|
||||||
|
t.Errorf("empty answer %q; want it to say so", answer)
|
||||||
|
}
|
||||||
|
if _, ok := h.queryList(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "какие у меня задачи?"},
|
||||||
|
}); ok {
|
||||||
|
t.Error("the list source claimed a task question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 0 answers a list turn without the model: the grammars route it, and the
|
||||||
|
// action handlers re-parse what the grammar matched.
|
||||||
|
func TestListGrammarsRouteWithoutTheModel(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
want router.Intent
|
||||||
|
}{
|
||||||
|
{"добавь в список покупок молоко", router.IntentNote},
|
||||||
|
{"что в списке покупок?", router.IntentQuery},
|
||||||
|
{"всё купил", router.IntentNote},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
var got router.Intent
|
||||||
|
claimed := false
|
||||||
|
for _, g := range router.ListGrammars() {
|
||||||
|
m := g.Pattern.FindStringSubmatch(c.utterance)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if dec, ok := g.Build(m); ok {
|
||||||
|
got, claimed = dec.Intent, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
t.Errorf("no list grammar claimed %q", c.utterance)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("%q routed to %v; want %v", c.utterance, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range router.ListGrammars() {
|
||||||
|
m := g.Pattern.FindStringSubmatch("напомни купить молоко завтра")
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := g.Build(m); ok {
|
||||||
|
t.Errorf("grammar %s claimed a reminder", g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListStoreSourceIsVoice(t *testing.T) {
|
||||||
|
h := listHandler(t)
|
||||||
|
if _, ok := askList(t, h, "добавь в список покупок молоко"); !ok {
|
||||||
|
t.Fatal("not claimed")
|
||||||
|
}
|
||||||
|
items, err := h.dataStore.ListItems(context.Background(), "покупки", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].Source != "tap:voice" {
|
||||||
|
t.Errorf("stored %+v; want one row from tap:voice", items)
|
||||||
|
}
|
||||||
|
if items[0].Status != store.ListItemOpen {
|
||||||
|
t.Errorf("status %q; want open", items[0].Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,36 +5,58 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"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
|
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||||
// index it for recall.
|
// index it for recall.
|
||||||
|
//
|
||||||
|
// The stored body is dec.Utterance and nothing else (V-576). It is not
|
||||||
|
// Slots.Text, not phraser output and not 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 spoken confirmation.
|
||||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
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
|
// An utterance that explicitly files a task is work, not recall, and
|
||||||
// belongs in the task store (Vikunja #130). Checked before the embedding
|
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||||
// is paid for. Everything else is a note, exactly as before.
|
// is paid for. Everything else is a note, exactly as before.
|
||||||
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
if reply, ok := h.captureTaskFromNote(ctx, dec); ok {
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
// A standing list is neither work nor recall (Vikunja #453). Checked here
|
||||||
|
// for the same reason and at the same cost: before the embedding is paid
|
||||||
|
// for, and it passes the turn straight back when no marker matches.
|
||||||
|
if reply, ok := h.captureListFromNote(ctx, dec); ok {
|
||||||
|
return reply
|
||||||
|
}
|
||||||
// embed the note text with the same model the classifier uses, persist
|
// embed the note text with the same model the classifier uses, persist
|
||||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||||
// facts — no predicate reads it (spec's two-memory split).
|
// facts — no predicate reads it (spec's two-memory split).
|
||||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: embed note: %v", err)
|
log.Printf("voice: embed note: %v", err)
|
||||||
return "не получилось сохранить заметку."
|
return phraser.Ack(phraser.FailNote, nil)
|
||||||
}
|
}
|
||||||
noteTs := h.now()
|
noteTs := h.now()
|
||||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: write note: %v", err)
|
log.Printf("voice: write note: %v", err)
|
||||||
return "не получилось сохранить заметку."
|
return phraser.Ack(phraser.FailNote, nil)
|
||||||
}
|
}
|
||||||
// Insert into long-term memory (best-effort, must not fail the note write).
|
// Insert into long-term memory (best-effort, must not fail the note write).
|
||||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||||
if h.memStore != nil {
|
if h.recall.memStore != nil {
|
||||||
if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "note",
|
"type": "note",
|
||||||
"text": dec.Utterance,
|
"text": dec.Utterance,
|
||||||
|
|||||||
+293
-81
@@ -8,8 +8,10 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/crawl"
|
"github.com/kami/maven/internal/crawl"
|
||||||
|
"github.com/kami/maven/internal/decision"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
@@ -81,11 +83,27 @@ var querySources = []querySource{
|
|||||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||||
// date-bearing question still reaches the calendar.
|
// date-bearing question still reaches the calendar.
|
||||||
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
||||||
|
// 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},
|
||||||
|
// 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},
|
||||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||||
// the money facts the poller wrote, and the notes pass would otherwise
|
// 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
|
// answer it from whatever he once said about spending. Its matcher needs a
|
||||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||||
|
// 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},
|
||||||
// Before the recall sources and before general knowledge: "что нового?" is
|
// Before the recall sources and before general knowledge: "что нового?" is
|
||||||
// a question about the feeds she reads, and general knowledge would answer
|
// 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
|
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||||
@@ -105,6 +123,12 @@ var querySources = []querySource{
|
|||||||
{name: "network", answer: (*reactiveHandler).queryNetwork},
|
{name: "network", answer: (*reactiveHandler).queryNetwork},
|
||||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
||||||
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
||||||
|
// 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},
|
||||||
{name: "embed", answer: (*reactiveHandler).queryEmbed},
|
{name: "embed", answer: (*reactiveHandler).queryEmbed},
|
||||||
{name: "memory", answer: (*reactiveHandler).queryMemory},
|
{name: "memory", answer: (*reactiveHandler).queryMemory},
|
||||||
{name: "notes", answer: (*reactiveHandler).queryNotes},
|
{name: "notes", answer: (*reactiveHandler).queryNotes},
|
||||||
@@ -135,21 +159,47 @@ var querySources = []querySource{
|
|||||||
|
|
||||||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||||||
t := &queryTurn{dec: dec}
|
t := &queryTurn{dec: dec}
|
||||||
|
// 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)
|
||||||
for _, src := range querySources {
|
for _, src := range querySources {
|
||||||
if dec.Continued && !src.dateAware {
|
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
|
continue
|
||||||
}
|
}
|
||||||
if reply, ok := src.answer(h, ctx, t); ok {
|
if reply, ok := src.answer(h, ctx, t); ok {
|
||||||
|
// Which source claimed is the one thing about a query turn that was
|
||||||
|
// invisible from outside: /trace is the nudge-rule trace and carries
|
||||||
|
// 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. 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
|
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 {
|
if dec.Continued {
|
||||||
// The previous question cannot be re-asked for another day. Saying so
|
// The previous question cannot be re-asked for another day. Saying so
|
||||||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||||||
// truth is that she never looked.
|
// truth is that she never looked.
|
||||||
return "про другой день так не отвечу — спроси целиком."
|
return phraser.Q(phraser.QueryOtherDay, nil)
|
||||||
}
|
}
|
||||||
return "не знаю."
|
return phraser.Q(phraser.QueryUnknown, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||||||
@@ -167,11 +217,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
|||||||
if dec.Slots.HasTime {
|
if dec.Slots.HasTime {
|
||||||
// The query asks about timing — the fact's own timestamp is the
|
// The query asks about timing — the fact's own timestamp is the
|
||||||
// answer it's looking for. Format as a natural reply.
|
// answer it's looking for. Format as a natural reply.
|
||||||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
|
||||||
}
|
}
|
||||||
// General fact reference: describe what we know.
|
// General fact reference: describe what we know.
|
||||||
if dec.Utterance == "" {
|
if dec.Utterance == "" {
|
||||||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
|
||||||
}
|
}
|
||||||
// The utterance still carries the question; fall through to normal RAG
|
// The utterance still carries the question; fall through to normal RAG
|
||||||
// with the resolved key in context.
|
// with the resolved key in context.
|
||||||
@@ -185,7 +235,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
|||||||
//
|
//
|
||||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
// 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
|
// 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").
|
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||||
@@ -197,23 +253,30 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
|||||||
plan, err := h.api.DayPlan(ctx)
|
plan, err := h.api.DayPlan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: day plan: %v", err)
|
log.Printf("voice: day plan: %v", err)
|
||||||
return "не получилось собрать план.", true
|
return phraser.Q(phraser.QueryFailPlan, nil), true
|
||||||
}
|
}
|
||||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||||
return plan.Spoken, true
|
return plan.Spoken, true
|
||||||
}
|
}
|
||||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||||
// rendered the whole day — one formatter, one persona.
|
// 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 {
|
for _, it := range plan.Items {
|
||||||
p.Items = append(p.Items, morning.PlanEntry{
|
p.Items = append(p.Items, morning.PlanEntry{
|
||||||
At: it.At,
|
At: it.At.In(zone),
|
||||||
Text: it.Text,
|
Text: it.Text,
|
||||||
Kind: morning.PlanKind(it.Kind),
|
Kind: morning.PlanKind(it.Kind),
|
||||||
Uncertain: it.Uncertain,
|
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
|
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||||
@@ -242,7 +305,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
|||||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: habits: recent facts: %v", err)
|
log.Printf("voice: habits: recent facts: %v", err)
|
||||||
return "не получилось посмотреть записи.", true
|
return phraser.Q(phraser.QueryFailNotes, nil), true
|
||||||
}
|
}
|
||||||
obs := make([]memory.Observation, 0, len(facts))
|
obs := make([]memory.Observation, 0, len(facts))
|
||||||
for _, f := range facts {
|
for _, f := range facts {
|
||||||
@@ -266,6 +329,14 @@ const (
|
|||||||
feedReadOut = 3
|
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 — "что нового в лентах?", "что нового по технологиям?"
|
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||||||
// (Vikunja #258).
|
// (Vikunja #258).
|
||||||
//
|
//
|
||||||
@@ -273,15 +344,26 @@ const (
|
|||||||
// never speaks; asking is the trigger. If that ever changes, the thing that
|
// 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.
|
// changed is "Maven is not a nag", not a detail of this file.
|
||||||
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
// The seeds decide the subject; router.ParseFeedQuery is the floor behind
|
||||||
if !ok {
|
// 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
|
return "", false
|
||||||
}
|
}
|
||||||
|
category := router.FeedCategoryOf(t.dec.Utterance)
|
||||||
if !h.feedsOn {
|
if !h.feedsOn {
|
||||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
// Claim only when nothing below can read the world. The reason this
|
||||||
// letting general knowledge answer "что нового?" would be an invented
|
// source used to claim unconditionally was that general knowledge would
|
||||||
// news bulletin.
|
// answer "что нового?" with an invented news bulletin — true, and it
|
||||||
return "я пока не читаю ленты — они не настроены.", true
|
// stopped being the only alternative on 2026-08-02, when live search
|
||||||
|
// took the lead. With SearXNG or the ZIMs configured, "что происходит
|
||||||
|
// в новостях про искусственный интеллект?" has a real answer below,
|
||||||
|
// and a configuration status is the wrong thing to say instead
|
||||||
|
// (Vikunja #474).
|
||||||
|
if h.search != nil || h.kiwix != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return phraser.Q(phraser.QueryFeedsOff, nil), true
|
||||||
}
|
}
|
||||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||||
// used to push the newest headline out of the window, and she answered "в
|
// used to push the newest headline out of the window, and she answered "в
|
||||||
@@ -289,11 +371,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: feeds: recent notes: %v", err)
|
log.Printf("voice: feeds: recent notes: %v", err)
|
||||||
return "не получилось посмотреть ленты.", true
|
return phraser.Q(phraser.QueryFailFeeds, nil), true
|
||||||
}
|
}
|
||||||
var picked []string
|
var picked []string
|
||||||
for _, n := range notes {
|
for _, n := range notes {
|
||||||
if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) {
|
if !router.CategoryMatches(rss.NoteCategory(n.Text), category) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// The note carries title, summary, category tag and link; she reads the
|
// The note carries title, summary, category tag and link; she reads the
|
||||||
@@ -305,18 +387,40 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(picked) == 0 {
|
if len(picked) == 0 {
|
||||||
if q.Category != "" {
|
if category != "" {
|
||||||
return "по этой теме в лентах пока ничего.", true
|
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||||
}
|
}
|
||||||
return "в лентах пока ничего нового.", true
|
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||||
}
|
}
|
||||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||||||
// source can be tested at a fixed time like the rest.
|
// source can be tested at a fixed time like the rest.
|
||||||
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
// A day word is all this source matches on, so any question that merely
|
||||||
|
// names a day reached it first. "какая сегодня погода в Москве?" answered
|
||||||
|
// "на 02.08.2026 ничего нет." (Vikunja #474). Weather is asked about a day
|
||||||
|
// far more often than the calendar is, and the weather source sits right
|
||||||
|
// below, so the calendar steps aside on weather wording — the same bail-out
|
||||||
|
// queryHome already does for the same reason.
|
||||||
|
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())
|
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -324,7 +428,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: calendar events: %v", err)
|
log.Printf("voice: calendar events: %v", err)
|
||||||
return "не получилось проверить календарь.", true
|
return phraser.Q(phraser.QueryFailCalendar, nil), true
|
||||||
}
|
}
|
||||||
// Provenance travels with each event. A work meeting relayed off a phone
|
// Provenance travels with each event. A work meeting relayed off a phone
|
||||||
// notification (source ambient:notif, #126) is stored below full confidence
|
// notification (source ambient:notif, #126) is stored below full confidence
|
||||||
@@ -337,11 +441,15 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
|||||||
return f.FormatEntries(entries, date), true
|
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
|
// 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
|
// 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.
|
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isHomeQuery(t.dec.Utterance) {
|
if !h.turnIsAbout(ctx, t, topicHome, isHomeQuery) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.home == nil {
|
if h.home == nil {
|
||||||
@@ -353,7 +461,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
|||||||
// unreachable case is different and homeSummary covers it.
|
// unreachable case is different and homeSummary covers it.
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
ctxH, cancel := context.WithTimeout(ctx, homeTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return h.home.homeSummary(ctxH)
|
return h.home.homeSummary(ctxH)
|
||||||
}
|
}
|
||||||
@@ -363,53 +471,106 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
|||||||
// because Scan takes no target — the utterance selects the question, never the
|
// because Scan takes no target — the utterance selects the question, never the
|
||||||
// subnet.
|
// subnet.
|
||||||
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isNetworkQuery(t.dec.Utterance) {
|
if !h.turnIsAbout(ctx, t, topicNetwork, isNetworkQuery) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.netscan == nil {
|
if h.netscan == nil {
|
||||||
// Fall through, same as queryHome: an unconfigured scanner must not
|
// The recogniser already matched, so this is a question about HIS LAN
|
||||||
// swallow "сколько устройств в сети?" before recall has looked.
|
// and there is no scanner to answer it. Falling through sent it to the
|
||||||
return "", false
|
// search leg, which answered with a paragraph about routers in general
|
||||||
|
// and put his network question on an upstream engine (Vikunja #479).
|
||||||
|
// A missing capability names itself.
|
||||||
|
return phraser.Q(phraser.QueryNetOff, nil), true
|
||||||
}
|
}
|
||||||
return h.netscan.scanSummary(ctx)
|
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) {
|
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if !isWeatherQuery(t.dec.Utterance) {
|
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||||||
if loc == "" {
|
if loc == "" {
|
||||||
// He named no city and voice.weather.default_location is unset. Saying
|
// He named no city and voice.weather.default_location is unset. Saying
|
||||||
// so is the only honest answer; picking a city would be inventing one.
|
// so is the only honest answer; picking a city would be inventing one.
|
||||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||||
}
|
}
|
||||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
ctxWT, cancel := context.WithTimeout(ctx, weatherTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||||
if errors.Is(err, weather.ErrNotConfigured) {
|
if errors.Is(err, weather.ErrNotConfigured) {
|
||||||
return "погода не настроена.", true
|
return phraser.Q(phraser.QueryWeatherOff, nil), true
|
||||||
|
}
|
||||||
|
if errors.Is(err, weather.ErrLocationUnknown) {
|
||||||
|
// He named a place and the geocoder does not have it. Saying so beats
|
||||||
|
// reading out the default city's temperature (Vikunja #421).
|
||||||
|
return "не знаю такого города — " + loc + ".", true
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: weather: %v", err)
|
log.Printf("voice: weather: %v", err)
|
||||||
return "не получилось узнать погоду.", true
|
return phraser.Q(phraser.QueryFailWeather, nil), true
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
||||||
|
"location": w.Location,
|
||||||
|
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
||||||
|
"word": phraser.Degrees(w.Temperature),
|
||||||
|
"condition": w.Condition,
|
||||||
|
}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
// 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
|
// 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) {
|
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
// 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 {
|
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)
|
log.Printf("voice: embed query: %v", err)
|
||||||
return "не получилось найти ответ.", true
|
return "", false
|
||||||
}
|
}
|
||||||
t.vec = vec
|
t.vec = vec
|
||||||
return "", false
|
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) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, utterance)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// queryMemory — long-term memory first: ONE search over everything Maven
|
// queryMemory — long-term memory first: ONE search over everything Maven
|
||||||
// remembers (notes and facts share this index) and ONE confidence gate, so
|
// 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
|
// the memory that is clearly the best match answers — a note just as much as
|
||||||
@@ -421,31 +582,39 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
|||||||
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
||||||
// which memory gets to answer them.
|
// which memory gets to answer them.
|
||||||
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
if h.memStore == nil {
|
if h.recall.memStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
hits, herr := h.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 {
|
if herr != nil {
|
||||||
log.Printf("voice: memory search: %v", herr)
|
log.Printf("voice: memory search: %v", herr)
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
text := hit.Meta["text"]
|
text := hit.Meta["text"]
|
||||||
// The score cleared the gate and the topic still has to match (#470). A
|
// The score cleared the gate and the topic still has to match.
|
||||||
// note about his slow network scored high enough to answer "почему небо
|
if !recallOnTopic(t.dec.Utterance, text) {
|
||||||
// синее?", 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)
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||||
// stored.
|
// stored.
|
||||||
if hit.Meta["type"] == "note" {
|
if hit.Meta["type"] == "note" {
|
||||||
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
|
||||||
|
switch {
|
||||||
|
case perr != nil:
|
||||||
|
// Reading the note back verbatim beats the phraser's own fallback,
|
||||||
|
// which only wraps the same text in "вот что я нашла:".
|
||||||
|
log.Printf("voice: recall phrase: %v", perr)
|
||||||
|
case reply != "":
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -464,23 +633,30 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
|||||||
// band. See memory.Confident. Failing the gate passes the turn on to general
|
// 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.
|
// knowledge, which is what "don't read back the runner-up" means here.
|
||||||
func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) {
|
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 {
|
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)
|
log.Printf("voice: query notes: %v", err)
|
||||||
return "не получилось найти ответ.", true
|
return "", false
|
||||||
}
|
}
|
||||||
t.notes = notes
|
t.notes = notes
|
||||||
noteScores := make([]float64, len(notes))
|
noteScores := make([]float64, len(notes))
|
||||||
for i, n := range notes {
|
for i, n := range notes {
|
||||||
noteScores[i] = n.Score
|
noteScores[i] = n.Score
|
||||||
}
|
}
|
||||||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
// Same topic veto as queryMemory above: the best note must be about what
|
// The best note must be about what he asked, not merely the nearest vector
|
||||||
// he asked, not merely the nearest vector in the index.
|
// in the index.
|
||||||
if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) {
|
if !recallOnTopic(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)
|
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
texts := make([]string, len(notes))
|
texts := make([]string, len(notes))
|
||||||
@@ -492,7 +668,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
log.Printf("voice: phrase query: %v", err)
|
log.Printf("voice: phrase query: %v", err)
|
||||||
}
|
}
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
reply = "вот что я нашла: " + texts[0]
|
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -502,6 +678,23 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
|||||||
// prompt, the persona block and the reply.
|
// prompt, the persona block and the reply.
|
||||||
const webPageContextRunes = 1500
|
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).
|
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||||
//
|
//
|
||||||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||||
@@ -514,25 +707,24 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
if h.crawler == nil {
|
if h.crawler == nil {
|
||||||
// Fall through. Reading pages is off unless configured, and on a daemon
|
// He named a URL, so the question is about that page and nothing else
|
||||||
// where it was never turned on the older behaviour is right: the model
|
// can answer it. The older comment here argued for falling through and
|
||||||
// answers the question as if the URL had not been said. Announcing a
|
// letting the model answer as if the URL had not been said; that is a
|
||||||
// configuration status is for a capability that exists and failed, not
|
// guess dressed as an answer (Vikunja #479).
|
||||||
// for one he never asked for.
|
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
ctxFetch, cancel := context.WithTimeout(ctx, webFetchTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
page, err := h.crawler.Page(ctxFetch, link)
|
page, err := h.crawler.Page(ctxFetch, link)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, crawl.ErrRobots) {
|
if errors.Is(err, crawl.ErrRobots) {
|
||||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
return phraser.Q(phraser.QueryPageBlocked, nil), true
|
||||||
}
|
}
|
||||||
log.Printf("voice: web: %v", err)
|
log.Printf("voice: web: %v", err)
|
||||||
return "не получилось прочитать страницу.", true
|
return phraser.Q(phraser.QueryFailPage, nil), true
|
||||||
}
|
}
|
||||||
if page.Text == "" {
|
if page.Text == "" {
|
||||||
return "страница открылась, но читать там нечего.", true
|
return phraser.Q(phraser.QueryPageEmpty, nil), true
|
||||||
}
|
}
|
||||||
// The page is handed to the phraser the same way a note is: as context for
|
// The page is handed to the phraser the same way a note is: as context for
|
||||||
// the question he actually asked. She answers the question, she does not
|
// the question he actually asked. She answers the question, she does not
|
||||||
@@ -542,7 +734,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser (or it failed): read back the top of the page rather than
|
// No phraser (or it failed): read back the top of the page rather than
|
||||||
// pretend the fetch did not happen.
|
// pretend the fetch did not happen.
|
||||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, readBackRunes)}), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -608,7 +800,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser, or it failed. Read back the best evidence rather than
|
// No phraser, or it failed. Read back the best evidence rather than
|
||||||
// pretend the search did not happen.
|
// pretend the search did not happen.
|
||||||
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
|
return readBack(resp.Snippets()[0]), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
@@ -635,11 +827,19 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
|||||||
ctxK, cancel := context.WithTimeout(ctx, kiwixTimeout)
|
ctxK, cancel := context.WithTimeout(ctx, kiwixTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// The ZIMs are English and kiwix ranks by keyword overlap, not meaning, so
|
// A Russian question reads the Russian ZIM verbatim when there is one
|
||||||
// a Russian sentence matches nothing at all. The rewriter turns it into a
|
// (V-508). Kiwix ranks by keyword overlap rather than meaning, so an English
|
||||||
// handful of English keywords with the resident model.
|
// 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
|
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)
|
q, err := h.kiwix.rewriter.Rewrite(ctxK, t.dec.Utterance)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fall through to the verbatim question rather than give up. It
|
// Fall through to the verbatim question rather than give up. It
|
||||||
@@ -650,7 +850,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hits, err := h.kiwix.client.Search(ctxK, pattern, h.kiwix.book, h.kiwix.max)
|
hits, err := h.kiwix.client.Search(ctxK, pattern, book, h.kiwix.max)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: kiwix: search %q: %v", pattern, err)
|
log.Printf("voice: kiwix: search %q: %v", pattern, err)
|
||||||
return "", false
|
return "", false
|
||||||
@@ -662,7 +862,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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// builds its snippet from wherever the keyword matched, which on Wikipedia
|
||||||
@@ -689,11 +889,23 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||||
// the search did not happen.
|
// the search did not happen.
|
||||||
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
|
return readBack(top.Title + " — " + page.Text), true
|
||||||
}
|
}
|
||||||
return reply, 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
|
// queryPersonal — stop the walk on a question about him that his own data did
|
||||||
// not answer.
|
// not answer.
|
||||||
//
|
//
|
||||||
@@ -719,7 +931,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
||||||
return "не знаю — не нашла у тебя такой записи.", true
|
return phraser.Q(phraser.QueryPersonalNone, nil), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// personalMarkers — first-person POSSESSION, not first person generally.
|
// personalMarkers — first-person POSSESSION, not first person generally.
|
||||||
@@ -761,8 +973,8 @@ func isPersonalQuery(utterance string) bool {
|
|||||||
// computed. Same shape as the cascade: the better test leads, the offline one
|
// computed. Same shape as the cascade: the better test leads, the offline one
|
||||||
// always answers.
|
// always answers.
|
||||||
func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool {
|
func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool {
|
||||||
h.boundary.load(ctx, h.embedder)
|
h.recall.boundary.load(ctx, h.recall.embedder)
|
||||||
if personal, world, ok := h.boundary.score(t.vec); ok {
|
if personal, world, ok := h.recall.boundary.score(t.vec); ok {
|
||||||
if personal > world {
|
if personal > world {
|
||||||
log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world)
|
log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world)
|
||||||
return true
|
return true
|
||||||
@@ -787,15 +999,15 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
|
|||||||
if h.phraser == nil {
|
if h.phraser == nil {
|
||||||
// No model of any size. That is not the workstation being asleep, so it
|
// No model of any size. That is not the workstation being asleep, so it
|
||||||
// is not that gap: it is simply not knowing.
|
// is not that gap: it is simply not knowing.
|
||||||
return "не знаю.", true
|
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||||
}
|
}
|
||||||
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
||||||
if errors.Is(err, phraser.ErrNoWorldModel) {
|
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||||||
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
||||||
return worldGap, true
|
return worldGap(), true
|
||||||
}
|
}
|
||||||
if err != nil || reply == "" {
|
if err != nil || reply == "" {
|
||||||
return "не знаю.", true
|
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,19 +19,44 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
|||||||
// time wasn't parsed. Run the parser as a fallback.
|
// time wasn't parsed. Run the parser as a fallback.
|
||||||
if dec.Stage == 0 && h.timeParser != nil {
|
if dec.Stage == 0 && h.timeParser != nil {
|
||||||
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
|
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.Time = t
|
||||||
dec.Slots.HasTime = true
|
dec.Slots.HasTime = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !dec.Slots.HasTime {
|
if !dec.Slots.HasTime {
|
||||||
return "не получилось разобрать время напоминания."
|
return phraser.Ack(phraser.FailReminderTime, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
// The body is what she says at the hour, so the marker and the time come
|
||||||
|
// out of it: the fire time is already a column, and "напомни" is an
|
||||||
|
// instruction that has been carried out (Vikunja #469).
|
||||||
|
payload := `{"text":` + jsonString(reminderBody(dec.Utterance, dec.Slots.Text)) + `}`
|
||||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||||
log.Printf("voice: create reminder: %v", err)
|
log.Printf("voice: create reminder: %v", err)
|
||||||
return "не получилось поставить напоминание."
|
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") + "."
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-5
@@ -3,8 +3,11 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/tasks"
|
"github.com/kami/maven/internal/tasks"
|
||||||
@@ -40,18 +43,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: capture task: %v", err)
|
log.Printf("voice: capture task: %v", err)
|
||||||
return "не получилось записать задачу.", true
|
return phraser.Ack(phraser.FailTask, nil), true
|
||||||
}
|
}
|
||||||
if resp.Promoted {
|
if resp.Promoted {
|
||||||
// It was a candidate Maven derived from something she read, and he has
|
// It was a candidate Maven derived from something she read, and he has
|
||||||
// now said it himself. Saying "уже в списке" here would be answering a
|
// now said it himself. Saying "уже в списке" here would be answering a
|
||||||
// confirmation with a shrug.
|
// confirmation with a shrug.
|
||||||
return "поняла, беру в работу: " + cap.Text, true
|
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
|
||||||
}
|
}
|
||||||
if !resp.Created {
|
if !resp.Created {
|
||||||
return "это уже в списке.", true
|
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
|
||||||
}
|
}
|
||||||
return "записала: " + cap.Text, true
|
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||||
@@ -70,7 +73,104 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
|||||||
log.Printf("voice: list tasks: %v", err)
|
log.Printf("voice: list tasks: %v", err)
|
||||||
return "не получилось посмотреть задачи.", true
|
return "не получилось посмотреть задачи.", true
|
||||||
}
|
}
|
||||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
ranked := tasks.Rank(taskItems(live), h.now())
|
||||||
|
// Bind what she is about to say, in the order she says it, so "второй"
|
||||||
|
// means the second task he heard (ordinal.go).
|
||||||
|
spoken := tasks.Spoken(ranked)
|
||||||
|
cands := make([]dialogue.Candidate, 0, len(spoken))
|
||||||
|
for _, r := range spoken {
|
||||||
|
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
|
||||||
|
}
|
||||||
|
h.offerCandidates(ctx, cands)
|
||||||
|
reply := tasks.FormatRU(ranked)
|
||||||
|
// The counted shapes, after the list and only when there are any (V-512).
|
||||||
|
// They answer "what is going wrong with this list" without assessing any of
|
||||||
|
// it, and they are said here rather than announced: no tick rule reads them.
|
||||||
|
if stalls := tasks.StallsRU(tasks.Stalls(taskItems(live), h.now())); stalls != "" {
|
||||||
|
if !strings.HasSuffix(reply, ".") {
|
||||||
|
reply += "."
|
||||||
|
}
|
||||||
|
reply += " " + stalls
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveTaskStatus moves a task he named out loud (Vikunja #512).
|
||||||
|
//
|
||||||
|
// The position path already worked: resolveCandidate answers "первую сделал"
|
||||||
|
// against the list she just read. This is the other half — naming the task
|
||||||
|
// instead of its position, which reached no code at all before the stage-0 rule
|
||||||
|
// in internal/router/taskstatus.go filled the fn slot.
|
||||||
|
//
|
||||||
|
// Three answers besides the move, and none of them guesses. No match says so. A
|
||||||
|
// match on more than one asks which, because closing the wrong task is work he
|
||||||
|
// never finished being marked done. No task named asks which too, since the
|
||||||
|
// router claims the turn without the referent and the list lives here.
|
||||||
|
func (h *reactiveHandler) resolveTaskStatus(ctx context.Context, dec router.Decision) string {
|
||||||
|
live, err := h.api.ListTasks(ctx, "live")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: task status: list: %v", err)
|
||||||
|
return "не получилось посмотреть задачи."
|
||||||
|
}
|
||||||
|
if dec.Slots.Text == "" {
|
||||||
|
return "какую задачу?"
|
||||||
|
}
|
||||||
|
match := matchTaskText(live, dec.Slots.Text)
|
||||||
|
switch len(match) {
|
||||||
|
case 0:
|
||||||
|
return "не нашла такой задачи."
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return "у тебя несколько подходящих — какую именно?"
|
||||||
|
}
|
||||||
|
pick := match[0]
|
||||||
|
status := dec.Slots.Value
|
||||||
|
// A candidate is work Maven proposed and he never confirmed, and the store
|
||||||
|
// refuses candidate → done: the legal move is to open it first. Saying it is
|
||||||
|
// done IS the confirmation, so both writes happen rather than the turn
|
||||||
|
// naming a gap about a distinction he did not make.
|
||||||
|
if pick.Status == store.TaskCandidate && status == store.TaskDone {
|
||||||
|
if err := h.api.SetTaskStatus(ctx, pick.ID, store.TaskOpen, h.now(), string(sourceVoice)); err != nil {
|
||||||
|
log.Printf("voice: task status: promote %d: %v", pick.ID, err)
|
||||||
|
return "не получилось изменить задачу."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.api.SetTaskStatus(ctx, pick.ID, status, h.now(), string(sourceVoice)); err != nil {
|
||||||
|
log.Printf("voice: task status: %d → %s: %v", pick.ID, status, err)
|
||||||
|
return "не получилось изменить задачу."
|
||||||
|
}
|
||||||
|
log.Printf("voice: task %d (%q) → %s", pick.ID, pick.Text, status)
|
||||||
|
if status == store.TaskDropped {
|
||||||
|
return "убрала: " + pick.Text
|
||||||
|
}
|
||||||
|
return "закрыла: " + pick.Text
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchTaskText finds the live tasks he could have meant.
|
||||||
|
//
|
||||||
|
// Normalised containment, either direction, over store.NormalizeTaskText — the
|
||||||
|
// same key capture dedupes on, so a task he can file twice is a task he can name
|
||||||
|
// twice. Either direction because he shortens what he said ("молоко" for
|
||||||
|
// "купить молоко") as often as he pads it.
|
||||||
|
//
|
||||||
|
// Deliberately not fuzzy. A ranked best guess would always return exactly one
|
||||||
|
// answer, and the one thing this must be able to say is that it is not sure.
|
||||||
|
func matchTaskText(live []ipc.Task, named string) []ipc.Task {
|
||||||
|
want := store.NormalizeTaskText(named)
|
||||||
|
if want == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []ipc.Task
|
||||||
|
for _, t := range live {
|
||||||
|
have := store.NormalizeTaskText(t.Text)
|
||||||
|
if have == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(have, want) || strings.Contains(want, have) {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +26,9 @@ type taskAPI struct {
|
|||||||
tasks []ipc.Task
|
tasks []ipc.Task
|
||||||
listArg string
|
listArg string
|
||||||
listErr error
|
listErr error
|
||||||
|
|
||||||
|
moved []setStatusCall
|
||||||
|
moveErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
func (a *taskAPI) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
|
||||||
@@ -108,7 +112,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "не получилось") {
|
if !phraser.IsAck(phraser.FailTask, nil, reply) {
|
||||||
t.Errorf("reply = %q, want an honest failure", reply)
|
t.Errorf("reply = %q, want an honest failure", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +141,7 @@ func TestQueryTasksRecitesTheLiveList(t *testing.T) {
|
|||||||
if !(openIdx < candIdx) {
|
if !(openIdx < candIdx) {
|
||||||
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
t.Errorf("reply = %q, want confirmed work before candidates", reply)
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "не подтвердил") {
|
if !strings.Contains(reply, "не подтверждал") {
|
||||||
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
t.Errorf("reply = %q, want the candidate flagged as unconfirmed", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,3 +256,87 @@ func TestCaptureTaskFromNoteAcknowledgesAPromotion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setStatusCall — one SetTaskStatus the arm made, in order, so a candidate he
|
||||||
|
// says is done can be shown to take both legal moves.
|
||||||
|
type setStatusCall struct {
|
||||||
|
id int64
|
||||||
|
status string
|
||||||
|
by string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *taskAPI) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error {
|
||||||
|
a.moved = append(a.moved, setStatusCall{id: id, status: status, by: by})
|
||||||
|
return a.moveErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) {
|
||||||
|
api := &taskAPI{tasks: []ipc.Task{
|
||||||
|
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||||
|
{ID: 8, Text: "оплатить интернет", Status: "open"},
|
||||||
|
}}
|
||||||
|
h := taskHandler(api)
|
||||||
|
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "молоко"},
|
||||||
|
})
|
||||||
|
if api.listArg != "live" {
|
||||||
|
t.Errorf("listed %q, want live — a resolved task cannot be resolved again", api.listArg)
|
||||||
|
}
|
||||||
|
if len(api.moved) != 1 {
|
||||||
|
t.Fatalf("moved %d tasks, want 1: %+v", len(api.moved), api.moved)
|
||||||
|
}
|
||||||
|
if api.moved[0].id != 7 || api.moved[0].status != "done" {
|
||||||
|
t.Errorf("moved %+v, want id 7 → done", api.moved[0])
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "купить молоко") {
|
||||||
|
t.Errorf("reply = %q, want the task named back", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tasks []ipc.Task
|
||||||
|
named string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"no match", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "позвонить маме", "не нашла"},
|
||||||
|
{"two matches", []ipc.Task{
|
||||||
|
{ID: 7, Text: "купить молоко", Status: "open"},
|
||||||
|
{ID: 8, Text: "купить молоко и хлеб", Status: "open"},
|
||||||
|
}, "купить молоко", "несколько"},
|
||||||
|
{"none named", []ipc.Task{{ID: 7, Text: "купить молоко", Status: "open"}}, "", "какую"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
api := &taskAPI{tasks: c.tasks}
|
||||||
|
h := taskHandler(api)
|
||||||
|
reply := h.resolveTaskStatus(context.Background(), router.Decision{
|
||||||
|
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: c.named},
|
||||||
|
})
|
||||||
|
if len(api.moved) != 0 {
|
||||||
|
t.Errorf("moved %+v — closing the wrong task is the failure this arm exists to avoid", api.moved)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, c.want) {
|
||||||
|
t.Errorf("reply = %q, want it to contain %q", reply, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTaskStatusOpensACandidateFirst(t *testing.T) {
|
||||||
|
// The store refuses candidate → done. Saying it is done is the confirmation
|
||||||
|
// the candidate was waiting for, so the arm makes both legal moves.
|
||||||
|
api := &taskAPI{tasks: []ipc.Task{{ID: 9, Text: "продлить домен", Status: "candidate"}}}
|
||||||
|
h := taskHandler(api)
|
||||||
|
h.resolveTaskStatus(context.Background(), router.Decision{
|
||||||
|
Slots: router.Slots{Fn: router.TaskStatusFn, HasFn: true, Value: "done", Text: "продлить домен"},
|
||||||
|
})
|
||||||
|
if len(api.moved) != 2 {
|
||||||
|
t.Fatalf("moved %+v, want open then done", api.moved)
|
||||||
|
}
|
||||||
|
if api.moved[0].status != "open" || api.moved[1].status != "done" {
|
||||||
|
t.Errorf("moved %+v, want open then done", api.moved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The sev4 repeat path had no off switch (Vikunja #535): it re-sent every
|
||||||
|
// pending telegram nudge every repeat_interval, and nothing in the tree could
|
||||||
|
// ever mark one acked. None of what follows can be reproduced by hand without
|
||||||
|
// sitting in front of the box for hours, so it is covered here or nowhere.
|
||||||
|
|
||||||
|
// seedDown writes one kuma monitor fact at ts. value is "down" or "up".
|
||||||
|
func seedDown(t *testing.T, st *store.Store, ctx context.Context, value string, ts time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := st.SetValue(ctx, store.KindSelf, "service_down:db", "poll:uptimekuma", value, ts); err != nil {
|
||||||
|
t.Fatalf("seed service_down:db=%s: %v", value, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAlarmTickLoop — like newTestTickLoop but with the ack tracker wired, which
|
||||||
|
// the shared helper leaves nil. Without it RepeatUnacked returns early and the
|
||||||
|
// repeat these tests are about never happens. The daemon wires it (main.go).
|
||||||
|
func newAlarmTickLoop(t *testing.T, st *store.Store, sink delivery.Sink) *tickLoop {
|
||||||
|
t.Helper()
|
||||||
|
rules := loop.DefaultRules()
|
||||||
|
g := loop.NewGatherer(st, rules)
|
||||||
|
d := delivery.NewDispatcher(delivery.Config{
|
||||||
|
Voice: sink, Ntfy: sink, Telegram: sink,
|
||||||
|
Ack: st, Nudges: st, Reminders: st,
|
||||||
|
})
|
||||||
|
return newTickLoop(st, g, d, phraser.NewStub(), rules, time.Second, 5*time.Minute, 0, nil, nil, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// telegramSends counts sends that went out on the telegram reach.
|
||||||
|
func telegramSends(sink *fakeSink, rule string) int {
|
||||||
|
n := 0
|
||||||
|
for _, s := range sink.sends {
|
||||||
|
if s.RuleName == rule {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// outcomes returns the outcome of every nudge row for a rule, newest first.
|
||||||
|
func outcomes(t *testing.T, st *store.Store, ctx context.Context, rule string) []string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := st.RecentNudges(ctx, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent nudges: %v", err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, n := range rows {
|
||||||
|
if n.Rule == rule {
|
||||||
|
out = append(out, n.Outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlarmStopsWhenTheServiceComesBackUp(t *testing.T) {
|
||||||
|
// The condition clearing is the ending that should happen. StillTrue reads
|
||||||
|
// the same DownServices helper the phraser reads, so the repeat stops on
|
||||||
|
// exactly the monitor he was told about.
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newAlarmTickLoop(t, st, sink)
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
if telegramSends(sink, "service_down") == 0 {
|
||||||
|
t.Fatal("the alarm never went out; the rest of this test proves nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||||
|
sink.sends = nil
|
||||||
|
tl.tick(ctx, now.Add(6*time.Minute)) // past repeat_interval
|
||||||
|
|
||||||
|
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||||
|
t.Fatalf("repeated %d time(s) after the service came back up; want 0", n)
|
||||||
|
}
|
||||||
|
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||||
|
if o != store.NudgeResolved {
|
||||||
|
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeResolved)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlarmStopsAtTheAgeCapWhileStillDown(t *testing.T) {
|
||||||
|
// Still down, still un-acked, and nobody has answered in two hours. That is
|
||||||
|
// not one more repeat away from being answered.
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newAlarmTickLoop(t, st, sink)
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
|
||||||
|
sink.sends = nil
|
||||||
|
tl.tick(ctx, now.Add(6*time.Minute))
|
||||||
|
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||||
|
t.Fatal("no repeat inside the cap; the cap is not what stopped it later")
|
||||||
|
}
|
||||||
|
|
||||||
|
sink.sends = nil
|
||||||
|
tl.tick(ctx, now.Add(maxAlarmAge+time.Minute))
|
||||||
|
if n := telegramSends(sink, "service_down"); n != 0 {
|
||||||
|
t.Fatalf("repeated %d time(s) past the %s cap; want 0", n, maxAlarmAge)
|
||||||
|
}
|
||||||
|
// Ignored, not resolved: nothing says the service got better.
|
||||||
|
for _, o := range outcomes(t, st, ctx, "service_down") {
|
||||||
|
if o != store.NudgeIgnored {
|
||||||
|
t.Fatalf("nudge outcome = %q, want %q", o, store.NudgeIgnored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFlapRaisesAFreshAlarmRatherThanReviveTheClosedOne(t *testing.T) {
|
||||||
|
// Down, up, down again. Closing the first run must not make the second run
|
||||||
|
// unreportable, and must not silently reopen the closed rows either.
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
seedDown(t, st, ctx, "down", now.Add(-5*time.Minute))
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newAlarmTickLoop(t, st, sink)
|
||||||
|
tl.tick(ctx, now)
|
||||||
|
first := len(outcomes(t, st, ctx, "service_down"))
|
||||||
|
|
||||||
|
seedDown(t, st, ctx, "up", now.Add(time.Minute))
|
||||||
|
tl.tick(ctx, now.Add(2*time.Minute))
|
||||||
|
if got := outcomes(t, st, ctx, "service_down"); len(got) != first {
|
||||||
|
t.Fatalf("closing the run changed the row count: %d → %d", first, len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
seedDown(t, st, ctx, "down", now.Add(25*time.Minute))
|
||||||
|
sink.sends = nil
|
||||||
|
tl.tick(ctx, now.Add(31*time.Minute))
|
||||||
|
|
||||||
|
if n := telegramSends(sink, "service_down"); n == 0 {
|
||||||
|
t.Fatal("the second outage said nothing; the first alarm's ending swallowed it")
|
||||||
|
}
|
||||||
|
got := outcomes(t, st, ctx, "service_down")
|
||||||
|
if len(got) <= first {
|
||||||
|
t.Fatalf("no new nudge row for the second outage (%d rows, was %d)", len(got), first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARuleThatSaysNothingAboutItsConditionOnlyStopsOnAge(t *testing.T) {
|
||||||
|
// StillTrue == nil means "I cannot tell you", never "it cleared". A rule
|
||||||
|
// that says nothing must keep its alarm until the age cap, or a rule author
|
||||||
|
// silences their own alarm by omission.
|
||||||
|
st := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := refNow()
|
||||||
|
|
||||||
|
sink := &fakeSink{}
|
||||||
|
tl := newAlarmTickLoop(t, st, sink)
|
||||||
|
tl.rules = []loop.Rule{{Name: "mute", Severity: loop.Sev4}} // no StillTrue
|
||||||
|
|
||||||
|
if _, err := st.RecordNudge(ctx, "mute", string(delivery.ChannelTelegram), "still bad", now); err != nil {
|
||||||
|
t.Fatalf("record nudge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
live := tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(time.Minute))
|
||||||
|
if len(live) != 1 {
|
||||||
|
t.Fatalf("a nil StillTrue was read as resolved: live = %v", live)
|
||||||
|
}
|
||||||
|
|
||||||
|
live = tl.stopFinishedAlarms(ctx, []string{"mute"}, loop.State{}, now.Add(maxAlarmAge+time.Minute))
|
||||||
|
if len(live) != 0 {
|
||||||
|
t.Fatalf("the age cap did not stop a rule with no StillTrue: live = %v", live)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An empty attention list used to be answered "ничего не требует внимания"
|
||||||
|
// unconditionally, which is an all-clear Maven had no way to know was true
|
||||||
|
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||||
|
func TestAttentionEmptyWithHealthySourcesIsAllClear(t *testing.T) {
|
||||||
|
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||||
|
t.Fatalf("healthy and quiet should be an all-clear, got %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAttentionEmptyWithAFailedSourceHedges(t *testing.T) {
|
||||||
|
praxis := newFakePraxisWithSources(t, `[]`, `[
|
||||||
|
{"source_id":"src_ntfy","health":"ok"},
|
||||||
|
{"source_id":"src_llamacpp","health":"failed"},
|
||||||
|
{"source_id":"src_imap","health":"stale"}
|
||||||
|
]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
if strings.Contains(reply, "ничего не требует внимания") {
|
||||||
|
t.Fatalf("a failed source must not read as all-clear, got %q", reply)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"src_llamacpp", "src_imap"} {
|
||||||
|
if !strings.Contains(reply, want) {
|
||||||
|
t.Errorf("reply names no %s: %q", want, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "src_ntfy") {
|
||||||
|
t.Errorf("the healthy source is named as a problem: %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Praxis that polls nothing knows nothing, which is the state the box is in.
|
||||||
|
func TestAttentionEmptyWithNoSourcesHedges(t *testing.T) {
|
||||||
|
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
if strings.Contains(reply, "ничего не требует внимания") {
|
||||||
|
t.Fatalf("a Praxis with no sources must not answer all-clear, got %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "источник") {
|
||||||
|
t.Errorf("reply does not say why she cannot tell: %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The spec's own mechanism, which the deployed Praxis does not send yet: the
|
||||||
|
// envelope's degraded array is believed without a second call.
|
||||||
|
func TestAttentionDegradedEnvelopeIsReadWithoutASourcesCall(t *testing.T) {
|
||||||
|
praxis := newFakePraxisWithSources(t,
|
||||||
|
`{"items":[],"degraded":["src_metrics"]}`,
|
||||||
|
`[{"source_id":"src_ntfy","health":"ok"}]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
if !strings.Contains(reply, "src_metrics") {
|
||||||
|
t.Fatalf("the envelope's degraded source is not named: %q", reply)
|
||||||
|
}
|
||||||
|
for _, r := range praxis.Requests() {
|
||||||
|
if r.Path == "/api/v1/sources" {
|
||||||
|
t.Error("sources was read even though the response carried degraded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sources endpoint that errors is not evidence of a fault: the attention call
|
||||||
|
// itself succeeded, and hedging on it would make her permanently uncertain.
|
||||||
|
func TestAttentionKeepsAllClearWhenSourcesCannotBeRead(t *testing.T) {
|
||||||
|
praxis := newFakePraxisWithSources(t, `[]`, `[]`)
|
||||||
|
praxis.SetRouteFault("/api/v1/sources", 500)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
if !strings.Contains(reply, "ничего не требует внимания") {
|
||||||
|
t.Fatalf("an unreadable sources list should leave the answer alone, got %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both response shapes decode, because the spec says one and the box sends the
|
||||||
|
// other.
|
||||||
|
func TestPraxisAttentionDecodesBothShapes(t *testing.T) {
|
||||||
|
var bare praxisAttention
|
||||||
|
if err := json.Unmarshal([]byte(`[{"id":"item_1"}]`), &bare); err != nil {
|
||||||
|
t.Fatalf("bare array: %v", err)
|
||||||
|
}
|
||||||
|
if len(bare.Items) != 1 || len(bare.Degraded) != 0 {
|
||||||
|
t.Errorf("bare array decoded as %+v", bare)
|
||||||
|
}
|
||||||
|
|
||||||
|
var env praxisAttention
|
||||||
|
if err := json.Unmarshal([]byte(`{"items":[{"id":"item_2"}],"degraded":["src_a"]}`), &env); err != nil {
|
||||||
|
t.Fatalf("envelope: %v", err)
|
||||||
|
}
|
||||||
|
if len(env.Items) != 1 || len(env.Degraded) != 1 || env.Degraded[0] != "src_a" {
|
||||||
|
t.Errorf("envelope decoded as %+v", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A source that reports no health at all counts as healthy. A Praxis that never
|
||||||
|
// fills the field would otherwise make every quiet turn a hedge.
|
||||||
|
func TestUnhealthySourcesTreatsAnAbsentHealthFieldAsHealthy(t *testing.T) {
|
||||||
|
praxis := newFakePraxisWithSources(t, `[]`, `[{"source_id":"src_a"},{"id":"src_b","health":"stale"}]`)
|
||||||
|
bad, total, err := newPraxisClient(praxis.URL).UnhealthySources(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnhealthySources: %v", err)
|
||||||
|
}
|
||||||
|
if total != 2 {
|
||||||
|
t.Errorf("total = %d, want 2", total)
|
||||||
|
}
|
||||||
|
if len(bad) != 1 || bad[0] != "src_b" {
|
||||||
|
t.Errorf("bad = %v, want [src_b]", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// attentionMarkers — the offline floor under the attention topic (topics.go).
|
||||||
|
// The seeds decide when the embedder is there; this answers when it is not, and
|
||||||
|
// it stays a substring list on purpose for the reason the other floors do: a
|
||||||
|
// narrow test made blind beats a broad guess made blind.
|
||||||
|
//
|
||||||
|
// "что нового" is deliberately absent: the feeds source claims it, and it
|
||||||
|
// still should — a question about news is a question about the feeds she
|
||||||
|
// reads. This list is about the operational state of his things.
|
||||||
|
var attentionMarkers = []string{
|
||||||
|
"внимани", "что требует", "что не так", "что важн", "что срочн",
|
||||||
|
"needs attention", "what needs looking",
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAttentionQuery reports whether the utterance asks what needs looking at.
|
||||||
|
// Called through turnIsAbout, never directly.
|
||||||
|
func isAttentionQuery(u string) bool {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(u))
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, m := range attentionMarkers {
|
||||||
|
if strings.Contains(s, m) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryAttention answers "что требует внимания?" from Praxis.
|
||||||
|
//
|
||||||
|
// The capability was already built and already degraded correctly, and no
|
||||||
|
// utterance could reach it (Vikunja #475). Its aliases live on the act
|
||||||
|
// dispatch, and the question routes to IntentQuery, so it fell through every
|
||||||
|
// source to the web search and came back with an encyclopedia article about
|
||||||
|
// the concept of attention — worse than silence, because it reads as an
|
||||||
|
// answer.
|
||||||
|
//
|
||||||
|
// Placed above the recall sources and well above the personal boundary: this
|
||||||
|
// is operational state about his things, and a notes pass would otherwise
|
||||||
|
// answer it from whatever he once wrote about a server. An unconfigured or
|
||||||
|
// absent Praxis falls through rather than claiming the turn, the same
|
||||||
|
// convention queryHome and queryNetwork follow. A Praxis that is configured
|
||||||
|
// and down does claim it, and says it cannot reach the service — that is the
|
||||||
|
// degradation the ecosystem contract asks for, and it comes from the same
|
||||||
|
// handler the act path uses.
|
||||||
|
func (h *reactiveHandler) queryAttention(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !h.turnIsAbout(ctx, t, topicAttend, isAttentionQuery) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if h.ecosystem == nil || h.ecosystem.praxis == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||||
|
Utterance: t.dec.Utterance,
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Fn: "list_attention", HasFn: true},
|
||||||
|
})
|
||||||
|
if reply == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsAttentionQuery(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"что требует внимания", true},
|
||||||
|
{"на что обратить внимание?", true},
|
||||||
|
{"что не так?", true},
|
||||||
|
{"что важного?", true},
|
||||||
|
// The feeds source owns this one, and should keep owning it.
|
||||||
|
{"что нового?", false},
|
||||||
|
{"какая погода?", false},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
if got := isAttentionQuery(tc.text); got != tc.want {
|
||||||
|
t.Errorf("isAttentionQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAttentionQuestionReachesPraxis — the defect (Vikunja #475). The question
|
||||||
|
// routes to IntentQuery, and every source used to pass, so a web search about
|
||||||
|
// the concept of attention answered it.
|
||||||
|
func TestAttentionQuestionReachesPraxis(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
praxis := newFakePraxis(t, fixturePraxisAttentionItems(map[string]any{
|
||||||
|
"id": "item_1", "title": "disk almost full", "importance": 3.0,
|
||||||
|
}))
|
||||||
|
h := ecoHandler(t, nil, praxis, nil)
|
||||||
|
|
||||||
|
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||||
|
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||||
|
}})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the attention question must be claimed before the world sources")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "disk almost full") {
|
||||||
|
t.Fatalf("reply = %q, want the praxis item", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A configured Praxis that is down claims the turn and says so. Falling
|
||||||
|
// through here would answer an outage with an encyclopedia article.
|
||||||
|
func TestAttentionQuestionSaysWhenPraxisIsDown(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||||
|
h := ecoHandler(t, nil, praxis, nil)
|
||||||
|
praxis.SetFault(503)
|
||||||
|
|
||||||
|
reply, ok := h.queryAttention(ctx, &queryTurn{dec: router.Decision{
|
||||||
|
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||||
|
}})
|
||||||
|
if !ok || !strings.Contains(reply, "не могу") {
|
||||||
|
t.Fatalf("an outage must name the gap, got ok=%v reply=%q", ok, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Praxis configured means no claim: the rest of the chain still runs.
|
||||||
|
func TestAttentionQuestionFallsThroughWithoutPraxis(t *testing.T) {
|
||||||
|
h, _ := newFactGateHandler(t, time.Now())
|
||||||
|
if _, ok := h.queryAttention(context.Background(), &queryTurn{dec: router.Decision{
|
||||||
|
Intent: router.IntentQuery, Utterance: "что требует внимания",
|
||||||
|
}}); ok {
|
||||||
|
t.Fatal("an unconfigured praxis must not claim the turn")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The two boot paths meet here. run() wires the daemon twice: once at boot
|
||||||
|
// when a key is in the environment, and once inside UnlockFn after a passkey
|
||||||
|
// assertion, minutes or days later. Listing the same wiring in both places is
|
||||||
|
// what let them drift — seven workers started untracked on the unlock path and
|
||||||
|
// two daemonAPI fields were never set there, silently, for as long as anyone
|
||||||
|
// had been cold-starting (V-639).
|
||||||
|
//
|
||||||
|
// So both paths call newDaemonAPI and startBackground and nothing else. A
|
||||||
|
// field or a worker added later reaches both paths or neither.
|
||||||
|
|
||||||
|
// bootDeps is everything the two constructors below read. It is filled from
|
||||||
|
// the same variables on both paths, by depsNow in run().
|
||||||
|
type bootDeps struct {
|
||||||
|
coreFor func() ipc.CoreAPI
|
||||||
|
tl *tickLoop
|
||||||
|
evBus *event.Bus
|
||||||
|
voiceW *voiceWiring
|
||||||
|
st *store.Store
|
||||||
|
factWorker *factEnrichmentWorker
|
||||||
|
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
|
||||||
|
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
|
||||||
|
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDaemonAPI builds the real CoreAPI, with every field set. The unlock path
|
||||||
|
// used to leave nexus and getMCPServers nil, so after a cold start
|
||||||
|
// ResolveEntity refused with a nexus block configured and /tools rendered
|
||||||
|
// "not configured" with an mcp block configured. Empty is a wrong answer
|
||||||
|
// there, not a degraded one.
|
||||||
|
func newDaemonAPI(d bootDeps) *daemonAPI {
|
||||||
|
api := &daemonAPI{
|
||||||
|
CoreAPI: d.coreFor(),
|
||||||
|
getTrace: d.tl.trace,
|
||||||
|
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return d.tl.morningStatus(ctx, time.Now()) },
|
||||||
|
getDayPlan: func(ctx context.Context) ipc.DayPlan { return d.tl.dayPlan(ctx, time.Now()) },
|
||||||
|
getEvents: intakeEventsFn(d.evBus),
|
||||||
|
getDecisions: turnDecisionsFn(d.voiceW),
|
||||||
|
seedStore: seedStoreIfAllowed(d.st),
|
||||||
|
nexus: nexusOf(d.voiceW),
|
||||||
|
}
|
||||||
|
if d.voiceW != nil && d.voiceW.handler != nil {
|
||||||
|
api.chatFn = d.voiceW.handler.handleText
|
||||||
|
// And the reverse: the handler was wired with the bare store adapter,
|
||||||
|
// which cannot serve the day plan. See upgradeAPI.
|
||||||
|
d.voiceW.handler.upgradeAPI(api)
|
||||||
|
}
|
||||||
|
if d.voiceW != nil && d.voiceW.mcp != nil {
|
||||||
|
api.getMCPServers = d.voiceW.mcp.status
|
||||||
|
}
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
// namedWorker is one long-running goroutine. The name exists so the set is
|
||||||
|
// assertable from a test and readable in a log; nothing dispatches on it.
|
||||||
|
type namedWorker struct {
|
||||||
|
name string
|
||||||
|
run func(ctx context.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// backgroundWorkers lists what this deployment runs. It is pure — it starts
|
||||||
|
// nothing — so a test can compare the set the two paths would start without
|
||||||
|
// standing a daemon up.
|
||||||
|
func backgroundWorkers(d bootDeps) []namedWorker {
|
||||||
|
var ws []namedWorker
|
||||||
|
if d.voiceW != nil && d.voiceW.server != nil {
|
||||||
|
ws = append(ws, namedWorker{"voice", func(context.Context) {
|
||||||
|
if err := d.voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||||
|
log.Printf("voice serve: %v", err)
|
||||||
|
}
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
ws = append(ws,
|
||||||
|
namedWorker{"tick", d.tl.run},
|
||||||
|
namedWorker{"fact-enrichment", d.factWorker.run},
|
||||||
|
)
|
||||||
|
if d.evalWorker != nil {
|
||||||
|
ws = append(ws, namedWorker{"memory-eval", d.evalWorker.run})
|
||||||
|
}
|
||||||
|
if d.feedWkr != nil {
|
||||||
|
ws = append(ws, namedWorker{"feed", d.feedWkr.run})
|
||||||
|
}
|
||||||
|
if d.crawlWkr != nil {
|
||||||
|
ws = append(ws, namedWorker{"crawl", d.crawlWkr.run})
|
||||||
|
}
|
||||||
|
if d.voiceW != nil && d.voiceW.mcp != nil {
|
||||||
|
ws = append(ws, namedWorker{"mcp", d.voiceW.mcp.run})
|
||||||
|
}
|
||||||
|
if d.voiceW != nil && d.voiceW.home != nil {
|
||||||
|
ws = append(ws, namedWorker{"home", d.voiceW.home.run})
|
||||||
|
}
|
||||||
|
return ws
|
||||||
|
}
|
||||||
|
|
||||||
|
// startBackground starts every worker through goWorker, so waitWorkers can
|
||||||
|
// wait for it at shutdown. A worker started as a bare `go func()` is the
|
||||||
|
// shutdown bug documented at the end of run(): run() never returns, the
|
||||||
|
// deferred Close never seals the database, and the ciphertext goes stale.
|
||||||
|
func startBackground(ctx context.Context, wg *sync.WaitGroup, d bootDeps) {
|
||||||
|
for _, w := range backgroundWorkers(d) {
|
||||||
|
goWorker(wg, func() { w.run(ctx) })
|
||||||
|
}
|
||||||
|
if d.voiceW != nil && d.voiceW.server != nil {
|
||||||
|
log.Printf("mavend: voice listening on %s", d.voiceW.server.Addr())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/decision"
|
||||||
|
"github.com/kami/maven/internal/event"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fullDeps — a deployment with every optional piece present. Nothing here is
|
||||||
|
// run: newDaemonAPI takes method values and backgroundWorkers is pure, so
|
||||||
|
// zero-value wirings are enough to say what WOULD be started.
|
||||||
|
func fullDeps() bootDeps {
|
||||||
|
h := &reactiveHandler{
|
||||||
|
ecosystem: &ecosystemWiring{nexus: &nexusClient{}},
|
||||||
|
decisions: decision.NewRing(),
|
||||||
|
}
|
||||||
|
return bootDeps{
|
||||||
|
coreFor: func() ipc.CoreAPI { return ipc.UnimplementedCoreAPI{} },
|
||||||
|
tl: &tickLoop{},
|
||||||
|
evBus: event.NewBus(4),
|
||||||
|
st: &store.Store{},
|
||||||
|
factWorker: &factEnrichmentWorker{},
|
||||||
|
evalWorker: &memoryEvalWorker{},
|
||||||
|
feedWkr: &feedWorker{},
|
||||||
|
crawlWkr: &crawlWorker{},
|
||||||
|
voiceW: &voiceWiring{
|
||||||
|
server: &voice.Server{},
|
||||||
|
handler: h,
|
||||||
|
mcp: &mcpWiring{},
|
||||||
|
home: &homeWiring{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The unlock path used to build its own daemonAPI literal and leave nexus and
|
||||||
|
// getMCPServers nil (V-639). Both paths call newDaemonAPI now, so the drift
|
||||||
|
// that can still happen is a field added to the struct and not to the
|
||||||
|
// constructor. This catches that one, by name.
|
||||||
|
func TestNewDaemonAPISetsEveryField(t *testing.T) {
|
||||||
|
prev := allowSeedOnStart
|
||||||
|
allowSeedOnStart = true
|
||||||
|
defer func() { allowSeedOnStart = prev }()
|
||||||
|
|
||||||
|
api := newDaemonAPI(fullDeps())
|
||||||
|
v := reflect.ValueOf(*api)
|
||||||
|
for i := range v.NumField() {
|
||||||
|
if v.Field(i).IsZero() {
|
||||||
|
t.Errorf("newDaemonAPI left %s unset — a fully wired deployment must fill every field", v.Type().Field(i).Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handler is wired with the bare store adapter and cannot serve the day
|
||||||
|
// plan until upgradeAPI hands it the real one. The unlocked path did that and
|
||||||
|
// the unlock path did it too; keep it a property of the constructor.
|
||||||
|
func TestNewDaemonAPIUpgradesTheHandler(t *testing.T) {
|
||||||
|
d := fullDeps()
|
||||||
|
api := newDaemonAPI(d)
|
||||||
|
if d.voiceW.handler.api != ipc.CoreAPI(api) {
|
||||||
|
t.Fatal("newDaemonAPI did not hand the handler the API it built")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every worker the daemon runs goes through startBackground, so shutdown can
|
||||||
|
// wait for it. The unlock path used to start seven of these as bare
|
||||||
|
// `go func()` under a shadowed WaitGroup.
|
||||||
|
func TestBackgroundWorkersFullSet(t *testing.T) {
|
||||||
|
want := []string{"voice", "tick", "fact-enrichment", "memory-eval", "feed", "crawl", "mcp", "home"}
|
||||||
|
var got []string
|
||||||
|
for _, w := range backgroundWorkers(fullDeps()) {
|
||||||
|
got = append(got, w.name)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("workers = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A default box configures none of the optional blocks. Two workers always run
|
||||||
|
// and the rest stay dark, rather than a nil run being scheduled.
|
||||||
|
func TestBackgroundWorkersFloor(t *testing.T) {
|
||||||
|
d := fullDeps()
|
||||||
|
d.evalWorker, d.feedWkr, d.crawlWkr, d.voiceW = nil, nil, nil, nil
|
||||||
|
want := []string{"tick", "fact-enrichment"}
|
||||||
|
var got []string
|
||||||
|
for _, w := range backgroundWorkers(d) {
|
||||||
|
got = append(got, w.name)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Errorf("workers = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCalendarStepsAsideForTheWorld — the defect (Vikunja #552). Weather was
|
||||||
|
// one instance of a wider class, and V-474 fixed only that instance. Every one
|
||||||
|
// of these answered "на 05.08.2026 ничего нет" on the deployed daemon, and
|
||||||
|
// every one of them has an answer in search, which sits below the calendar.
|
||||||
|
func TestCalendarStepsAsideForTheWorld(t *testing.T) {
|
||||||
|
h, api := contQueryHandler()
|
||||||
|
for _, u := range []string{
|
||||||
|
"во сколько закат сегодня",
|
||||||
|
"какой сегодня курс доллара",
|
||||||
|
"какой сегодня праздник",
|
||||||
|
"что интересного произошло сегодня в мире",
|
||||||
|
} {
|
||||||
|
if reply, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||||
|
}); ok {
|
||||||
|
t.Errorf("the calendar claimed %q with %q", u, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if api.events != 0 {
|
||||||
|
t.Errorf("CalendarEvents called %d times for world questions, want 0", api.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half of the same narrowing: a question about his own day still
|
||||||
|
// reaches the calendar, including the one that names no subject at all.
|
||||||
|
func TestCalendarStillAnswersHisDay(t *testing.T) {
|
||||||
|
for _, u := range []string{
|
||||||
|
"что у меня сегодня",
|
||||||
|
"во сколько у меня встреча сегодня",
|
||||||
|
"какие встречи завтра",
|
||||||
|
"что в календаре на завтра",
|
||||||
|
"что сегодня?",
|
||||||
|
} {
|
||||||
|
h, _ := contQueryHandler()
|
||||||
|
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||||
|
}); !ok {
|
||||||
|
t.Errorf("the calendar passed on %q", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A continuation carries its subject in the turn before it, and the calendar
|
||||||
|
// is the only date-aware source, so the narrowing must not reach it.
|
||||||
|
func TestCalendarStillAnswersAContinuation(t *testing.T) {
|
||||||
|
h, _ := contQueryHandler()
|
||||||
|
if _, ok := h.queryCalendar(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: "а завтра?", Continued: true},
|
||||||
|
}); !ok {
|
||||||
|
t.Error("the calendar passed on a continuation")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The chat path must answer when llama-server is down (Vikunja #45 step 4).
|
||||||
|
// Both halves of a turn call the model — the router and the replier — and each
|
||||||
|
// has its own floor: the cascade falls to the classifier, the replier falls to
|
||||||
|
// the stub. This wires a client at a closed port so both floors are exercised
|
||||||
|
// by a dial error rather than by a stubbed error value.
|
||||||
|
func TestChatAnswersWithNoLlamaServer(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
|
||||||
|
emb := router.NewHashEmbedder(1024)
|
||||||
|
h.recall.embedder = emb
|
||||||
|
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+324
-69
@@ -6,6 +6,8 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
@@ -33,19 +35,10 @@ var wantedSlots = map[router.Intent][]dialogue.Slot{
|
|||||||
router.IntentAct: {dialogue.SlotFn},
|
router.IntentAct: {dialogue.SlotFn},
|
||||||
}
|
}
|
||||||
|
|
||||||
// clarifyQuestions — one short question per missing slot.
|
// The questions themselves live in clarifytemplates.go, one list per slot,
|
||||||
//
|
// picked by attempt (Vikunja #457). The first ask is the short one this map
|
||||||
// These are fixed templates, not model output. The resident model is a 0.8B; it
|
// used to hold; a re-ask says it differently, because a question he already
|
||||||
// would wander, and a question whose wording changes every time is harder to
|
// failed to answer is the worst one to repeat unchanged.
|
||||||
// answer than a blunt one that always reads the same. They are infinitive
|
|
||||||
// questions, so there is no gender agreement to get wrong; the feminine
|
|
||||||
// self-reference lives in the reply she gives when she drops the request.
|
|
||||||
var clarifyQuestions = map[dialogue.Slot]string{
|
|
||||||
dialogue.SlotTime: "Когда?",
|
|
||||||
dialogue.SlotText: "О чём напомнить?",
|
|
||||||
dialogue.SlotKey: "Что записать?",
|
|
||||||
dialogue.SlotFn: "Что сделать?",
|
|
||||||
}
|
|
||||||
|
|
||||||
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
// clarifyGaveUp — she is out of questions and still does not have the slot. She
|
||||||
// says so out loud: dropping the request in silence would leave him thinking it
|
// says so out loud: dropping the request in silence would leave him thinking it
|
||||||
@@ -74,8 +67,28 @@ var clarifyExpiredVariants = []string{
|
|||||||
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
"Столько времени прошло, что я отпустила прошлую просьбу. Скажи заново, если она в силе.",
|
||||||
}
|
}
|
||||||
|
|
||||||
// clarifyExpiredLine picks one of them at random.
|
// clarifyExpiredPluralVariants — the same notice when TWO parked requests died
|
||||||
func clarifyExpiredLine() string {
|
// 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))]
|
return clarifyExpiredVariants[rand.Intn(len(clarifyExpiredVariants))]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,38 +96,50 @@ func clarifyExpiredLine() string {
|
|||||||
// notice is glued in front of this turn's reply (see withNotice), so a caller
|
// 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.
|
// checking for it has to match a prefix, not the whole string.
|
||||||
func isClarifyExpired(s string) bool {
|
func isClarifyExpired(s string) bool {
|
||||||
for _, v := range clarifyExpiredVariants {
|
_, ok := cutClarifyExpired(s)
|
||||||
if strings.HasPrefix(s, v) {
|
return ok
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
// trimClarifyExpired strips a leading expiry notice, leaving this turn's actual
|
||||||
// reply. "" ⇒ the notice was the whole thing.
|
// reply. "" ⇒ the notice was the whole thing.
|
||||||
func trimClarifyExpired(s string) string {
|
func trimClarifyExpired(s string) string {
|
||||||
for _, v := range clarifyExpiredVariants {
|
rest, ok := cutClarifyExpired(s)
|
||||||
if strings.HasPrefix(s, v) {
|
if !ok {
|
||||||
return strings.TrimSpace(strings.TrimPrefix(s, v))
|
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
|
// clarifyExpiredNotice returns that line when a parked question had just timed
|
||||||
// out, and "" when nothing was parked. Call it right after
|
// out, and "" when nothing was parked. Call it right after
|
||||||
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
// resolveClarifyAnswer: a live question is answered there, an expired one is
|
||||||
// only reported here — the words themselves still go on to be routed fresh.
|
// only reported here — the words themselves still go on to be routed fresh.
|
||||||
func (h *reactiveHandler) clarifyExpiredNotice() string {
|
func (h *reactiveHandler) clarifyExpiredNotice(ctx context.Context) string {
|
||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if !h.clarifyStore.TakeExpired(voiceDialogueID, h.now()) {
|
n := h.clarifyStore.TakeExpired(dialogueIDOf(ctx), h.now())
|
||||||
|
if n == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
log.Printf("voice: clarify — parked question expired, telling him and routing the words fresh")
|
log.Printf("voice: clarify — %d parked question(s) expired, telling him and routing the words fresh", n)
|
||||||
return clarifyExpiredLine()
|
return clarifyExpiredLine(n)
|
||||||
}
|
}
|
||||||
|
|
||||||
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
// withNotice glues the expiry notice in front of this turn's reply. One turn
|
||||||
@@ -130,10 +155,52 @@ func withNotice(notice, reply string) string {
|
|||||||
return notice + " " + reply
|
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.
|
||||||
|
//
|
||||||
|
// A statement's full stop is folded into a comma, so the two acts read as one
|
||||||
|
// sentence — that is the owner's own punctuation, "в Риме сейчас ..., на какое
|
||||||
|
// время поставить напоминание?". An answer that is ITSELF a question keeps its
|
||||||
|
// mark and the resume starts a new sentence: she sometimes answers a side query
|
||||||
|
// by asking him to say it again, and "переформулировать?, на какое время" folds
|
||||||
|
// two questions into one unreadable line.
|
||||||
|
//
|
||||||
|
// 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 strings.HasSuffix(reply, "?") {
|
||||||
|
return reply + " " + resumed
|
||||||
|
}
|
||||||
|
if trimmed := strings.TrimRight(reply, ".!"); trimmed != "" {
|
||||||
|
reply = trimmed
|
||||||
|
}
|
||||||
|
return reply + ", " + lowerFirst(resumed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lowerFirst lowercases the opening rune, so a deck line written as a standalone
|
||||||
|
// sentence reads as the second half of one. Only the first rune: "На какое
|
||||||
|
// время" must become "на какое время" and nothing else in it may move.
|
||||||
|
func lowerFirst(s string) string {
|
||||||
|
for i, r := range s {
|
||||||
|
return string(unicode.ToLower(r)) + s[i+utf8.RuneLen(r):]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// missingFor returns the slots a decision still needs, most important first.
|
// missingFor returns the slots a decision still needs, most important first.
|
||||||
// Empty ⇒ there is nothing identifiable to ask about.
|
// Empty ⇒ there is nothing identifiable to ask about.
|
||||||
func missingFor(dec router.Decision) []dialogue.Slot {
|
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
|
// clarifyQuestion picks the one question to ask for a clarify decision. Returns
|
||||||
@@ -142,30 +209,49 @@ func missingFor(dec router.Decision) []dialogue.Slot {
|
|||||||
// One question about one thing: if two slots are missing she asks about the
|
// 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
|
// 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).
|
// 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)
|
missing := missingFor(dec)
|
||||||
if len(missing) == 0 {
|
if len(missing) == 0 {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
q, ok := clarifyQuestions[missing[0]]
|
q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "")
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
return missing[0], q, true
|
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
|
// askClarify parks the request and returns the question to ask instead of the
|
||||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||||
// the caller falls back to the canned reply.
|
// the caller falls back to the canned reply.
|
||||||
func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (string, bool) {
|
||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
slot, question, ok := clarifyQuestion(dec)
|
slot, question, ok := h.clarifyQuestion(dec)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
h.clarifyStore.Put(voiceDialogueID, &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),
|
Intent: dialogue.Intent(dec.Intent),
|
||||||
Slots: toDialogueSlots(dec.Slots),
|
Slots: toDialogueSlots(dec.Slots),
|
||||||
Missing: []dialogue.Slot{slot},
|
Missing: []dialogue.Slot{slot},
|
||||||
@@ -174,41 +260,164 @@ func (h *reactiveHandler) askClarify(dec router.Decision) (string, bool) {
|
|||||||
TTL: clarifyTTL,
|
TTL: clarifyTTL,
|
||||||
Attempts: 1, // this ask
|
Attempts: 1, // this ask
|
||||||
MaxAttempts: h.clarifyMaxAttempts,
|
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)
|
log.Printf("voice: clarify — asked about %s for intent=%s", slot, dec.Intent)
|
||||||
return question, true
|
return question, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveClarifyAnswer reads an utterance as the answer to a parked question.
|
// clarifyCancelled — he called the half-built request off. Said out loud, like
|
||||||
// Returns ("", false) when no live question is parked (or it expired), so the
|
// every other way it can end: a silent drop reads as "done". Feminine
|
||||||
// caller routes the utterance normally as a fresh request. Sibling of
|
// self-reference ("отменила"), as everywhere.
|
||||||
// resolveConfirm and checked in the same place.
|
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
|
// Only new_request and cancel reach this since V-561. A side query used to as
|
||||||
// she parked — no second parser. If it still does not fill the gap she asks
|
// well, and the owner rejected it on sight: he asks about the weather in the
|
||||||
// again, up to MaxAttempts; after that she says out loud that she did not
|
// 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.
|
// understand. She never drops the request in silence.
|
||||||
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
|
||||||
if h.clarifyStore == nil {
|
if h.clarifyStore == nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
|
||||||
if q == nil {
|
if q == nil {
|
||||||
return "", false
|
return "", false // not_applicable: nothing is pending
|
||||||
}
|
}
|
||||||
|
|
||||||
intent := router.Intent(q.Intent)
|
intent := router.Intent(q.Intent)
|
||||||
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
answer := h.extractor.Extract(ctx, intent, text, h.now())
|
||||||
|
|
||||||
|
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.clarifyStore.Delete(dialogueIDOf(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
|
||||||
|
}
|
||||||
|
|
||||||
merged := q.Answer(text, toDialogueSlots(answer))
|
merged := q.Answer(text, toDialogueSlots(answer))
|
||||||
// Fold a newly answered subject into the raw utterance. Downstream actions
|
// Fold a newly answered subject into the raw utterance. Downstream actions
|
||||||
// phrase from Utterance, not from the text slot — actionReminder stores it
|
// phrase from Utterance, not from the text slot — actionReminder stores it
|
||||||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||||||
// would fire at 11:00 saying "напомни" and nothing else.
|
// would fire at 11:00 saying "напомни" and nothing else.
|
||||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
// A fact answers with a key and a value and fills no Text slot at all, so
|
||||||
return h.reaskOrGiveUp(q, merged, text), true
|
// 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(voiceDialogueID)
|
// 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
|
||||||
|
}
|
||||||
|
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||||
|
|
||||||
// One gap filled is not the same as a complete request. askClarify parks
|
// 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
|
// only the first gap, because one question per turn is the rule, but a
|
||||||
@@ -217,7 +426,7 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
// a reminder with no time, which answered "не получилось разобрать время
|
// a reminder with no time, which answered "не получилось разобрать время
|
||||||
// напоминания." — an error for a request she never finished asking about.
|
// напоминания." — an error for a request she never finished asking about.
|
||||||
// Re-enter the loop instead, one question at a time as before.
|
// Re-enter the loop instead, one question at a time as before.
|
||||||
if reply, asked := h.askRemainingGap(q, intent, merged); asked {
|
if reply, asked := h.askRemainingGap(ctx, q, intent, merged); asked {
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +444,45 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
return h.finishClarified(ctx, dec), true
|
return h.finishClarified(ctx, dec), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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", q.Missing[0])
|
||||||
|
}
|
||||||
|
|
||||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||||
// unless they already carry it. "напомни" + "позвонить маме" reads as the
|
// unless they already carry it. "напомни" + "позвонить маме" reads as the
|
||||||
// request he would have made in one breath. Nothing is appended when the
|
// request he would have made in one breath. Nothing is appended when the
|
||||||
@@ -261,20 +509,23 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
|
|||||||
// The attempt budget is shared with the re-ask path on purpose. A second gap
|
// 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
|
// 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.
|
// still bounds how many times she can speak before acting or letting go.
|
||||||
func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
remaining := stillMissingFor(intent, whenTextOf(q), merged)
|
||||||
if len(remaining) == 0 {
|
if len(remaining) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
question, ok := clarifyQuestions[remaining[0]]
|
// 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 := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged, "")
|
||||||
if !ok || !q.CanAsk() {
|
if !ok || !q.CanAsk() {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
h.clarifyStore.Put(voiceDialogueID, &dialogue.PendingQuestion{
|
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||||
Intent: q.Intent,
|
Intent: q.Intent,
|
||||||
Slots: merged,
|
Slots: merged,
|
||||||
Missing: []dialogue.Slot{remaining[0]},
|
Missing: []dialogue.Slot{remaining[0]},
|
||||||
Utterance: q.Utterance,
|
Utterance: q.Utterance,
|
||||||
|
WhenText: q.WhenText,
|
||||||
Asked: h.now(),
|
Asked: h.now(),
|
||||||
TTL: clarifyTTL,
|
TTL: clarifyTTL,
|
||||||
Attempts: q.Attempts + 1,
|
Attempts: q.Attempts + 1,
|
||||||
@@ -287,13 +538,15 @@ func (h *reactiveHandler) askRemainingGap(q *dialogue.PendingQuestion, intent ro
|
|||||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
// 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
|
// 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".
|
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||||
func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
// 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 := ""
|
question := ""
|
||||||
if len(q.Missing) > 0 {
|
if len(q.Missing) > 0 {
|
||||||
question = clarifyQuestions[q.Missing[0]]
|
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken)
|
||||||
}
|
}
|
||||||
if question == "" || !q.CanAsk() {
|
if question == "" || !q.CanAsk() {
|
||||||
h.clarifyStore.Delete(voiceDialogueID)
|
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||||
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
log.Printf("voice: clarify — gave up on %v after %d question(s), answer was %q", q.Missing, q.Attempts, text)
|
||||||
return clarifyGaveUp
|
return clarifyGaveUp
|
||||||
}
|
}
|
||||||
@@ -302,7 +555,7 @@ func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dial
|
|||||||
q.Slots = merged
|
q.Slots = merged
|
||||||
q.Attempts++
|
q.Attempts++
|
||||||
q.Asked = h.now()
|
q.Asked = h.now()
|
||||||
h.clarifyStore.Put(voiceDialogueID, q)
|
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||||
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
log.Printf("voice: clarify — answer %q did not fill %v, asking again (attempt %d)", text, q.Missing, q.Attempts)
|
||||||
return question
|
return question
|
||||||
}
|
}
|
||||||
@@ -312,13 +565,13 @@ func (h *reactiveHandler) reaskOrGiveUp(q *dialogue.PendingQuestion, merged dial
|
|||||||
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decision) string {
|
||||||
if h.dialogueSessions != nil {
|
if h.dialogueSessions != nil {
|
||||||
now := h.now()
|
now := h.now()
|
||||||
prev := h.dialogueSessions.Get(voiceDialogueID, now)
|
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||||||
dec = followUpMerge(prev, dec, now)
|
dec = followUpMerge(prev, dec, now)
|
||||||
h.rememberTurn(prev, dec, now)
|
h.rememberTurn(ctx, prev, dec, now)
|
||||||
}
|
}
|
||||||
reply := h.applyAction(ctx, dec)
|
reply := h.applyAction(ctx, dec)
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
reply = h.replier.Reply(dec)
|
reply = h.replier.Reply(ctx, dec)
|
||||||
}
|
}
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
// Belt: an empty reply here would be a silent drop.
|
// Belt: an empty reply here would be a silent drop.
|
||||||
@@ -327,20 +580,22 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
|||||||
return reply
|
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
|
// 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
|
// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
|
||||||
// one long conversation can't grow the session unboundedly.
|
// 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
|
var history []dialogue.Turn
|
||||||
if prev != nil {
|
if prev != nil {
|
||||||
history = append(history, dialogue.Turn{
|
history = append(history, sessionAsTurn(prev))
|
||||||
Intent: prev.Intent,
|
|
||||||
Slots: prev.Slots,
|
|
||||||
Text: prev.Slots.Text,
|
|
||||||
})
|
|
||||||
maxHist := len(prev.History)
|
maxHist := len(prev.History)
|
||||||
if maxHist > 3 {
|
if maxHist > maxCarriedHistory {
|
||||||
maxHist = 3
|
maxHist = maxCarriedHistory
|
||||||
}
|
}
|
||||||
history = append(history, prev.History[:maxHist]...)
|
history = append(history, prev.History[:maxHist]...)
|
||||||
}
|
}
|
||||||
@@ -366,7 +621,7 @@ func (h *reactiveHandler) rememberTurn(prev *dialogue.Session, dec router.Decisi
|
|||||||
if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
|
if !dec.Continued && (dec.Intent == router.IntentSystem || dec.Intent == router.IntentQuery) {
|
||||||
slots.Text = dec.Utterance
|
slots.Text = dec.Utterance
|
||||||
}
|
}
|
||||||
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{
|
h.dialogueSessions.Put(dialogueIDOf(ctx), &dialogue.Session{
|
||||||
Intent: dialogue.Intent(dec.Intent),
|
Intent: dialogue.Intent(dec.Intent),
|
||||||
Slots: slots,
|
Slots: slots,
|
||||||
Timestamp: now,
|
Timestamp: now,
|
||||||
|
|||||||
+302
-63
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/phraser/eval"
|
"github.com/kami/maven/internal/phraser/eval"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -57,18 +58,26 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
asked bool
|
asked bool
|
||||||
}{
|
}{
|
||||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
|
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Сейчас 09:00. Когда?", true},
|
||||||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||||||
// A time with nothing to say at that time is still half a reminder, so
|
// A time with nothing to say at that time is still half a reminder, so
|
||||||
// the subject is what she asks about — not silence.
|
// 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 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},
|
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
||||||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||||||
}
|
}
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
_, got, asked := clarifyQuestion(tc.dec)
|
_, got, asked := h.clarifyQuestion(tc.dec)
|
||||||
if asked != tc.asked || got != tc.want {
|
if asked != tc.asked || got != tc.want {
|
||||||
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
|
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
|
||||||
}
|
}
|
||||||
@@ -81,12 +90,14 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
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)
|
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 {
|
if !handled {
|
||||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
t.Fatal("the answer to an open question must be consumed as an answer")
|
||||||
}
|
}
|
||||||
@@ -112,7 +123,7 @@ func TestClarifyFactCompletesOnAnswer(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши")); !asked {
|
||||||
t.Fatal("a fact with no key should be asked about")
|
t.Fatal("a fact with no key should be asked about")
|
||||||
}
|
}
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
if reply, handled := h.resolveClarifyAnswer(ctx, "пил воду"); !handled || reply == clarifyGaveUp {
|
||||||
@@ -128,7 +139,7 @@ func TestClarifyAnswerAfterTTLIsANewRequest(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, now := newClarifyHandler(t)
|
h, st, now := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
@@ -147,7 +158,7 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a first question")
|
t.Fatal("expected a first question")
|
||||||
}
|
}
|
||||||
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
// Two more unclear answers ⇒ two more questions (3 asks in total).
|
||||||
@@ -156,8 +167,14 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
|||||||
if !handled {
|
if !handled {
|
||||||
t.Fatalf("answer %d must be consumed as an answer", i)
|
t.Fatalf("answer %d must be consumed as an answer", i)
|
||||||
}
|
}
|
||||||
if reply != "Когда?" {
|
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||||
t.Fatalf("attempt %d should ask again, got %q", i, reply)
|
// question he already failed to answer is the worst way to ask it.
|
||||||
|
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, _ := 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 {
|
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||||
t.Fatalf("attempt %d must leave the question armed", i)
|
t.Fatalf("attempt %d must leave the question armed", i)
|
||||||
@@ -185,7 +202,7 @@ func TestClarifyMaxAttemptsIsConfigurable(t *testing.T) {
|
|||||||
h, _, _ := newClarifyHandler(t)
|
h, _, _ := newClarifyHandler(t)
|
||||||
h.clarifyMaxAttempts = 1
|
h.clarifyMaxAttempts = 1
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
if reply, handled := h.resolveClarifyAnswer(ctx, "ну не знаю"); !handled || reply != clarifyGaveUp {
|
||||||
@@ -199,7 +216,7 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
// First answer parses, but re-park it by hand as if she had asked again:
|
// First answer parses, but re-park it by hand as if she had asked again:
|
||||||
@@ -209,10 +226,11 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
|||||||
if q == nil {
|
if q == nil {
|
||||||
t.Fatal("expected an armed question")
|
t.Fatal("expected an armed question")
|
||||||
}
|
}
|
||||||
first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now())
|
first := h.extractor.Extract(ctx, router.IntentReminder, "сегодня в 11:00", h.now())
|
||||||
q.Slots = q.Answer("в 11:00", toDialogueSlots(first))
|
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)
|
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))
|
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||||
@@ -225,34 +243,35 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClarifiedActOffAllowlistIsStillRefused — clarification fills in an
|
// TestActOffAllowlistIsStillRefused — naming a capability is not being granted
|
||||||
// argument, it never grants authority.
|
// one. Since Vikunja #556 an unresolved act no longer parks a question, so this
|
||||||
func TestClarifiedActOffAllowlistIsStillRefused(t *testing.T) {
|
// goes through applyAction, which is the only way an act runs.
|
||||||
|
func TestActOffAllowlistIsStillRefused(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
marker := filepath.Join(t.TempDir(), "not-allowed-ran")
|
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(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
reply := h.applyAction(ctx, router.Decision{
|
||||||
t.Fatal("an act with no fn should be asked about")
|
Utterance: "rm " + marker,
|
||||||
}
|
Intent: router.IntentAct,
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "rm "+marker)
|
Slots: router.Slots{Fn: "rm " + marker, HasFn: true},
|
||||||
if !handled {
|
})
|
||||||
t.Fatal("the answer should be consumed")
|
|
||||||
}
|
|
||||||
if strings.Contains(reply, "готово") {
|
if strings.Contains(reply, "готово") {
|
||||||
t.Fatalf("an act that is not on the allowlist must not report success: %q", 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) {
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
||||||
t.Fatalf("a clarified act off the allowlist ran anyway: %v", 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 {
|
if tools, err := st.ListTools(ctx, "enabled"); err != nil || len(tools) != 1 {
|
||||||
t.Fatalf("clarify must not enable a tool: tools=%+v err=%v", tools, err)
|
t.Fatalf("an act must not enable a tool: tools=%+v err=%v", tools, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestClarifiedDestructiveActStillNeedsConfirm — the confirm gate survives the
|
// TestDestructiveActStillNeedsConfirm — the confirm gate stands on the act path.
|
||||||
// clarify path.
|
func TestDestructiveActStillNeedsConfirm(t *testing.T) {
|
||||||
func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
marker := filepath.Join(t.TempDir(), "destructive-ran")
|
marker := filepath.Join(t.TempDir(), "destructive-ran")
|
||||||
@@ -260,18 +279,16 @@ func TestClarifiedDestructiveActStillNeedsConfirm(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это")); !asked {
|
reply := h.applyAction(ctx, router.Decision{
|
||||||
t.Fatal("expected a question")
|
Utterance: "delete_backups",
|
||||||
}
|
Intent: router.IntentAct,
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "delete_backups")
|
Slots: router.Slots{Fn: "delete_backups", HasFn: true},
|
||||||
if !handled {
|
})
|
||||||
t.Fatal("the answer should be consumed")
|
|
||||||
}
|
|
||||||
if !strings.Contains(reply, "да") || h.pending == nil {
|
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) {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +301,7 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
|||||||
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
clarifyDec(router.IntentQuery, router.Slots{Text: "ммм"}, "ммм"),
|
||||||
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
clarifyDec(router.IntentNote, router.Slots{Text: "..."}, "..."),
|
||||||
} {
|
} {
|
||||||
if question, asked := h.askClarify(dec); asked {
|
if question, asked := h.askClarify(context.Background(), dec); asked {
|
||||||
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
t.Fatalf("intent %s should keep the canned reply, got %q", dec.Intent, question)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,29 +313,29 @@ func TestNoQuestionWhenNothingIsMissing(t *testing.T) {
|
|||||||
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
// TestClarifyExpiryIsAnnouncedAndWordsStillRoute — his answer lands after the
|
||||||
// TTL: she must say the old request is gone AND still answer the new words.
|
// TTL: she must say the old request is gone AND still answer the new words.
|
||||||
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||||
h, _, now := newClarifyHandler(t)
|
h, _, now := newClarifyHandler(t)
|
||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
h.embedder = emb
|
h.recall.embedder = emb
|
||||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
|
|
||||||
reply := h.handleText(ctx, "как дела")
|
reply := h.handleText(ctx, "", "как дела")
|
||||||
if !isClarifyExpired(reply) {
|
if !isClarifyExpired(reply) {
|
||||||
t.Fatalf("expired question must be announced first, got %q", reply)
|
t.Fatalf("expired question must be announced first, got %q", reply)
|
||||||
}
|
}
|
||||||
if trimClarifyExpired(reply) == "" {
|
if trimClarifyExpired(reply) == "" {
|
||||||
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
t.Fatalf("the new words must still be answered, got only the notice: %q", reply)
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||||
t.Fatal("the expired question must be gone")
|
t.Fatal("the expired question must be gone")
|
||||||
}
|
}
|
||||||
// The notice is said once, not on every later utterance.
|
// The notice is said once, not on every later utterance.
|
||||||
if reply := h.handleText(ctx, "как дела"); isClarifyExpired(reply) {
|
if reply := h.handleText(ctx, "", "как дела"); isClarifyExpired(reply) {
|
||||||
t.Fatalf("notice repeated on a later turn: %q", reply)
|
t.Fatalf("notice repeated on a later turn: %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,7 +357,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни"))
|
||||||
if !asked || question != "О чём напомнить?" {
|
if !asked || question != "О чём напомнить?" {
|
||||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||||
}
|
}
|
||||||
@@ -349,8 +366,11 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
|||||||
if !handled {
|
if !handled {
|
||||||
t.Fatal("the answer must be consumed as an answer")
|
t.Fatal("the answer must be consumed as an answer")
|
||||||
}
|
}
|
||||||
if reply != "Когда?" {
|
// Second gap, second attempt, so it is the second wording of the time
|
||||||
t.Fatalf("a filled subject with no time must ask about the time, got %q", reply)
|
// question — the attempt budget is shared between the two paths.
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
q := h.clarifyStore.Get(voiceDialogueID, h.now())
|
||||||
if q == nil {
|
if q == nil {
|
||||||
@@ -360,7 +380,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
|||||||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
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)
|
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))
|
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||||
@@ -380,7 +400,7 @@ func TestClarifySecondGapRespectsTheAttemptCap(t *testing.T) {
|
|||||||
h, _, _ := newClarifyHandler(t)
|
h, _, _ := newClarifyHandler(t)
|
||||||
h.clarifyMaxAttempts = 1
|
h.clarifyMaxAttempts = 1
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{}, "напомни")); !asked {
|
||||||
t.Fatal("expected the subject question")
|
t.Fatal("expected the subject question")
|
||||||
}
|
}
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
||||||
@@ -410,9 +430,10 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
|||||||
eval.CheckAddress: true,
|
eval.CheckAddress: true,
|
||||||
eval.CheckCringe: true,
|
eval.CheckCringe: true,
|
||||||
}
|
}
|
||||||
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
|
lines := append([]string{clarifyGaveUp, clarifyCancelled, clarifyDropped}, clarifyExpiredVariants...)
|
||||||
for _, q := range clarifyQuestions {
|
lines = append(lines, clarifyMissedVariants...)
|
||||||
lines = append(lines, q)
|
for _, variants := range clarifyQuestionVariants {
|
||||||
|
lines = append(lines, variants...)
|
||||||
}
|
}
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
for _, r := range eval.RunChecks(eval.Case{}, line, "neutral") {
|
||||||
@@ -440,10 +461,10 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
|
|||||||
// The confirm turn used to return before the notice was even computed, so he
|
// The confirm turn used to return before the notice was even computed, so he
|
||||||
// answered the confirm and never heard that the older request was let go.
|
// answered the confirm and never heard that the older request was let go.
|
||||||
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, ""))
|
||||||
h, _, now := newClarifyHandler(t)
|
h, _, now := newClarifyHandler(t)
|
||||||
|
|
||||||
if _, asked := h.askClarify(clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
}
|
}
|
||||||
// A confirm parked with a longer life than the question, so only the
|
// A confirm parked with a longer life than the question, so only the
|
||||||
@@ -451,7 +472,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
|||||||
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
h.pending = &pendingAct{fn: "delete_backups", phrase: "удалить бэкапы", expiry: now.Add(time.Hour)}
|
||||||
*now = now.Add(clarifyTTL + time.Second)
|
*now = now.Add(clarifyTTL + time.Second)
|
||||||
|
|
||||||
reply := h.handleText(ctx, "нет")
|
reply := h.handleText(ctx, "", "нет")
|
||||||
if !isClarifyExpired(reply) {
|
if !isClarifyExpired(reply) {
|
||||||
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
t.Fatalf("the expired question must be announced on a confirm turn too, got %q", reply)
|
||||||
}
|
}
|
||||||
@@ -461,7 +482,7 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
|||||||
if h.pending != nil {
|
if h.pending != nil {
|
||||||
t.Fatal("the confirm must still have been consumed")
|
t.Fatal("the confirm must still have been consumed")
|
||||||
}
|
}
|
||||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) != nil {
|
if h.clarifyStore.Get(textDialogueID, h.now()) != nil {
|
||||||
t.Fatal("the expired question must be gone")
|
t.Fatal("the expired question must be gone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,8 +497,8 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
|||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
at := h.now().Add(2 * time.Hour)
|
at := h.now().Add(2 * time.Hour)
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder,
|
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 != "О чём напомнить?" {
|
if !asked || question != "О чём напомнить?" {
|
||||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||||
}
|
}
|
||||||
@@ -497,7 +518,225 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
|||||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
if !strings.Contains(reminders[0].Payload, "маме") {
|
||||||
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
||||||
}
|
}
|
||||||
if !strings.Contains(reminders[0].Payload, "11") {
|
// The hour is the fire time, not a word in the body: the body is what she
|
||||||
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
// says at the hour, and the time expression is stripped out of it
|
||||||
|
// (Vikunja #469). Clobbering the parked request would show up here as a
|
||||||
|
// reminder that fires at some other time than the one he asked for.
|
||||||
|
if got := reminders[0].FireTs.UTC(); !got.Equal(at.UTC()) {
|
||||||
|
t.Fatalf("the answer clobbered the original request: fires at %v, want %v", got, at.UTC())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClarifyIsPerConversation — the parked question belongs to the reach that
|
||||||
|
// was asked. Before this the clarify store had one global key, so a question
|
||||||
|
// asked in the web chat and never answered captured the next utterance from
|
||||||
|
// telegram, or from the mic, and answered it against a request the speaker had
|
||||||
|
// never made (Vikunja #466).
|
||||||
|
func TestClarifyIsPerConversation(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
web := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||||
|
telegram := withDialogueID(context.Background(), dialogueIDFor(sourceText, "telegram:42"))
|
||||||
|
|
||||||
|
if _, asked := h.askClarify(web, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question on the web conversation")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveClarifyAnswer(telegram, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question asked on the web must not eat a telegram utterance")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveClarifyAnswer(voiceCtx(), "в 11:00"); handled {
|
||||||
|
t.Fatal("a question asked on the web must not eat what he says at the mic")
|
||||||
|
}
|
||||||
|
if reply, handled := h.resolveClarifyAnswer(web, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||||
|
t.Fatalf("the asker's own answer must land, handled=%v reply=%q", handled, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceCtx — the mic's conversation, which carries no id of its own.
|
||||||
|
func voiceCtx() context.Context {
|
||||||
|
return withDialogueID(context.Background(), dialogueIDFor(sourceVoice, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestARestartExpiresTheParkedQuestion pins the Vikunja #385 decision: the
|
||||||
|
// question dies with the process, and she does not claim to have let it go —
|
||||||
|
// the words that follow are routed as a fresh request. Restarting is modelled
|
||||||
|
// the way the daemon does it, by building a second handler over the same store.
|
||||||
|
func TestARestartExpiresTheParkedQuestion(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
ctx := voiceCtx()
|
||||||
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
|
t.Fatal("expected a question before the restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
restarted, _, _ := newClarifyHandler(t)
|
||||||
|
if _, handled := restarted.resolveClarifyAnswer(ctx, "в 11:00"); handled {
|
||||||
|
t.Fatal("a question parked before the restart must not eat the next utterance")
|
||||||
|
}
|
||||||
|
if notice := restarted.clarifyExpiredNotice(ctx); notice != "" {
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The clarify copy deck (Vikunja #457).
|
||||||
|
//
|
||||||
|
// Every clarify turn used to say one sentence per gap, and a re-ask repeated
|
||||||
|
// that sentence word for word. A question he already failed to answer is the
|
||||||
|
// worst one to ask again unchanged: the second wording is the one that tells
|
||||||
|
// him which part she missed.
|
||||||
|
//
|
||||||
|
// Fixed templates, not model output, for the reason clarifyQuestions has always
|
||||||
|
// given: the resident model would wander, and a question whose wording changes
|
||||||
|
// at random is harder to answer than a blunt one. What changes here is that the
|
||||||
|
// wording varies with the attempt rather than with a die roll — the first ask is
|
||||||
|
// short, the second names the gap, the third spells it out.
|
||||||
|
//
|
||||||
|
// No schema_version, unlike internal/phraser/nudge_templates.go. These are Go
|
||||||
|
// constants compiled into the daemon, so there is no file that can drift out of
|
||||||
|
// step with the code that reads it.
|
||||||
|
//
|
||||||
|
// Persona holds: infinitive and imperative questions, so there is no gender
|
||||||
|
// agreement to get wrong, "ты" throughout, and no pet names.
|
||||||
|
var clarifyQuestionVariants = map[dialogue.Slot][]string{
|
||||||
|
dialogue.SlotTime: {
|
||||||
|
"Когда?",
|
||||||
|
"Во сколько напомнить?",
|
||||||
|
"Скажи время — например, «в семь вечера» или «через час».",
|
||||||
|
},
|
||||||
|
dialogue.SlotText: {
|
||||||
|
"О чём напомнить?",
|
||||||
|
"Что сказать тебе в это время?",
|
||||||
|
"Скажи одной фразой, о чём напомнить.",
|
||||||
|
},
|
||||||
|
dialogue.SlotKey: {
|
||||||
|
"Что записать?",
|
||||||
|
"Что именно отметить?",
|
||||||
|
"Назови, что записать — например, «выпил воды».",
|
||||||
|
},
|
||||||
|
// 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: {
|
||||||
|
"Что сделать?",
|
||||||
|
"Какое действие выполнить?",
|
||||||
|
"Назови действие — я умею только то, что ты мне разрешил.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// wrapping would ask the same short question he has already not answered.
|
||||||
|
//
|
||||||
|
// Deterministic on purpose, unlike clarifyExpiredLine: an expiry notice is the
|
||||||
|
// same statement however it is worded, and a re-ask is not.
|
||||||
|
func clarifyQuestionFor(slot dialogue.Slot, attempt int) (string, bool) {
|
||||||
|
variants, ok := clarifyQuestionVariants[slot]
|
||||||
|
if !ok || len(variants) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
i := attempt - 1
|
||||||
|
if i < 0 {
|
||||||
|
i = 0
|
||||||
|
}
|
||||||
|
if i >= len(variants) {
|
||||||
|
i = len(variants) - 1
|
||||||
|
}
|
||||||
|
return variants[i], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedVariants — she is asking for the whole utterance again, because
|
||||||
|
// the gate fired on an intent with nothing identifiable to ask about (note,
|
||||||
|
// query, chat, system are not in wantedSlots).
|
||||||
|
//
|
||||||
|
// Rotated like the expiry lines and for the same reason: this is the line he
|
||||||
|
// hears whenever she misses him completely, so it is a line that repeats, and
|
||||||
|
// the same sentence every time is what makes a house assistant sound like a
|
||||||
|
// kiosk. All of them say the same two things — she did not catch it, and he
|
||||||
|
// should say it again — because the wording may vary and the meaning may not.
|
||||||
|
var clarifyMissedVariants = []string{
|
||||||
|
"Не совсем поняла — скажи, пожалуйста, ещё раз.",
|
||||||
|
"Я тебя не разобрала. Повтори, пожалуйста.",
|
||||||
|
"Не уловила. Скажи это по-другому?",
|
||||||
|
"Прости, не поняла — попробуй сказать иначе.",
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedFor picks a wording by the utterance itself, so the same words
|
||||||
|
// asked twice get the same answer and two different misses sound different.
|
||||||
|
//
|
||||||
|
// A hash, not rand: a test that drives an utterance twice must not depend on a
|
||||||
|
// die roll, and the point of rotating is only that consecutive misses differ.
|
||||||
|
func clarifyMissedFor(utterance string) string {
|
||||||
|
var sum int
|
||||||
|
for _, r := range utterance {
|
||||||
|
sum += int(r)
|
||||||
|
}
|
||||||
|
return clarifyMissedVariants[sum%len(clarifyMissedVariants)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// clarifyMissedLine is the canned reply for a clarify decision she cannot turn
|
||||||
|
// into a question. Returns "" for a decision that is not a clarify, so the
|
||||||
|
// caller keeps its own reply.
|
||||||
|
func clarifyMissedLine(dec router.Decision) string {
|
||||||
|
if !dec.Clarify {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return clarifyMissedFor(dec.Utterance)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every slot she can ask about has a wording for every attempt she is allowed,
|
||||||
|
// and no two attempts on one slot read the same. A deck with a repeated line is
|
||||||
|
// the defect this deck exists to fix (Vikunja #457).
|
||||||
|
func TestClarifyQuestionsVaryByAttempt(t *testing.T) {
|
||||||
|
for slot, variants := range clarifyQuestionVariants {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, v := range variants {
|
||||||
|
if v == "" {
|
||||||
|
t.Errorf("%s: empty wording in the deck", slot)
|
||||||
|
}
|
||||||
|
if seen[v] {
|
||||||
|
t.Errorf("%s: repeated wording %q", slot, v)
|
||||||
|
}
|
||||||
|
seen[v] = true
|
||||||
|
}
|
||||||
|
for attempt := 1; attempt <= len(variants); attempt++ {
|
||||||
|
got, ok := clarifyQuestionFor(slot, attempt)
|
||||||
|
if !ok || got != variants[attempt-1] {
|
||||||
|
t.Errorf("%s attempt %d = %q ok=%v, want %q", slot, attempt, got, ok, variants[attempt-1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the end she keeps the most explicit wording. Wrapping round would ask
|
||||||
|
// the short question he has already not answered twice.
|
||||||
|
func TestClarifyQuestionPastTheEndKeepsTheLastWording(t *testing.T) {
|
||||||
|
last := clarifyQuestionVariants[dialogue.SlotTime][len(clarifyQuestionVariants[dialogue.SlotTime])-1]
|
||||||
|
for _, attempt := range []int{0, 4, 9} {
|
||||||
|
if got, _ := clarifyQuestionFor(dialogue.SlotTime, attempt); attempt > 1 && got != last {
|
||||||
|
t.Errorf("attempt %d = %q, want the last wording %q", attempt, got, last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := clarifyQuestionFor("nonesuch", 1); ok {
|
||||||
|
t.Error("an unknown slot must have no question")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The missed line is stable for one utterance and absent for a decision that is
|
||||||
|
// not a clarify.
|
||||||
|
func TestClarifyMissedLine(t *testing.T) {
|
||||||
|
d := router.Decision{Clarify: true, Utterance: "мгм"}
|
||||||
|
first := clarifyMissedLine(d)
|
||||||
|
if first == "" || first != clarifyMissedLine(d) {
|
||||||
|
t.Fatalf("the missed line must be stable for one utterance, got %q", first)
|
||||||
|
}
|
||||||
|
if got := clarifyMissedLine(router.Decision{Intent: router.IntentNote}); got != "" {
|
||||||
|
t.Errorf("a decision that is not a clarify got %q", got)
|
||||||
|
}
|
||||||
|
// The empty utterance still gets a line: she has to say something.
|
||||||
|
if got := clarifyMissedLine(router.Decision{Clarify: true}); got == "" {
|
||||||
|
t.Error("an empty utterance must still be answered out loud")
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
-34
@@ -3,9 +3,14 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +24,14 @@ type pendingHexisExec struct {
|
|||||||
entityID string
|
entityID string
|
||||||
displayName string
|
displayName string
|
||||||
expiry time.Time
|
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
|
// 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
|
// resolveConfirm interprets an utterance as the answer to a parked destructive
|
||||||
// act OR a parked routine proposal. Returns (reply, true) when it consumed the
|
// 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
|
// 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
|
// parked act expired), so the caller routes the utterance normally.
|
||||||
// unrecognised answer cancels the pending and routes normally — a confirm that
|
//
|
||||||
// can't be answered clearly is safer abandoned than left armed.
|
// 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) {
|
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
|
||||||
|
verdict := classifyConfirm(text)
|
||||||
|
if verdict == confirmUnknown {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
|
||||||
@@ -67,16 +88,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri
|
|||||||
if !r.claim() {
|
if !r.claim() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// The slot is already cleared by claim(): every branch below drops the
|
// The slot is already cleared by claim().
|
||||||
// pending, including the unclear one — a confirm that can't be
|
switch verdict {
|
||||||
// answered clearly is safer abandoned than left armed.
|
|
||||||
switch classifyConfirm(text) {
|
|
||||||
case confirmYes:
|
case confirmYes:
|
||||||
return r.yes(), true
|
return r.yes(), true
|
||||||
case confirmNo:
|
|
||||||
return r.no(), true
|
|
||||||
default:
|
default:
|
||||||
return "", false
|
return r.no(), true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
@@ -118,13 +135,13 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
|||||||
//
|
//
|
||||||
// Acceptance itself is recorded by /routines, and the tick
|
// Acceptance itself is recorded by /routines, and the tick
|
||||||
// loop nudges on the interval from there (Vikunja #366).
|
// loop nudges on the interval from there (Vikunja #366).
|
||||||
return "поняла — подтверди на странице рутин, и начну напоминать."
|
return phraser.C(phraser.ConfirmRoutineAuthed, nil)
|
||||||
},
|
},
|
||||||
no: func() string {
|
no: func() string {
|
||||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
||||||
log.Printf("voice: dismiss proposed routine: %v", err)
|
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
|
// 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)
|
return hx != nil && !h.now().After(hx.expiry)
|
||||||
},
|
},
|
||||||
yes: func() string {
|
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.
|
// Tool confirm.
|
||||||
{
|
{
|
||||||
@@ -150,16 +171,16 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
log.Printf("voice: tool %s (confirmed): %v", p.fn, err)
|
||||||
if out != "" {
|
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 != "" {
|
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 {
|
func (h *reactiveHandler) proposeGap(ctx context.Context, dec router.Decision) string {
|
||||||
name := firstWord(stripWake(dec.Utterance))
|
name := firstWord(stripWake(dec.Utterance))
|
||||||
if name == "" {
|
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())
|
newly, err := h.api.ProposeTool(ctx, name, dec.Utterance, "", h.now())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: propose tool %q: %v", name, err)
|
log.Printf("voice: propose tool %q: %v", name, err)
|
||||||
return "команды «" + name + "» нет в списке разрешённых."
|
return phraser.C(phraser.ProposeFailed, vars)
|
||||||
}
|
}
|
||||||
if newly {
|
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.
|
// confirmVerdict — the parse of a y/n confirm answer.
|
||||||
@@ -192,23 +214,92 @@ const (
|
|||||||
confirmNo
|
confirmNo
|
||||||
)
|
)
|
||||||
|
|
||||||
// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the
|
// confirmWords are the two closed sets, tokenized once and ordered
|
||||||
// stems so inflections/fillers ("да, давай", "нет, отмени") still land.
|
// 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 {
|
func classifyConfirm(text string) confirmVerdict {
|
||||||
t := strings.ToLower(strings.TrimSpace(text))
|
tokens := confirmTokens(text)
|
||||||
// negatives first — "не надо" contains no "да", but check no-stems before
|
if len(tokens) == 0 {
|
||||||
// yes so a leading "нет" isn't shadowed.
|
return confirmUnknown
|
||||||
for _, no := range []string{"нет", "не надо", "отмен", "стоп", "no", "cancel", "stop", "don't"} {
|
}
|
||||||
if strings.Contains(t, no) {
|
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
|
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"} {
|
return verdict
|
||||||
if strings.Contains(t, yes) {
|
}
|
||||||
return confirmYes
|
|
||||||
|
// 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.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -149,12 +150,13 @@ func TestRememberTurnRefreshesTheTopic(t *testing.T) {
|
|||||||
now: func() time.Time { return contNow },
|
now: func() time.Time { return contNow },
|
||||||
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
dialogueSessions: dialogue.NewSessionStore(2 * time.Minute),
|
||||||
}
|
}
|
||||||
h.rememberTurn(nil, router.Decision{
|
ctx := context.Background()
|
||||||
|
h.rememberTurn(ctx, nil, router.Decision{
|
||||||
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
Intent: router.IntentQuery, Utterance: "во сколько у меня встреча",
|
||||||
}, contNow)
|
}, contNow)
|
||||||
// The second turn arrives with the first turn's Text already merged in.
|
// The second turn arrives with the first turn's Text already merged in.
|
||||||
prev := h.dialogueSessions.Get(voiceDialogueID, contNow)
|
prev := h.dialogueSessions.Get(voiceDialogueID, contNow)
|
||||||
h.rememberTurn(prev, router.Decision{
|
h.rememberTurn(ctx, prev, router.Decision{
|
||||||
Intent: router.IntentQuery,
|
Intent: router.IntentQuery,
|
||||||
Utterance: "какие у меня планы",
|
Utterance: "какие у меня планы",
|
||||||
Slots: router.Slots{Text: "во сколько у меня встреча"},
|
Slots: router.Slots{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):
|
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)
|
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
|
||||||
case errors.Is(err, webfetch.ErrStatus):
|
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, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
|
||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
+16
-11
@@ -125,13 +125,17 @@ func TestQueryWebPassesWithoutAURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A daemon where page reading was never turned on — the default — answers the
|
// A daemon where page reading was never turned on names the gap. He asked
|
||||||
// question the way it did before the capability existed. Claiming the turn to
|
// about one page, nothing else on the box can read it, and the old behaviour
|
||||||
// report a configuration status is for something that exists and failed.
|
// here was to answer as though the URL had not been said (Vikunja #479).
|
||||||
func TestQueryWebPassesWhenNotConfigured(t *testing.T) {
|
func TestQueryWebNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||||
h := buildWebHandler(nil)
|
h := buildWebHandler(nil)
|
||||||
if reply, ok := askWeb(h, "посмотри https://example.org/page"); ok {
|
reply, ok := askWeb(h, "посмотри https://example.org/page")
|
||||||
t.Fatalf("an unconfigured crawler claimed the turn with %q", reply)
|
if !ok {
|
||||||
|
t.Fatal("an unconfigured crawler let the page question fall through")
|
||||||
|
}
|
||||||
|
if !phraser.IsQ(phraser.QueryPageOff, nil, reply) {
|
||||||
|
t.Errorf("got %q, want the gap named", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,21 +160,22 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("the web source did not claim a question with a URL")
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "не получилось") {
|
if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
|
||||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// robots.txt is honoured on the answer path too, and she says so instead of
|
// robots.txt is honoured on the answer path too, and she says the page is
|
||||||
// reporting a generic failure.
|
// closed instead of reporting a generic failure.
|
||||||
func TestQueryWebObeysRobots(t *testing.T) {
|
func TestQueryWebObeysRobots(t *testing.T) {
|
||||||
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
|
||||||
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
reply, ok := askWeb(h, "посмотри https://example.org/private")
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("the web source did not claim a question with a URL")
|
t.Fatal("the web source did not claim a question with a URL")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "robots.txt") {
|
// She names the cause without reading a filename out loud.
|
||||||
t.Errorf("reply = %q, want the robots answer", reply)
|
if !strings.Contains(reply, "закрыта для чтения") || strings.Contains(reply, "robots") {
|
||||||
|
t.Errorf("reply = %q, want the closed-page answer with no filename", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/calendar"
|
"github.com/kami/maven/internal/calendar"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"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/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
@@ -110,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
|
// A question that is not about the plan must fall through, or the plan buries
|
||||||
// the calendar listing and the weather behind it.
|
// the calendar listing and the weather behind it.
|
||||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||||
@@ -147,8 +232,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||||
}
|
}
|
||||||
if reply != "не получилось собрать план." {
|
if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
||||||
t.Errorf("reply = %q", reply)
|
t.Errorf("reply = %q, want the honest failure", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair",
|
||||||
|
"repair-negative", "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),
|
||||||
|
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,807 @@
|
|||||||
|
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)
|
||||||
|
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{},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- rows below carry the CORRECT expectation and fail today ----
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
skip: "the offline floor cannot route «у меня новый ноутбук» confidently; needs the resident model",
|
||||||
|
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",
|
||||||
|
skip: "V-560: a cancel is scored as a failed answer, not as a cancel",
|
||||||
|
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",
|
||||||
|
skip: "V-560: clarify pre-empts the repair marker, so a correction cannot be spoken mid-flow",
|
||||||
|
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{"купить молоко"}},
|
||||||
|
},
|
||||||
|
// A reminder said whole, in one breath, with the hour in it — and she
|
||||||
|
// asks when. ReminderGrammar (stage0.go) builds its slots by hand and
|
||||||
|
// never runs the extractor, so a stage-0 reminder carries no time
|
||||||
|
// whatever the sentence says, and the clarify gate reads the gap as
|
||||||
|
// real. It costs a turn on the commonest reminder shape there is.
|
||||||
|
//
|
||||||
|
// Hermetic despite the date parser: stage 0 calls no parser at all, so
|
||||||
|
// this fails the same way with or without python dateparser installed.
|
||||||
|
{
|
||||||
|
name: "a reminder said whole is not asked about",
|
||||||
|
skip: "V-562: a stage-0 decision never meets the extractor, so its slots are never validated",
|
||||||
|
turns: []turn{
|
||||||
|
{say: "напомни в 11:00 позвонить маме", contains: []string{"11:00"}, noQuestion: true},
|
||||||
|
},
|
||||||
|
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||||
|
},
|
||||||
|
// 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",
|
||||||
|
skip: "V-562: finishClarified skips the clarify gate, so a repaired decision is never checked for gaps",
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+217
-107
@@ -12,6 +12,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
hexisclient "github.com/kami/hexis/pkg/client"
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
@@ -42,28 +43,100 @@ const ecosystemAPIVersion = "v1"
|
|||||||
// anonymous HTTP client.
|
// anonymous HTTP client.
|
||||||
const mavenRequester = "maven"
|
const mavenRequester = "maven"
|
||||||
|
|
||||||
// setEcosystemHeaders stamps the version, requester, auth and correlation
|
// ecosystemHTTP is the JSON transport every ecosystem client shares: one base
|
||||||
// headers common to every outgoing ecosystem request. token may be empty,
|
// URL, one bearer token, and the header set the contract requires on each
|
||||||
// which means the transport itself is trusted (loopback or unix socket).
|
// 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
|
// The correlation ID is read from the request's own context and never minted
|
||||||
// one per request sent the far side an ID that existed nowhere on this side,
|
// here. Minting one per request sent the far side an ID that existed nowhere on
|
||||||
// and gave a single multi-hop action as many unrelated IDs as it made calls.
|
// this side, and gave a single multi-hop action as many unrelated IDs as it
|
||||||
// Callers that start an action assign the ID once (handleHexisAct,
|
// made calls. Callers that start an action assign the ID once (handleHexisAct,
|
||||||
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
|
// 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("Content-Type", "application/json")
|
||||||
req.Header.Set(versionHeader, ecosystemAPIVersion)
|
req.Header.Set(t.versionHeader, ecosystemAPIVersion)
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
req.Header.Set("X-Requested-By", mavenRequester)
|
req.Header.Set("X-Requested-By", mavenRequester)
|
||||||
if token != "" {
|
if t.token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+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)
|
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
|
// ecosystemError is the typed failure every ecosystem client returns, so
|
||||||
// callers can tell a transport failure from a refusal from a contract
|
// callers can tell a transport failure from a refusal from a contract
|
||||||
// mismatch without matching on message text. The distinction matters:
|
// 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 {
|
type nexusClient struct {
|
||||||
baseURL string
|
ecosystemHTTP
|
||||||
token string
|
|
||||||
httpClient *http.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newNexusClient(url string) *nexusClient {
|
func newNexusClient(url string) *nexusClient {
|
||||||
return &nexusClient{
|
return &nexusClient{newEcosystemHTTP("nexus", "X-Nexus-Version", url)}
|
||||||
baseURL: url,
|
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// withToken sets the bearer token sent on every request. Returns the client so
|
// 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
|
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
|
var result nexusResolveResult
|
||||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
if err := c.postJSON(ctx, "resolve", "/api/v1/resolve", body, &result); err != nil {
|
||||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *nexusClient) Health(ctx context.Context) error {
|
func (c *nexusClient) Health(ctx context.Context) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
return c.getJSON(ctx, "health", "/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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// praxisClient talks to the Praxis HTTP tools API. Maven must not open Praxis's
|
// 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),
|
// SQLite store directly (ecosystem invariant: no component reads another's DB),
|
||||||
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
|
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
|
||||||
type praxisClient struct {
|
type praxisClient struct {
|
||||||
baseURL string
|
ecosystemHTTP
|
||||||
token string
|
|
||||||
httpClient *http.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newPraxisClient(url string) *praxisClient {
|
func newPraxisClient(url string) *praxisClient {
|
||||||
return &praxisClient{
|
return &praxisClient{newEcosystemHTTP("praxis", "X-Praxis-Version", url)}
|
||||||
baseURL: url,
|
|
||||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *praxisClient) withToken(token string) *praxisClient {
|
func (c *praxisClient) withToken(token string) *praxisClient {
|
||||||
@@ -239,42 +319,85 @@ func (c *praxisClient) withToken(token string) *praxisClient {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// getJSON performs a GET and decodes the JSON body into out. op is the logical
|
// praxisAttention — an attention response in either of the two shapes Praxis
|
||||||
// operation name for errors and traces: the path carries the query string, and
|
// may send (Vikunja #540).
|
||||||
// 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.
|
// ECOSYSTEM-SPEC §2.6 says the response carries `degraded: [source_ids]` when a
|
||||||
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
|
// source is failed or stale, and that Maven is required to say so rather than
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
// report all-clear. The deployed Praxis answers with a bare JSON array and no
|
||||||
if err != nil {
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
a.Items, a.Degraded = env.Items, env.Degraded
|
||||||
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}
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *praxisClient) ListAttention(ctx context.Context, limit int) ([]map[string]any, error) {
|
func (c *praxisClient) ListAttention(ctx context.Context, limit int) (praxisAttention, error) {
|
||||||
var out []map[string]any
|
var out praxisAttention
|
||||||
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
err := c.getJSON(ctx, "attention", fmt.Sprintf("/api/v1/tools/attention?limit=%d", limit), &out)
|
||||||
return out, err
|
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
|
// ListAttentionForEntity is ListAttention scoped to a single canonical Nexus
|
||||||
// entity, so callers already holding a resolved entity_id (e.g. after
|
// entity, so callers already holding a resolved entity_id (e.g. after
|
||||||
// resolveEntityReference) can ask "what needs attention for this entity"
|
// resolveEntityReference) can ask "what needs attention for this entity"
|
||||||
// instead of filtering the unscoped list client-side.
|
// instead of filtering the unscoped list client-side.
|
||||||
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) ([]map[string]any, error) {
|
func (c *praxisClient) ListAttentionForEntity(ctx context.Context, entityID string, limit int) (praxisAttention, error) {
|
||||||
var out []map[string]any
|
var out praxisAttention
|
||||||
err := c.getJSON(ctx, "attention_for_entity",
|
err := c.getJSON(ctx, "attention_for_entity",
|
||||||
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
fmt.Sprintf("/api/v1/tools/attention?limit=%d&entity_id=%s", limit, url.QueryEscape(entityID)), &out)
|
||||||
return out, err
|
return out, err
|
||||||
@@ -309,31 +432,14 @@ type praxisItem struct {
|
|||||||
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
|
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
|
||||||
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
|
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
|
||||||
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
|
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.
|
// postItem posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||||
// Every failure is a *ecosystemError, including the transport and decode ones:
|
func (c *praxisClient) postItem(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
var out praxisItem
|
var out praxisItem
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
if err := c.postJSON(ctx, op, path, payload, &out); err != nil {
|
||||||
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
return nil, err
|
||||||
}
|
}
|
||||||
return &out, nil
|
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) {
|
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) {
|
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)
|
caps, err := w.hexis.Capabilities(ctx, entityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = hexisError("capabilities", err)
|
||||||
log.Printf("ecosystem: hexis capabilities error: %v", err)
|
log.Printf("ecosystem: hexis capabilities error: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -500,7 +607,10 @@ func (w *ecosystemWiring) executeCapability(ctx context.Context, capabilityID, t
|
|||||||
|
|
||||||
exec, err := w.hexis.Execute(ctx, req)
|
exec, err := w.hexis.Execute(ctx, req)
|
||||||
if err != nil {
|
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" {
|
if exec.Status == "succeeded" {
|
||||||
return correlationID, nil
|
return correlationID, nil
|
||||||
|
|||||||
+394
-75
@@ -5,14 +5,42 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
hexisclient "github.com/kami/hexis/pkg/client"
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
|
"github.com/kami/maven/internal/tool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The three services, spelled the way she says them out loud. A service that is
|
||||||
|
// down or refusing has to be named: they degrade independently, so "не
|
||||||
|
// отвечает" on its own tells him nothing he can act on, and each call site
|
||||||
|
// already knows which one it was talking to — it records the same name in the
|
||||||
|
// trace (Vikunja #521).
|
||||||
|
const (
|
||||||
|
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
|
// 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
|
// 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
|
// verb aliases it answers to, the trace name it records, and its own reply
|
||||||
@@ -90,6 +118,13 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
|||||||
ctx = withCorrelationID(ctx, newCorrelationID())
|
ctx = withCorrelationID(ctx, newCorrelationID())
|
||||||
}
|
}
|
||||||
px := h.ecosystem.praxis
|
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 _, capability := range praxisCapabilities {
|
||||||
for _, alias := range capability.aliases() {
|
for _, alias := range capability.aliases() {
|
||||||
if alias == dec.Slots.Fn {
|
if alias == dec.Slots.Fn {
|
||||||
@@ -104,10 +139,16 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
|||||||
// praxisItemAction is the shared shape of the item-lifecycle capabilities: take
|
// 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.
|
// an item id from the value slot, call one Praxis endpoint, trace the result.
|
||||||
type praxisItemAction struct {
|
type praxisItemAction struct {
|
||||||
verbs []string
|
verbs []string
|
||||||
ask string // reply when no item id was given
|
ask string // reply when no item id was given
|
||||||
op string // trace + log name of the operation
|
op string // trace + log name of the operation
|
||||||
failure string // reply when the Praxis call errors
|
// 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
|
success string
|
||||||
call func(ctx context.Context, px *praxisClient, id string) error
|
call func(ctx context.Context, px *praxisClient, id string) error
|
||||||
}
|
}
|
||||||
@@ -124,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)
|
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
|
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
|
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})
|
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
|
||||||
return a.success
|
return a.success
|
||||||
@@ -139,24 +180,34 @@ func (listAttentionCapability) aliases() []string {
|
|||||||
|
|
||||||
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, px *praxisClient, _ router.Decision) string {
|
||||||
started := h.now()
|
started := h.now()
|
||||||
items, err := px.ListAttention(ctx, 20)
|
att, err := px.ListAttention(ctx, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ecosystem: praxis attention: %v", err)
|
log.Printf("ecosystem: praxis attention: %v", err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "list_attention", traceStatusForError(err),
|
||||||
started, traceErrorFields(err))
|
started, traceErrorFields(err))
|
||||||
return "не могу сейчас узнать, что требует внимания."
|
return phraser.A(phraser.AttentionFail, nil)
|
||||||
}
|
}
|
||||||
|
items := att.Items
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return "ничего не требует внимания."
|
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)})
|
h.recordPraxisTrace(ctx, "list_attention", started, map[string]any{"count": len(items)})
|
||||||
var parts []string
|
var parts []string
|
||||||
|
var spoken []string
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
title, _ := item["title"].(string)
|
title, _ := item["title"].(string)
|
||||||
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
|
// importance arrives as JSON number ⇒ float64 over the HTTP contract.
|
||||||
importance, _ := item["importance"].(float64)
|
importance, _ := item["importance"].(float64)
|
||||||
rule, _ := item["rule"].(string)
|
rule, _ := item["rule"].(string)
|
||||||
s := title
|
s := title
|
||||||
|
if s == "" {
|
||||||
|
// An item Praxis returned without a title is not an item she can
|
||||||
|
// read out. Counting it would put an empty slot in the list.
|
||||||
|
continue
|
||||||
|
}
|
||||||
if importance > 0 {
|
if importance > 0 {
|
||||||
s += fmt.Sprintf(" (важность %d", int(importance))
|
s += fmt.Sprintf(" (важность %d", int(importance))
|
||||||
if rule != "" {
|
if rule != "" {
|
||||||
@@ -166,16 +217,20 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
|||||||
}
|
}
|
||||||
parts = append(parts, s)
|
parts = append(parts, s)
|
||||||
|
|
||||||
// Speaking an item surfaces it, it does not acknowledge it
|
// Recorded in the order she says them, and only for items she could
|
||||||
// (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort:
|
// say: an item skipped above has no position in what he heard (#516).
|
||||||
// a failed surface call must not block delivering the digest.
|
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||||
if id, ok := item["id"].(string); ok && id != "" {
|
spoken = append(spoken, id)
|
||||||
if _, err := px.Surface(ctx, id); err != nil {
|
|
||||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "требует внимания: " + strings.Join(parts, "; ")
|
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
|
||||||
|
// its own label and a colon (Vikunja #521).
|
||||||
|
return phraser.A(phraser.AttentionNone, nil)
|
||||||
|
}
|
||||||
|
return phraser.A(phraser.AttentionList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||||
}
|
}
|
||||||
|
|
||||||
// listChangesCapability reads the recent-changes feed.
|
// listChangesCapability reads the recent-changes feed.
|
||||||
@@ -192,19 +247,29 @@ func (listChangesCapability) handle(ctx context.Context, h *reactiveHandler, px
|
|||||||
log.Printf("ecosystem: praxis changes: %v", err)
|
log.Printf("ecosystem: praxis changes: %v", err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "list_changes", traceStatusForError(err),
|
||||||
started, traceErrorFields(err))
|
started, traceErrorFields(err))
|
||||||
return "не могу сейчас узнать об изменениях."
|
return phraser.A(phraser.ChangesFail, nil)
|
||||||
}
|
}
|
||||||
if len(changes) == 0 {
|
if len(changes) == 0 {
|
||||||
return "нет изменений."
|
return phraser.A(phraser.ChangesNone, nil)
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
h.recordPraxisTrace(ctx, "list_changes", started, map[string]any{"count": len(changes)})
|
||||||
var parts []string
|
var parts []string
|
||||||
for _, c := range changes {
|
for _, c := range changes {
|
||||||
title, _ := c["title"].(string)
|
title, _ := c["title"].(string)
|
||||||
|
if title == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
typ, _ := c["change_type"].(string)
|
typ, _ := c["change_type"].(string)
|
||||||
|
if typ == "" {
|
||||||
|
parts = append(parts, title)
|
||||||
|
continue
|
||||||
|
}
|
||||||
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
parts = append(parts, fmt.Sprintf("%s (%s)", title, typ))
|
||||||
}
|
}
|
||||||
return "изменения: " + strings.Join(parts, "; ")
|
if len(parts) == 0 {
|
||||||
|
return phraser.A(phraser.ChangesNone, nil)
|
||||||
|
}
|
||||||
|
return phraser.A(phraser.ChangesList, map[string]string{"items": strings.Join(parts, "; ")})
|
||||||
}
|
}
|
||||||
|
|
||||||
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
// entityAttentionCapability answers "what's going on with X" by resolving X to
|
||||||
@@ -230,12 +295,12 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
subject = dec.Slots.Text
|
subject = dec.Slots.Text
|
||||||
}
|
}
|
||||||
if subject == "" {
|
if subject == "" {
|
||||||
return "про что именно спросить?"
|
return phraser.A(phraser.EcoAboutWhat, nil)
|
||||||
}
|
}
|
||||||
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
if h.ecosystem == nil || h.ecosystem.nexus == nil {
|
||||||
// Without Nexus there is no canonical ref to scope by. Say so rather
|
// Without Nexus there is no canonical ref to scope by. Say so rather
|
||||||
// than quietly answering about something else.
|
// than quietly answering about something else.
|
||||||
return "не могу связать это с сущностью — Nexus не настроен."
|
return phraser.A(phraser.EcoNoNexus, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
started := h.now()
|
started := h.now()
|
||||||
@@ -245,32 +310,27 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
// trace gets. A trace that stores a rune count next to a log line
|
// trace gets. A trace that stores a rune count next to a log line
|
||||||
// storing the runes is not redacted at all.
|
// storing the runes is not redacted at all.
|
||||||
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
|
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
|
||||||
if unauthorizedEcosystemError(err) {
|
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
|
||||||
}
|
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
|
||||||
}
|
}
|
||||||
if len(ambiguous) > 0 {
|
if len(ambiguous) > 0 {
|
||||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
return "не знаю такой сущности."
|
return phraser.A(phraser.EcoUnknownEntity, nil)
|
||||||
}
|
}
|
||||||
if displayName == "" {
|
if displayName == "" {
|
||||||
displayName = subject
|
displayName = subject
|
||||||
}
|
}
|
||||||
|
|
||||||
queried := h.now()
|
queried := h.now()
|
||||||
items, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
att, err := px.ListAttentionForEntity(ctx, entityID, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
log.Printf("ecosystem: praxis attention for %s: %v", entityID, err)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceStatusForError(err),
|
||||||
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
queried, mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||||
}
|
}
|
||||||
items, scoped := scopedToEntity(items, entityID)
|
items, scoped := scopedToEntity(att.Items, entityID)
|
||||||
if !scoped {
|
if !scoped {
|
||||||
// A Praxis old enough to ignore an unknown query parameter answers the
|
// A Praxis old enough to ignore an unknown query parameter answers the
|
||||||
// scoped question with the unscoped list. Reading that back as "по
|
// scoped question with the unscoped list. Reading that back as "по
|
||||||
@@ -279,33 +339,59 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
|||||||
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
log.Printf("ecosystem: praxis returned unscoped items for %s, refusing to answer", entityID)
|
||||||
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
h.recordEcosystemTrace(ctx, "praxis", "entity_attention", traceFailed, queried,
|
||||||
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
map[string]any{"entity_id": entityID, "class": "unscoped_response"})
|
||||||
return "не могу сейчас узнать, что требует внимания по «" + displayName + "»."
|
return phraser.A(phraser.AttentionFailEntity, map[string]string{"name": displayName})
|
||||||
}
|
}
|
||||||
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
h.recordPraxisTrace(ctx, "entity_attention", queried, map[string]any{
|
||||||
"entity_id": entityID, "count": len(items),
|
"entity_id": entityID, "count": len(items),
|
||||||
})
|
})
|
||||||
|
|
||||||
var parts []string
|
var parts []string
|
||||||
|
var spoken []string
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
title, _ := item["title"].(string)
|
title, _ := item["title"].(string)
|
||||||
if title == "" {
|
if title == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
parts = append(parts, title)
|
parts = append(parts, title)
|
||||||
// Same surfaced != acknowledged rule as the unscoped digest.
|
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||||
if id, ok := item["id"].(string); ok && id != "" {
|
spoken = append(spoken, id)
|
||||||
if _, err := px.Surface(ctx, id); err != nil {
|
|
||||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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 != "" {
|
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||||
parts = append(parts, known)
|
parts = append(parts, known)
|
||||||
}
|
}
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return "по «" + displayName + "» ничего нет."
|
// 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 "по «" + displayName + "»: " + strings.Join(parts, "; ")
|
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
|
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||||
@@ -370,7 +456,7 @@ func (h *reactiveHandler) localFactsForEntity(ctx context.Context, entityID stri
|
|||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
out := "я помню: " + strings.Join(parts, ", ")
|
out := phraser.A(phraser.EcoRecall, map[string]string{"items": strings.Join(parts, ", ")})
|
||||||
if more {
|
if more {
|
||||||
out += ", и это не всё"
|
out += ", и это не всё"
|
||||||
}
|
}
|
||||||
@@ -416,6 +502,14 @@ func traceStatusForError(err error) string {
|
|||||||
return traceFailed
|
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
|
// redactSubject reduces a user utterance to something safe to persist in a
|
||||||
// trace: its length only. Traces are diagnostics, and his words are not
|
// trace: its length only. Traces are diagnostics, and his words are not
|
||||||
// diagnostics — the correlation ID is what ties a trace to the turn.
|
// diagnostics — the correlation ID is what ties a trace to the turn.
|
||||||
@@ -474,29 +568,92 @@ func unauthorizedEcosystemError(err error) bool {
|
|||||||
return errors.As(err, &ee) && ee.Unauthorized()
|
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
|
// traceErrorFields describes an ecosystemError for a trace without leaking the
|
||||||
// payload: the HTTP status and the failure class, nothing else.
|
// payload: the HTTP status and the failure class, nothing else.
|
||||||
func traceErrorFields(err error) map[string]any {
|
func traceErrorFields(err error) map[string]any {
|
||||||
fields := map[string]any{}
|
fields := map[string]any{"class": "error"}
|
||||||
var ee *ecosystemError
|
var ee *ecosystemError
|
||||||
if errors.As(err, &ee) {
|
if !errors.As(err, &ee) {
|
||||||
fields["http_status"] = ee.Status
|
|
||||||
switch {
|
|
||||||
case ee.Unauthorized():
|
|
||||||
fields["class"] = "unauthorized"
|
|
||||||
case ee.ContractMismatch():
|
|
||||||
fields["class"] = "contract_mismatch"
|
|
||||||
case ee.Unreachable():
|
|
||||||
fields["class"] = "unreachable"
|
|
||||||
default:
|
|
||||||
fields["class"] = "error"
|
|
||||||
}
|
|
||||||
return fields
|
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
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entityResolution — what asking Nexus about a turn's candidate names came to.
|
||||||
|
// One shape rather than five return values, because the caller needs the
|
||||||
|
// reference that answered as well as the answer: it goes in the trace.
|
||||||
|
type entityResolution struct {
|
||||||
|
subject string // the reference Nexus answered about
|
||||||
|
entityID string // set when exactly one name resolved
|
||||||
|
displayName string // that entity's name as Nexus spells it
|
||||||
|
ambiguous []string // candidate display names to ask between
|
||||||
|
err error // a dependency failure, not a miss
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveEntityCandidates asks Nexus about each name the turn offered and
|
||||||
|
// reports what it knows, stopping early where the answer is already decided.
|
||||||
|
//
|
||||||
|
// The rules, in the order they apply:
|
||||||
|
//
|
||||||
|
// - A dependency failure ends it. Nexus being down is not "no such entity",
|
||||||
|
// and asking about the next name would report the outage as a miss.
|
||||||
|
// - Nexus calling one name ambiguous ends it. It has the candidates and it is
|
||||||
|
// telling us to ask.
|
||||||
|
// - Two names resolving to different entities is a clarify too, this time ours:
|
||||||
|
// "перезапусти nginx на muzick-indexer" names both a service and its host,
|
||||||
|
// and picking either would be inventing an intent he did not state.
|
||||||
|
// - Nothing resolving returns the first name as the subject, so the trace says
|
||||||
|
// what was actually looked for.
|
||||||
|
func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []string) entityResolution {
|
||||||
|
var out entityResolution
|
||||||
|
for _, ref := range refs {
|
||||||
|
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, ref, nil)
|
||||||
|
if err != nil {
|
||||||
|
return entityResolution{subject: ref, err: err}
|
||||||
|
}
|
||||||
|
if len(ambiguous) > 0 {
|
||||||
|
return entityResolution{subject: ref, ambiguous: ambiguous}
|
||||||
|
}
|
||||||
|
if entityID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if out.entityID == "" {
|
||||||
|
out = entityResolution{subject: ref, entityID: entityID, displayName: displayName}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if entityID == out.entityID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Both are real and they are not the same thing. Hand back the names
|
||||||
|
// Nexus spells, not the words he happened to say.
|
||||||
|
return entityResolution{
|
||||||
|
subject: out.subject,
|
||||||
|
ambiguous: []string{out.displayName, displayName},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.entityID == "" && len(refs) > 0 {
|
||||||
|
out.subject = refs[0]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// handleHexisAct — resolves entity references through Nexus and executes
|
// handleHexisAct — resolves entity references through Nexus and executes
|
||||||
// matching capabilities through Hexis. Returns a reply string when handled,
|
// matching capabilities through Hexis. Returns a reply string when handled,
|
||||||
// or "" to fall through to the system command executor.
|
// or "" to fall through to the system command executor.
|
||||||
@@ -514,28 +671,26 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
|
|
||||||
// Resolve the utterance text as an entity reference through Nexus. An
|
// Resolve the utterance text as an entity reference through Nexus. An
|
||||||
// ambiguous match must stop and clarify — never guess a mutation target.
|
// ambiguous match must stop and clarify — never guess a mutation target.
|
||||||
|
// The names come from entityReferences, not straight from the Text slot: the
|
||||||
|
// model transliterates Latin names as it routes (Vikunja #476, #524).
|
||||||
started := h.now()
|
started := h.now()
|
||||||
entityID, displayName, ambiguous, err := h.ecosystem.resolveEntityReference(ctx, dec.Slots.Text, nil)
|
res := h.resolveEntityCandidates(ctx, entityReferences(dec))
|
||||||
|
subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(dec.Slots.Text)}))
|
|
||||||
if unauthorizedEcosystemError(err) {
|
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
|
||||||
}
|
|
||||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||||
// and report degradation rather than silently falling through to the
|
// and report degradation rather than silently falling through to the
|
||||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||||
// independently, never a silent all-clear).
|
// independently, never a silent all-clear).
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||||
}
|
}
|
||||||
if len(ambiguous) > 0 {
|
if len(ambiguous) > 0 {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||||
map[string]any{"candidates": len(ambiguous)})
|
map[string]any{"candidates": len(ambiguous)})
|
||||||
return "уточни, что именно: " + strings.Join(ambiguous, ", ") + "?"
|
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||||
}
|
}
|
||||||
if entityID == "" {
|
if entityID == "" {
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceNotFound, started,
|
||||||
map[string]any{"subject": redactSubject(dec.Slots.Text)})
|
map[string]any{"subject": redactSubject(subject)})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceOK, started,
|
||||||
@@ -549,10 +704,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||||
if unauthorizedEcosystemError(err) {
|
return ecosystemGap(serviceHexis, err)
|
||||||
return "экосистема отклоняет доступ, проверь токен."
|
|
||||||
}
|
|
||||||
return "экосистема недоступна, попробуй ещё раз."
|
|
||||||
}
|
}
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||||
@@ -569,10 +721,21 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
}
|
}
|
||||||
verbLower := strings.ToLower(verb)
|
verbLower := strings.ToLower(verb)
|
||||||
|
|
||||||
|
// With no allowlisted fn the verb is a whole phrase ("restart status muzick
|
||||||
|
// indexer"), which no capability name ever contains. Read it the other way
|
||||||
|
// 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
|
||||||
var matches []*hexisclient.Capability
|
var matches []*hexisclient.Capability
|
||||||
for i, c := range caps {
|
for i, c := range caps {
|
||||||
if strings.Contains(strings.ToLower(c.Name), verbLower) ||
|
name := strings.ToLower(c.Name)
|
||||||
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower)) {
|
hit := strings.Contains(name, verbLower) ||
|
||||||
|
(c.Description != "" && strings.Contains(strings.ToLower(c.Description), verbLower))
|
||||||
|
if loose && name != "" && strings.Contains(verbLower, name) {
|
||||||
|
hit = true
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
matches = append(matches, &caps[i])
|
matches = append(matches, &caps[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,13 +747,30 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
names = append(names, m.Name)
|
names = append(names, m.Name)
|
||||||
}
|
}
|
||||||
return "какую команду для " + displayName + ": " + strings.Join(names, ", ") + "?"
|
return phraser.A(phraser.ActWhich, map[string]string{"name": displayName, "items": strings.Join(names, ", ")})
|
||||||
}
|
}
|
||||||
matched := matches[0]
|
matched := matches[0]
|
||||||
|
|
||||||
// Read-only capabilities run immediately; mutating ones are parked for an
|
// Read-only capabilities run immediately; mutating ones are parked for an
|
||||||
// explicit spoken confirm bound to this capability + target.
|
// explicit spoken confirm bound to this capability + target.
|
||||||
if !matched.ReadOnly {
|
// The tier decides, and Hexis owns the tier (Vikunja #523). read_only alone
|
||||||
|
// used to decide it here, which flattened three answers into two: a
|
||||||
|
// capability that wipes the thing it names got the same single spoken "да"
|
||||||
|
// as one that restarts a service, and requires_confirmation — which the
|
||||||
|
// Hexis contract calls server-derived and not settable by a caller — was
|
||||||
|
// read by nobody. docs/ecosystem.md §17.3 says confirmation follows risk.
|
||||||
|
tier := tool.RiskOfCapability(matched.Risk, matched.ReadOnly, matched.RequiresConfirmation)
|
||||||
|
policy := tool.PolicyFor(tier)
|
||||||
|
if !policy.VoiceMayRun {
|
||||||
|
// Irreversible. A confirm turn would not help, for the same reason it
|
||||||
|
// does not help a local row: the STT heard it, the model routed it and
|
||||||
|
// a substring matched the capability, and a spoken "да" checks none of
|
||||||
|
// those. She names the gap and he runs it himself.
|
||||||
|
h.recordEcosystemTrace(ctx, "hexis", "confirmation", traceRefused, started,
|
||||||
|
map[string]any{"entity_id": entityID, "capability": matched.Name, "risk": string(tier)})
|
||||||
|
return phraser.A(phraser.ActNeedsAuthedSurface, nil)
|
||||||
|
}
|
||||||
|
if policy.Confirm {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.pendingHexis = &pendingHexisExec{
|
h.pendingHexis = &pendingHexisExec{
|
||||||
capabilityID: matched.ID,
|
capabilityID: matched.ID,
|
||||||
@@ -598,11 +778,14 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
|||||||
entityID: entityID,
|
entityID: entityID,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
expiry: h.now().Add(confirmTTL),
|
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.mu.Unlock()
|
||||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||||
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
map[string]any{"entity_id": entityID, "capability": matched.Name})
|
||||||
return "выполнить «" + matched.Name + "» для " + displayName + "? скажи «да» или «нет»."
|
return phraser.A(phraser.ActConfirmEntity, map[string]string{"name": matched.Name, "name_entity": displayName})
|
||||||
}
|
}
|
||||||
|
|
||||||
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
return h.execHexis(ctx, matched.ID, matched.Name, entityID, displayName)
|
||||||
@@ -622,7 +805,15 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
mergeFields(traceErrorFields(err), map[string]any{
|
mergeFields(traceErrorFields(err), map[string]any{
|
||||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||||
}))
|
}))
|
||||||
return "не получилось выполнить команду для " + displayName + "."
|
// 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
|
// One record per hop: the second write this used to make said the same
|
||||||
// thing under a different key, in a different shape.
|
// thing under a different key, in a different shape.
|
||||||
@@ -630,5 +821,133 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
|||||||
"entity_id": entityID, "entity_name": displayName,
|
"entity_id": entityID, "entity_name": displayName,
|
||||||
"capability": capName, "causation_id": causationID,
|
"capability": capName, "causation_id": causationID,
|
||||||
})
|
})
|
||||||
return "команда выполнена для " + displayName + "."
|
return phraser.A(phraser.ActDoneEntity, map[string]string{"name": displayName})
|
||||||
|
}
|
||||||
|
|
||||||
|
// hexisBeforeClarify gives an entity-shaped act one chance at Hexis before she
|
||||||
|
// asks what to do.
|
||||||
|
//
|
||||||
|
// The stage-3 gate thins an act that never matched an allowlisted fn, so
|
||||||
|
// "перезапусти muzick indexer" was answered with "Что сделать?" and the Hexis
|
||||||
|
// path was never entered — the capability existed and no utterance could reach
|
||||||
|
// it (Vikunja #476). Hexis is exactly where an act with no local fn belongs:
|
||||||
|
// the verb is matched against the capabilities Hexis registers for the entity,
|
||||||
|
// not against the allowlist.
|
||||||
|
//
|
||||||
|
// Narrow on purpose. Only an act, only when the fn slot is still empty, and
|
||||||
|
// only when Hexis is wired — a box with no ecosystem asks the question it
|
||||||
|
// always asked. A "" back means Nexus knew no such entity or Hexis had no
|
||||||
|
// matching capability, and then she asks after all. Authority is unchanged:
|
||||||
|
// 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 {
|
||||||
|
if h.ecosystem == nil || h.ecosystem.hexis == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return h.handleHexisAct(ctx, dec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// attentionCannotTell returns the hedge to say instead of an all-clear, or ""
|
||||||
|
// when an empty attention list really does mean nothing needs looking at
|
||||||
|
// (ECOSYSTEM-SPEC §2.6, Vikunja #540).
|
||||||
|
//
|
||||||
|
// "Nothing needs attention" and "I cannot currently tell" are different answers
|
||||||
|
// and only one of them was ever said. The spec's mechanism is a `degraded` array
|
||||||
|
// on the attention response, which the deployed Praxis does not send, so the
|
||||||
|
// source health read is the half that works today. It costs one HTTP call and
|
||||||
|
// only on the empty-list turn, which is the only turn where an all-clear is at
|
||||||
|
// stake.
|
||||||
|
//
|
||||||
|
// A failed sources read is deliberately NOT a hedge. The attention call itself
|
||||||
|
// succeeded, and not being able to ask about health is not evidence of a fault —
|
||||||
|
// hedging on it would turn one flaky endpoint into a permanently uncertain
|
||||||
|
// assistant.
|
||||||
|
func (h *reactiveHandler) attentionCannotTell(ctx context.Context, px *praxisClient, degraded []string, started time.Time) string {
|
||||||
|
if len(degraded) > 0 {
|
||||||
|
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||||
|
"degraded": strings.Join(degraded, ","), "source": "response",
|
||||||
|
})
|
||||||
|
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(degraded, ", ")})
|
||||||
|
}
|
||||||
|
bad, total, err := px.UnhealthySources(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ecosystem: praxis sources: %v", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
// A Praxis that polls nothing knows nothing, so its silence is not an
|
||||||
|
// all-clear either. This is the state the box is in as of 2026-08-05:
|
||||||
|
// /api/v1/sources answers with an empty array.
|
||||||
|
h.recordPraxisTrace(ctx, "attention_no_sources", started, map[string]any{"sources": 0})
|
||||||
|
return phraser.A(phraser.AttentionNoSources, nil)
|
||||||
|
}
|
||||||
|
if len(bad) > 0 {
|
||||||
|
h.recordPraxisTrace(ctx, "attention_degraded", started, map[string]any{
|
||||||
|
"degraded": strings.Join(bad, ","), "sources": total, "source": "health",
|
||||||
|
})
|
||||||
|
return phraser.A(phraser.AttentionDegraded, map[string]string{"items": strings.Join(bad, ", ")})
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// rememberSurfaced records the item ids she just read out, replacing whatever the
|
||||||
|
// previous digest left. Called with the ids in speaking order (Vikunja #516).
|
||||||
|
func (h *reactiveHandler) rememberSurfaced(ids []string) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.surfacedItems = ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSurfacedPosition turns a positional item reference into a Praxis item
|
||||||
|
// id, using the list she last read out.
|
||||||
|
//
|
||||||
|
// The router names a position and not an id, because only the daemon has the
|
||||||
|
// list: PraxisGrammars fills the value slot with "2", "last" or "this". An id is
|
||||||
|
// left alone, since "item_ab12" is already one.
|
||||||
|
//
|
||||||
|
// The second return says whether the turn is still Praxis's. A position that
|
||||||
|
// names nothing keeps the turn and clears the slot, so the capability answers its
|
||||||
|
// own "какой пункт?" — he said "второй пункт" and deserves to hear that there is
|
||||||
|
// no second one. A demonstrative that resolves to nothing gives the turn BACK,
|
||||||
|
// because "я это сделал" was probably never about a пункт at all. "это" also
|
||||||
|
// needs the list to hold exactly one item: pointing at one of five is a guess,
|
||||||
|
// and a wrong guess here transitions the wrong item.
|
||||||
|
func (h *reactiveHandler) resolveSurfacedPosition(dec router.Decision) (router.Decision, bool) {
|
||||||
|
ref := dec.Slots.Value
|
||||||
|
if ref == "" || strings.HasPrefix(ref, "item") {
|
||||||
|
return dec, true
|
||||||
|
}
|
||||||
|
h.mu.Lock()
|
||||||
|
ids := h.surfacedItems
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
|
idx := -1
|
||||||
|
switch {
|
||||||
|
case ref == "this":
|
||||||
|
if len(ids) != 1 {
|
||||||
|
log.Printf("ecosystem: praxis \"это\" has no single item (%d surfaced)", len(ids))
|
||||||
|
return dec, false
|
||||||
|
}
|
||||||
|
idx = 0
|
||||||
|
case ref == "last":
|
||||||
|
idx = len(ids) - 1
|
||||||
|
default:
|
||||||
|
n, err := strconv.Atoi(ref)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
// Neither a position nor an id: leave it for the capability to
|
||||||
|
// reject rather than silently rewriting what he said.
|
||||||
|
return dec, true
|
||||||
|
}
|
||||||
|
idx = n - 1
|
||||||
|
}
|
||||||
|
if idx < 0 || idx >= len(ids) {
|
||||||
|
log.Printf("ecosystem: praxis position %q has no item (%d surfaced)", ref, len(ids))
|
||||||
|
dec.Slots.Value = ""
|
||||||
|
return dec, true
|
||||||
|
}
|
||||||
|
dec.Slots.Value = ids[idx]
|
||||||
|
return dec, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
|||||||
// A Nexus outage during a Hexis act writes a failure trace, and a shared
|
// 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.
|
// store is the one thing the Praxis path could inherit it through.
|
||||||
nexus.SetFault(503)
|
nexus.SetFault(503)
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); actRan(reply) {
|
||||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
if len(tracesFor(t, h, "nexus", "resolve")) == 0 {
|
||||||
@@ -117,7 +117,7 @@ func TestEcosystem_OutagesLeaveNoSharedFailureState(t *testing.T) {
|
|||||||
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
if reply := h.handlePraxisAct(ctx, praxisActDec("list_attention")); strings.Contains(reply, "disk") {
|
||||||
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
t.Fatalf("praxis outage must not serve content, got %q", reply)
|
||||||
}
|
}
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
t.Fatalf("a praxis outage must not block the hexis path, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +154,7 @@ func TestEcosystem_ResolvedWithoutEntityFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
t.Fatal("a resolve with no entity must degrade, not fall through to local execution")
|
||||||
}
|
}
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
t.Fatalf("a resolve with no entity must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -212,7 +212,7 @@ func TestEcosystem_MalformedNexusResponseFailsClosed(t *testing.T) {
|
|||||||
|
|
||||||
nexus.SetBody(`{"status":"resolved","entity":`)
|
nexus.SetBody(`{"status":"resolved","entity":`)
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
if reply == "" || actRan(reply) {
|
||||||
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
t.Fatalf("malformed nexus body must degrade, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -232,7 +232,7 @@ func TestEcosystem_UnknownContractFieldsTolerated(t *testing.T) {
|
|||||||
nexus := newFakeNexus(t, body)
|
nexus := newFakeNexus(t, body)
|
||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
t.Fatalf("%s contract shape must still resolve and execute, got %q", name, reply)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -250,7 +250,7 @@ func TestEcosystem_CancelledContextDegrades(t *testing.T) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if reply == "" || strings.Contains(reply, "выполнена") {
|
if reply == "" || actRan(reply) {
|
||||||
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
t.Fatalf("cancelled resolve must degrade, got %q", reply)
|
||||||
}
|
}
|
||||||
if hexis.Count("", "/api/v1") != 0 {
|
if hexis.Count("", "/api/v1") != 0 {
|
||||||
@@ -268,7 +268,7 @@ func TestEcosystem_ExecutionFailureIsNotSuccess(t *testing.T) {
|
|||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("failed execution must not read as success, got %q", reply)
|
t.Fatalf("failed execution must not read as success, got %q", reply)
|
||||||
}
|
}
|
||||||
if reply == "" {
|
if reply == "" {
|
||||||
@@ -291,7 +291,7 @@ func TestEcosystem_SuccessfulActionWritesATrace(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("setup: expected success, got %q", reply)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
exec := tracesFor(t, h, "hexis", "execute")
|
exec := tracesFor(t, h, "hexis", "execute")
|
||||||
@@ -313,7 +313,7 @@ func TestEcosystem_TracesStayOutOfFacts(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("setup: expected success, got %q", reply)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
if len(traces(t, h)) == 0 {
|
if len(traces(t, h)) == 0 {
|
||||||
@@ -434,7 +434,7 @@ func TestEcosystem_TotalOutageSaysSoForEveryPath(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Errorf("%s: total outage must not answer with silence", name)
|
t.Errorf("%s: total outage must not answer with silence", name)
|
||||||
}
|
}
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Errorf("%s: total outage must not claim success: %q", name, reply)
|
t.Errorf("%s: total outage must not claim success: %q", name, 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"))
|
||||||
|
if !denied(serviceHexis, reply) {
|
||||||
|
t.Fatalf("401 from hexis discovery: got %q, want the denied line naming Hexis", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, serviceHexis) {
|
||||||
|
t.Errorf("reply does not name Hexis: %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHexisDiscoveryOutageIsDownNotDenied — the other half of the same fork.
|
||||||
|
// Without this the fix could pass by calling everything a refused credential.
|
||||||
|
func TestHexisDiscoveryOutageIsDownNotDenied(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||||
|
h := hexisGapHandler(t, nexus.URL, unreachableURL)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if !down(serviceHexis, reply) {
|
||||||
|
t.Fatalf("connection refused from hexis: got %q, want the outage line naming Hexis", reply)
|
||||||
|
}
|
||||||
|
if denied(serviceHexis, reply) {
|
||||||
|
t.Error("an outage must not be reported as a refused credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHexisExecute401IsDeniedNotCommandFailure — the execute hop, which did not
|
||||||
|
// consult ecosystemGap at all and named neither the service nor the cause.
|
||||||
|
func TestHexisExecute401IsDeniedNotCommandFailure(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||||
|
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||||
|
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||||
|
|
||||||
|
// Discovery stays healthy; only the execute endpoint refuses. A blanket
|
||||||
|
// fault would never reach the site under test.
|
||||||
|
hexis.SetRouteFault("/api/v1/execute", 401)
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if !denied(serviceHexis, reply) {
|
||||||
|
t.Fatalf("401 from hexis execute: got %q, want the denied line naming Hexis", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHexisExecuteOutageIsDown — same site, the other classification.
|
||||||
|
//
|
||||||
|
// Discovery and execution share one base URL, so the outage has to be scoped to
|
||||||
|
// the execute endpoint rather than to the server: it answers capabilities
|
||||||
|
// normally and drops the connection on execute, which is what the client sees
|
||||||
|
// when the far side dies mid-call. That produces no HTTP status at all, which is
|
||||||
|
// what Unreachable() means.
|
||||||
|
func TestHexisExecuteOutageIsDown(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||||
|
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||||
|
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/api/v1/execute" {
|
||||||
|
conn, _, err := w.(http.Hijacker).Hijack()
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("hijack: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(caps))
|
||||||
|
}))
|
||||||
|
t.Cleanup(hexis.Close)
|
||||||
|
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if !down(serviceHexis, reply) {
|
||||||
|
t.Fatalf("dropped connection on hexis execute: got %q, want the outage line", reply)
|
||||||
|
}
|
||||||
|
if denied(serviceHexis, reply) {
|
||||||
|
t.Error("an outage must not be reported as a refused credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHexisExecutionFailedStaysCommandFailure — the boundary of the fix. Hexis
|
||||||
|
// answering 200 with a failed execution is the command failing, not Hexis
|
||||||
|
// degrading, and it must keep the command-level line rather than accusing a
|
||||||
|
// healthy service of being down.
|
||||||
|
func TestHexisExecutionFailedStaysCommandFailure(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||||
|
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||||
|
hexis := newFakeHexis(t, caps, fixtureHexisExecutionFailed("exec_1", "unit refused to start"))
|
||||||
|
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if down(serviceHexis, reply) || denied(serviceHexis, reply) {
|
||||||
|
t.Fatalf("a failed execution must not be reported as an ecosystem gap, got %q", reply)
|
||||||
|
}
|
||||||
|
if !phraser.IsA(phraser.ActFailEntity, map[string]string{"name": muzickIndexer}, reply) {
|
||||||
|
t.Fatalf("want the command-failure line, got %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,13 +105,93 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
|||||||
|
|
||||||
nexus.SetFault(503)
|
nexus.SetFault(503)
|
||||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("nexus outage must not report success, got %q", reply)
|
t.Fatalf("nexus outage must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
nexus.SetFault(0)
|
nexus.SetFault(0)
|
||||||
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
reply = h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
if !strings.Contains(reply, "выполнена") {
|
if !actRan(reply) {
|
||||||
t.Fatalf("expected success once nexus recovers, got %q", 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"},
|
||||||
|
})
|
||||||
|
if !strings.Contains(reply, "indexer wedged") {
|
||||||
|
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.Lock()
|
||||||
|
surfaced := append([]string(nil), h.surfacedItems...)
|
||||||
|
h.mu.Unlock()
|
||||||
|
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
|
||||||
|
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The follow-up resolves against what he just heard, not the stale list.
|
||||||
|
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
|
||||||
|
t.Fatal("positional follow-up should have been claimed by praxis")
|
||||||
|
}
|
||||||
|
var body string
|
||||||
|
for _, r := range praxis.Requests() {
|
||||||
|
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
|
||||||
|
body = string(r.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "item_scoped_1") {
|
||||||
|
t.Fatalf("resolve must transition the item she read out, posted %q", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "item_stale") {
|
||||||
|
t.Fatal("resolve transitioned an item from a previous digest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
|
||||||
|
// later turn with a context of its own. The contract mints one id per action,
|
||||||
|
// so the execution it authorises must still be joinable to the resolve and the
|
||||||
|
// discovery that proposed it — it recorded a fresh id and no causation at all.
|
||||||
|
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||||
|
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
|
||||||
|
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||||
|
}
|
||||||
|
resolve := findTrace(t, h, "nexus", "resolve")
|
||||||
|
if resolve == nil || resolve.CorrelationID == "" {
|
||||||
|
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
|
||||||
|
t.Fatal("confirm should have been claimed")
|
||||||
|
}
|
||||||
|
exec := findTrace(t, h, "hexis", "execute")
|
||||||
|
if exec == nil {
|
||||||
|
t.Fatal("expected a hexis execute trace")
|
||||||
|
}
|
||||||
|
if exec.CausationID != resolve.CorrelationID {
|
||||||
|
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
|
||||||
|
exec.CausationID, resolve.CorrelationID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
hexisclient "github.com/kami/hexis/pkg/client"
|
hexisclient "github.com/kami/hexis/pkg/client"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ func TestHexisMutatingRequiresConfirm(t *testing.T) {
|
|||||||
|
|
||||||
// The follow-up "да" turn executes exactly the parked capability.
|
// The follow-up "да" turn executes exactly the parked capability.
|
||||||
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
confirmReply, handled := h.resolveConfirm(ctx, "да")
|
||||||
if !handled || !strings.Contains(confirmReply, "выполнена") {
|
if !handled || !actRan(confirmReply) {
|
||||||
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
t.Fatalf("confirm should execute, got handled=%v reply=%q", handled, confirmReply)
|
||||||
}
|
}
|
||||||
if !*executed {
|
if !*executed {
|
||||||
@@ -125,7 +126,7 @@ func TestHexisReadOnlyExecutesImmediately(t *testing.T) {
|
|||||||
if h.pendingHexis != nil {
|
if h.pendingHexis != nil {
|
||||||
t.Fatal("read-only cap should not park a confirmation")
|
t.Fatal("read-only cap should not park a confirmation")
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "выполнена") {
|
if !actRan(reply) {
|
||||||
t.Fatalf("unexpected reply %q", reply)
|
t.Fatalf("unexpected reply %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +187,7 @@ func TestHexisNexusErrorFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
t.Fatal("nexus dependency failure must not fall through with an empty reply")
|
||||||
}
|
}
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
t.Fatalf("nexus dependency failure must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@ func TestHexisUnavailableFailsClosed(t *testing.T) {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
t.Fatal("hexis dependency failure must not fall through with an empty reply")
|
||||||
}
|
}
|
||||||
if strings.Contains(reply, "выполнена") {
|
if actRan(reply) {
|
||||||
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
t.Fatalf("hexis dependency failure must not report success, got %q", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,3 +242,73 @@ func TestHexisNotFoundStillFallsThrough(t *testing.T) {
|
|||||||
t.Fatal("not_found resolution must never execute a hexis capability")
|
t.Fatal("not_found resolution must never execute a hexis capability")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// actRan — the reply is the line she says when a capability ran against an
|
||||||
|
// entity. The tests used to look for the substring "выполнена", which was a
|
||||||
|
// literal out of the act file: the review reworded that line to "готово: {name}"
|
||||||
|
// and seventeen assertions went with it (Vikunja #521).
|
||||||
|
func actRan(reply string) bool {
|
||||||
|
return phraser.IsA(phraser.ActDoneEntity, map[string]string{"name": muzickIndexer}, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// muzickIndexer — the display name every ecosystem fixture resolves to.
|
||||||
|
const muzickIndexer = "Muzick indexer"
|
||||||
|
|
||||||
|
// read_only used to be the whole decision on this path, which meant a
|
||||||
|
// capability that destroys what it names got the same single spoken "да" as one
|
||||||
|
// that restarts a service. Hexis declares the tier and the voice path is not an
|
||||||
|
// authorised surface for the top one (Vikunja #523).
|
||||||
|
func TestHexisIrreversibleCapabilityIsNotRunFromVoice(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
||||||
|
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"))
|
||||||
|
if *executed {
|
||||||
|
t.Fatal("an irreversible capability ran from the voice path")
|
||||||
|
}
|
||||||
|
if h.pendingHexis != nil {
|
||||||
|
t.Fatal("an irreversible capability parked a confirm; a spoken да is not enough authority")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "не вернуть") {
|
||||||
|
t.Errorf("reply = %q; want it to name why she will not run it", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other half: Hexis calling a capability safe is enough to run it, even
|
||||||
|
// though read_only is the field that used to decide. Nothing here re-derives.
|
||||||
|
func TestHexisSafeCapabilityRunsOnItsDeclaredTier(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
||||||
|
caps := `[{"id":"cap_status","name":"restart","read_only":true,"risk":"safe"}]`
|
||||||
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if !*executed {
|
||||||
|
t.Fatal("a capability Hexis calls safe should run")
|
||||||
|
}
|
||||||
|
if !actRan(reply) {
|
||||||
|
t.Fatalf("unexpected reply %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A mutating capability with no declared tier keeps the confirm turn it has
|
||||||
|
// always had, so the split does not quietly loosen an existing box.
|
||||||
|
func TestHexisUndeclaredTierStillConfirms(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolved := `{"status":"resolved","entity":{"id":"ent_muzick","display_name":"Muzick indexer","type":"service"}}`
|
||||||
|
caps := `[{"id":"cap_restart","name":"restart","read_only":false}]`
|
||||||
|
h, executed := newHexisTestHandler(t, resolved, caps)
|
||||||
|
|
||||||
|
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||||
|
if *executed {
|
||||||
|
t.Fatal("a mutating capability ran without a confirm")
|
||||||
|
}
|
||||||
|
if h.pendingHexis == nil {
|
||||||
|
t.Fatal("a mutating capability did not park a confirm")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "да или нет") {
|
||||||
|
t.Errorf("reply = %q; want the confirm question", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ func TestEcosystemTrace_SuccessfulActionTracesEveryHop(t *testing.T) {
|
|||||||
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h := ecoHandler(t, nexus, nil, hexis)
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !strings.Contains(reply, "выполнена") {
|
if reply := h.handleHexisAct(ctx, actDec("muzick indexer")); !actRan(reply) {
|
||||||
t.Fatalf("setup: expected success, got %q", reply)
|
t.Fatalf("setup: expected success, got %q", reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// latinRun matches a run of Latin-script words — the shape a service, host or
|
||||||
|
// project name takes in a Russian sentence. Digits, dot, dash and underscore
|
||||||
|
// ride along because "muzick-indexer" and "nginx.conf" are one name, not two.
|
||||||
|
var latinRun = regexp.MustCompile(`[A-Za-z][A-Za-z0-9._-]*(?:\s+[A-Za-z][A-Za-z0-9._-]*)*`)
|
||||||
|
|
||||||
|
// maxEntityReferences caps how many names one utterance may send to Nexus. The
|
||||||
|
// cap is not about correctness, it is about one turn not fanning out into a
|
||||||
|
// dozen HTTP calls when the utterance is a paragraph of English.
|
||||||
|
const maxEntityReferences = 4
|
||||||
|
|
||||||
|
// hasLatin reports whether s carries a Latin letter.
|
||||||
|
func hasLatin(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if unicode.In(r, unicode.Latin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// entityReferences returns the names Nexus is asked to resolve, in the order
|
||||||
|
// they were said.
|
||||||
|
//
|
||||||
|
// Normally there is one, and it is the router's Text slot — the verb phrase the
|
||||||
|
// model wrote. But the resident model rewrites a Russian utterance as it routes,
|
||||||
|
// and on the way it transliterates: "перезапусти muzick indexer" came back as
|
||||||
|
// "перезагрузить музик индексер" (Vikunja #476). Nexus is then asked for a
|
||||||
|
// service nobody has ever named, so the act cannot resolve its target even with
|
||||||
|
// every gate open.
|
||||||
|
//
|
||||||
|
// The recovery is deliberately narrow. Only when the utterance holds a Latin run
|
||||||
|
// and the model's Text holds none has a name certainly been rewritten. Anything
|
||||||
|
// else keeps the Text slot, so an English utterance and a Russian entity name
|
||||||
|
// are both untouched. Un-transliterating the Cyrillic back is not attempted: the
|
||||||
|
// surface form he said is right there, and guessing at a reverse mapping would
|
||||||
|
// invent a second name to be wrong about.
|
||||||
|
//
|
||||||
|
// What this does NOT do is pick. It used to return the longest run, and length
|
||||||
|
// is a guess: "перезапусти nginx на muzick-indexer" has two names in it and the
|
||||||
|
// longer one is not reliably the target. Nexus owns which names it knows
|
||||||
|
// (docs/ecosystem.md — ambiguous resolution asks the owner, it does not pick),
|
||||||
|
// so every run goes over and Nexus answers. Two runs that both resolve are a
|
||||||
|
// clarify, not a coin toss.
|
||||||
|
func entityReferences(dec router.Decision) []string {
|
||||||
|
text := dec.Slots.Text
|
||||||
|
if hasLatin(text) || !hasLatin(dec.Utterance) {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
var refs []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range latinRun.FindAllString(dec.Utterance, -1) {
|
||||||
|
m = strings.TrimSpace(m)
|
||||||
|
// A single stray letter is not a name.
|
||||||
|
if len(m) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(m)
|
||||||
|
if seen[key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = true
|
||||||
|
refs = append(refs, m)
|
||||||
|
if len(refs) == maxEntityReferences {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(refs) == 0 {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEntityReferences pins when his own words win over the model's, and that
|
||||||
|
// every name he said goes over rather than one of them being picked.
|
||||||
|
func TestEntityReferences(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
utterance string
|
||||||
|
text string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "the model transliterated the name",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить музик индексер",
|
||||||
|
want: []string{"muzick indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "it kept the name, so nothing to repair",
|
||||||
|
utterance: "перезапусти muzick indexer",
|
||||||
|
text: "перезагрузить muzick indexer",
|
||||||
|
want: []string{"перезагрузить muzick indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an all-Russian entity name is not a rewrite",
|
||||||
|
utterance: "перезапусти домашний сервер",
|
||||||
|
text: "перезагрузить домашний сервер",
|
||||||
|
want: []string{"перезагрузить домашний сервер"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an English turn never enters the recovery",
|
||||||
|
utterance: "restart muzick indexer",
|
||||||
|
text: "restart muzick indexer",
|
||||||
|
want: []string{"restart muzick indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both names go over, in the order he said them",
|
||||||
|
utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста",
|
||||||
|
text: "перезагрузить нгинкс",
|
||||||
|
want: []string{"nginx", "muzick-indexer"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one stray letter is not a name",
|
||||||
|
utterance: "перезапусти сервер a",
|
||||||
|
text: "перезагрузить сервер",
|
||||||
|
want: []string{"перезагрузить сервер"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the same name twice is one question",
|
||||||
|
utterance: "перезапусти nginx, ну правда, nginx",
|
||||||
|
text: "перезагрузить нгинкс",
|
||||||
|
want: []string{"nginx"},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}}
|
||||||
|
got := entityReferences(dec)
|
||||||
|
if len(got) != len(tc.want) {
|
||||||
|
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != tc.want[i] {
|
||||||
|
t.Fatalf("entityReferences = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the
|
||||||
|
// router hands over a transliterated Text, and Nexus must still be asked about
|
||||||
|
// the service that exists.
|
||||||
|
func TestNexusIsAskedForTheNameHeSaid(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
h.handleHexisAct(ctx, dec)
|
||||||
|
|
||||||
|
reqs := nexus.Requests()
|
||||||
|
if len(reqs) == 0 {
|
||||||
|
t.Fatal("nexus was never asked")
|
||||||
|
}
|
||||||
|
body := string(reqs[0].Body)
|
||||||
|
if !strings.Contains(body, "muzick indexer") {
|
||||||
|
t.Fatalf("nexus resolve body = %s, want the name he said", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The
|
||||||
|
// stage-3 gate thins an act with no allowlisted fn, and that question used to
|
||||||
|
// be the whole turn, so the Hexis path was unreachable from voice or chat.
|
||||||
|
func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "restart status muzick indexer"},
|
||||||
|
}
|
||||||
|
reply := h.hexisBeforeClarify(ctx, dec)
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question")
|
||||||
|
}
|
||||||
|
if hexis.Count("", "/api/v1") == 0 {
|
||||||
|
t.Fatal("hexis was never contacted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change:
|
||||||
|
// she asks exactly what she asked before.
|
||||||
|
func TestClarifyStillAsksWithoutHexis(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти muzick indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Stage: 3,
|
||||||
|
Clarify: true,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить музик индексер"},
|
||||||
|
}
|
||||||
|
if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" {
|
||||||
|
t.Fatalf("no hexis must mean no reply, got %q", reply)
|
||||||
|
}
|
||||||
|
if _, asked := h.askClarify(voiceCtx(), dec); !asked {
|
||||||
|
t.Fatal("she must still ask what to do")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func nexusInOrder(t *testing.T, bodies ...string) *fakeServer {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
n := 0
|
||||||
|
return newFakeServer(t, map[string]http.HandlerFunc{
|
||||||
|
"POST /api/v1/resolve": func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
body := bodies[min(n, len(bodies)-1)]
|
||||||
|
n++
|
||||||
|
mu.Unlock()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoResolvedNamesAsk — «перезапусти nginx на muzick-indexer» names a
|
||||||
|
// service and the host it runs on. Both are real, and which one he meant is not
|
||||||
|
// in the utterance, so she asks. Picking one by length was the old behaviour and
|
||||||
|
// length is not evidence (Vikunja #524).
|
||||||
|
func TestTwoResolvedNamesAsk(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := nexusInOrder(t,
|
||||||
|
fixtureNexusResolved("ent_nginx", "nginx", "service"),
|
||||||
|
fixtureNexusResolved("ent_host", "Muzick indexer", "device"),
|
||||||
|
)
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти nginx на muzick-indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
reply := h.handleHexisAct(ctx, dec)
|
||||||
|
if !strings.Contains(reply, "nginx") || !strings.Contains(reply, "Muzick indexer") {
|
||||||
|
t.Fatalf("reply = %q, want both names she found", reply)
|
||||||
|
}
|
||||||
|
if hexis.Count("POST", "/api/v1/execute") != 0 {
|
||||||
|
t.Fatal("she must not execute against a target she is still asking about")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTheNameNexusKnowsWins — the other half. Two names go over and only one is
|
||||||
|
// an entity, so there is nothing to ask about and the act runs.
|
||||||
|
func TestTheNameNexusKnowsWins(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
nexus := nexusInOrder(t,
|
||||||
|
fixtureNexusNotFound(),
|
||||||
|
fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"),
|
||||||
|
)
|
||||||
|
hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
|
h := ecoHandler(t, nexus, nil, hexis)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: "перезапусти nginx на muzick-indexer",
|
||||||
|
Intent: router.IntentAct,
|
||||||
|
Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true},
|
||||||
|
}
|
||||||
|
reply := h.handleHexisAct(ctx, dec)
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("the resolvable name must carry the act")
|
||||||
|
}
|
||||||
|
if len(nexus.Requests()) != 2 {
|
||||||
|
t.Fatalf("nexus asked %d times, want both names", len(nexus.Requests()))
|
||||||
|
}
|
||||||
|
if hexis.Count("POST", "/api/v1/execute") == 0 {
|
||||||
|
t.Fatal("hexis was never asked to run it")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,11 +28,10 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
|
|||||||
|
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
embedder: emb,
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: rtr,
|
router: rtr,
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: memory.NewInMemoryStore(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ type factEnrichmentWorker struct {
|
|||||||
nextTry map[int64]time.Time // fact id → earliest retry
|
nextTry map[int64]time.Time // fact id → earliest retry
|
||||||
}
|
}
|
||||||
|
|
||||||
// enrichmentScanLimit bounds how deep a single tick (or status report) walks
|
// enrichmentScanLimit bounds how deep a single tick walks the pending queue
|
||||||
// the pending queue looking for facts whose backoff has elapsed. The queue is
|
// looking for facts whose backoff has elapsed. The queue is
|
||||||
// ordered by id, so without a scan the oldest facts hold every batch slot
|
// 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
|
// whether or not they are eligible, and one permanently failing fact stalls
|
||||||
// every younger one behind it.
|
// 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
|
// has been down all day must be visible as a backlog, not as facts that
|
||||||
// silently never got tagged.
|
// silently never got tagged.
|
||||||
//
|
//
|
||||||
// All three numbers describe the same set of rows, the first
|
// All three numbers describe the same set of rows, whatever is still pending
|
||||||
// enrichmentScanLimit pending facts. Counting Pending over a thousand rows
|
// 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
|
// while counting InBackoff over the twenty that reached the head of a batch
|
||||||
// described two different populations under one struct.
|
// described two different populations under one struct.
|
||||||
type enrichmentStatus struct {
|
type enrichmentStatus struct {
|
||||||
@@ -86,13 +86,22 @@ type enrichmentStatus struct {
|
|||||||
Scanned int // rows the other three counts were taken over
|
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 {
|
func (w *factEnrichmentWorker) status(ctx context.Context) enrichmentStatus {
|
||||||
var st enrichmentStatus
|
|
||||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("factenrichment: status: %v", err)
|
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.Pending = len(pending)
|
||||||
st.Scanned = len(pending)
|
st.Scanned = len(pending)
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
@@ -144,17 +153,24 @@ func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
w.forgetDeparted(pending)
|
w.forgetDeparted(pending)
|
||||||
skipped, failed, attempted := 0, 0, 0
|
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 {
|
for _, f := range pending {
|
||||||
if attempted >= w.batch {
|
if attempted >= w.batch {
|
||||||
break
|
remaining = append(remaining, f)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
if !w.due(f.ID) {
|
if !w.due(f.ID) {
|
||||||
skipped++
|
skipped++
|
||||||
|
remaining = append(remaining, f)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
attempted++
|
attempted++
|
||||||
if !w.resolveOne(ctx, f) {
|
if !w.resolveOne(ctx, f) {
|
||||||
failed++
|
failed++
|
||||||
|
remaining = append(remaining, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if failed > 0 {
|
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
|
// Report the backlog every tick, not only when something failed: the
|
||||||
// stalled state worth seeing is the one where nothing failed because
|
// stalled state worth seeing is the one where nothing failed because
|
||||||
// nothing was attempted.
|
// 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)",
|
log.Printf("factenrichment: %d facts pending entity resolution, %d in backoff, worst attempt %d (scanned %d)",
|
||||||
st.Pending, st.InBackoff, st.MaxAttempts, st.Scanned)
|
st.Pending, st.InBackoff, st.MaxAttempts, st.Scanned)
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-5
@@ -19,11 +19,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
|
|||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
embedder: emb,
|
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),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: memory.NewInMemoryStore(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
}
|
}
|
||||||
return h, api
|
return h, api
|
||||||
@@ -44,7 +43,7 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
|||||||
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
||||||
t.Fatal("a question was stored as a fact about him")
|
t.Fatal("a question was stored as a fact about him")
|
||||||
}
|
}
|
||||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("memory search: %v", err)
|
t.Fatalf("memory search: %v", err)
|
||||||
}
|
}
|
||||||
@@ -81,7 +80,7 @@ func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
|
|||||||
// #493: what recall reads back is the fact, not the sentence he said.
|
// #493: what recall reads back is the fact, not the sentence he said.
|
||||||
// queryMemory returns a fact's text verbatim, so the utterance sitting here
|
// queryMemory returns a fact's text verbatim, so the utterance sitting here
|
||||||
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
|
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
|
||||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("memory search: %v", err)
|
t.Fatalf("memory search: %v", err)
|
||||||
}
|
}
|
||||||
@@ -117,9 +116,120 @@ func TestFactConfidence(t *testing.T) {
|
|||||||
|
|
||||||
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
|
vec, err := router.EmbedQuery(context.Background(), h.recall.embedder, text)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("embed %q: %v", text, err)
|
t.Fatalf("embed %q: %v", text, err)
|
||||||
}
|
}
|
||||||
return vec
|
return vec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The write half of #481: a complaint about a thing is a state of the
|
||||||
|
// afternoon, not a fact about him. Stored as a `self` row at confidence 1.00
|
||||||
|
// it comes back on recall as if the network were still down.
|
||||||
|
func TestActionFact_ComplaintIsNotWritten(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, api := newFactGateHandler(t, time.Now())
|
||||||
|
|
||||||
|
reply := h.actionFact(ctx, router.Decision{
|
||||||
|
Intent: router.IntentFact,
|
||||||
|
Utterance: "сеть какая-то медленная",
|
||||||
|
Slots: router.Slots{Key: "network_speed", HasKey: true, Value: "медленная"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := api.LatestFact(ctx, "network_speed"); err == nil {
|
||||||
|
t.Fatal("a passing complaint was stored as a fact about him")
|
||||||
|
}
|
||||||
|
hits, err := h.recall.memStore.Search(ctx, mustEmbedPassage(t, h, "сеть какая-то медленная"), 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memory search: %v", err)
|
||||||
|
}
|
||||||
|
if len(hits) != 0 {
|
||||||
|
t.Fatalf("the complaint was indexed for recall: %+v", hits)
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
t.Fatal("the turn was neither stored nor answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the complaint he asked her to keep: the capture verb wins, as it does
|
||||||
|
// over the question gate.
|
||||||
|
func TestActionFact_AskedToRememberAComplaintStillWrites(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, api := newFactGateHandler(t, time.Now())
|
||||||
|
|
||||||
|
h.actionFact(ctx, router.Decision{
|
||||||
|
Intent: router.IntentFact,
|
||||||
|
Utterance: "запомни что интернет не работает",
|
||||||
|
Slots: router.Slots{Key: "internet", HasKey: true, Value: "не работает"},
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := api.LatestFact(ctx, "internet"); err != nil {
|
||||||
|
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
|
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 {
|
func jsonHandler(status int, body string) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
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
|
// Maven's praxisClient calls. Every route returns its fixed body until a
|
||||||
// fault is injected via SetFault.
|
// fault is injected via SetFault.
|
||||||
func newFakePraxis(t *testing.T, attentionBody string) *fakeServer {
|
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{
|
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/attention": jsonHandler(http.StatusOK, attentionBody),
|
||||||
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
|
"GET /api/v1/tools/changes": jsonHandler(http.StatusOK, `[]`),
|
||||||
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
"POST /api/v1/tools/surface": jsonHandler(http.StatusOK, `{}`),
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &reactiveHandler{
|
return &reactiveHandler{
|
||||||
api: ipc.NewStoreAPI(st),
|
api: ipc.NewStoreAPI(st),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
phraser: phraser.NewStub(),
|
phraser: phraser.NewStub(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
feedsOn: feedsOn,
|
feedsOn: feedsOn,
|
||||||
embedder: nil,
|
recall: recallWiring{embedder: nil},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
|||||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||||
}
|
}
|
||||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||||
if !strings.Contains(reply, "ничего") {
|
if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
|
||||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,16 +87,22 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
|||||||
// "не настроены" and "ничего нового" are different truths, and neither may be
|
// "не настроены" and "ничего нового" are different truths, and neither may be
|
||||||
// answered by the model inventing a bulletin.
|
// answered by the model inventing a bulletin.
|
||||||
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
|
||||||
|
// Against the entries, not against a substring: both of these have several
|
||||||
|
// wordings, so "ничего нового" passed only on the turns the picker happened
|
||||||
|
// to choose the first one.
|
||||||
off := buildFeedHandler(t, false)
|
off := buildFeedHandler(t, false)
|
||||||
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
reply, ok := askFeeds(t, off, "что нового в лентах?")
|
||||||
if !ok || !strings.Contains(reply, "не настроены") {
|
if !ok || !phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||||
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
|
||||||
}
|
}
|
||||||
on := buildFeedHandler(t, true)
|
on := buildFeedHandler(t, true)
|
||||||
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
reply, ok = askFeeds(t, on, "что нового в лентах?")
|
||||||
if !ok || !strings.Contains(reply, "ничего нового") {
|
if !ok || !phraser.IsQ(phraser.QueryFeedsEmpty, nil, reply) {
|
||||||
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
|
||||||
}
|
}
|
||||||
|
if phraser.IsQ(phraser.QueryFeedsOff, nil, reply) {
|
||||||
|
t.Fatalf("an empty feed answered as an unconfigured one: %q", reply)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
|
||||||
@@ -194,3 +200,22 @@ func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
|
|||||||
t.Fatal("the poller fetched a private address")
|
t.Fatal("the poller fetched a private address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQueryFeedsPassesWhenTheWorldCanAnswer — the defect (Vikunja #474). The
|
||||||
|
// deployed box has no feeds block and does have SearXNG, and "что происходит
|
||||||
|
// сейчас в новостях про искусственный интеллект?" got a configuration status
|
||||||
|
// instead of the live answer sitting one source below.
|
||||||
|
func TestQueryFeedsPassesWhenTheWorldCanAnswer(t *testing.T) {
|
||||||
|
h := buildFeedHandler(t, false)
|
||||||
|
h.search = &searchWiring{max: 3, runes: 1500}
|
||||||
|
|
||||||
|
if reply, ok := askFeeds(t, h, "что нового в лентах?"); ok {
|
||||||
|
t.Fatalf("feeds off with a search configured must fall through, got %q", reply)
|
||||||
|
}
|
||||||
|
// With nothing below that reads the world, the honest status is still said:
|
||||||
|
// general knowledge would otherwise answer with an invented bulletin.
|
||||||
|
h.search = nil
|
||||||
|
if reply, ok := askFeeds(t, h, "что нового в лентах?"); !ok || !strings.Contains(reply, "не настроены") {
|
||||||
|
t.Fatalf("no search and no ZIMs: reply = %q, ok = %v", reply, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+66
-4
@@ -1,17 +1,64 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
// voiceDialogueID — the single dialogue-session key. This is a single-user box
|
// voiceDialogueID — the dialogue-session and clarify key for the microphone.
|
||||||
// (ponytail), so one slot suffices; a second speaker would need per-speaker ids,
|
// This is a single-user box (ponytail), so one slot per reach suffices; a
|
||||||
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
|
// second speaker would need per-speaker ids, which waits on voice-print
|
||||||
|
// attribution (see PROGRESS multi-user deferral).
|
||||||
const voiceDialogueID = "voice"
|
const voiceDialogueID = "voice"
|
||||||
|
|
||||||
|
// textDialogueID — the clarify key for a text turn that named no conversation.
|
||||||
|
// Separate from the mic: an old client that sends no id still must not answer
|
||||||
|
// a question she asked out loud.
|
||||||
|
const textDialogueID = "text"
|
||||||
|
|
||||||
|
// dialogueKey — the context key carrying the id of the conversation this turn
|
||||||
|
// belongs to. It rides the context rather than a parameter for the same reason
|
||||||
|
// the correlation id does: every step of the turn needs it, most of them only
|
||||||
|
// to hand to the next one, and threading it by hand would put it in six
|
||||||
|
// clarify signatures that have nothing else to say about it.
|
||||||
|
type dialogueKey struct{}
|
||||||
|
|
||||||
|
// dialogueIDFor builds the id a turn is held under: the conversation the reach
|
||||||
|
// named, qualified by the tap it arrived on, or the tap's own fallback when it
|
||||||
|
// named none.
|
||||||
|
//
|
||||||
|
// A parked clarifying question used to be held under voiceDialogueID no matter
|
||||||
|
// where the turn came from, so one unanswerable question captured the next
|
||||||
|
// three utterances from anywhere. Three independent curl sessions fed a
|
||||||
|
// capture attempt that had already failed, and a reminder among them was lost
|
||||||
|
// (Vikunja #466).
|
||||||
|
func dialogueIDFor(src turnSource, conversation string) string {
|
||||||
|
if conversation != "" {
|
||||||
|
return string(src) + ":" + conversation
|
||||||
|
}
|
||||||
|
if src == sourceVoice {
|
||||||
|
return voiceDialogueID
|
||||||
|
}
|
||||||
|
return textDialogueID
|
||||||
|
}
|
||||||
|
|
||||||
|
// withDialogueID tags a turn with that id.
|
||||||
|
func withDialogueID(ctx context.Context, id string) context.Context {
|
||||||
|
return context.WithValue(ctx, dialogueKey{}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dialogueIDOf reads it back. Falls back to the microphone's slot, which is
|
||||||
|
// what an unthreaded caller — a test, an internal replay — gets.
|
||||||
|
func dialogueIDOf(ctx context.Context) string {
|
||||||
|
if id, ok := ctx.Value(dialogueKey{}).(string); ok && id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return voiceDialogueID
|
||||||
|
}
|
||||||
|
|
||||||
// toDialogueSlots and applyDialogueSlots are the only bridge between
|
// toDialogueSlots and applyDialogueSlots are the only bridge between
|
||||||
// router.Slots and dialogue.Slots. dialogue must not import router (import
|
// router.Slots and dialogue.Slots. dialogue must not import router (import
|
||||||
// cycle), so the two structs are hand-kept copies and every field has to be
|
// cycle), so the two structs are hand-kept copies and every field has to be
|
||||||
@@ -50,7 +97,8 @@ var anaphoraResolver router.AnaphoraResolver
|
|||||||
// followUpMerge fills the current turn's missing slots from a prior
|
// followUpMerge fills the current turn's missing slots from a prior
|
||||||
// non-expired session — the multi-turn seam. It handles three cases:
|
// non-expired session — the multi-turn seam. It handles three cases:
|
||||||
//
|
//
|
||||||
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior).
|
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior),
|
||||||
|
// except a reminder time the current sentence named and the parser missed.
|
||||||
// 2. Cross-intent anaphora: if the current utterance contains a pronoun
|
// 2. Cross-intent anaphora: if the current utterance contains a pronoun
|
||||||
// ("это" / "он" / "она" etc.) AND the prior session has a key, inherit
|
// ("это" / "он" / "она" etc.) AND the prior session has a key, inherit
|
||||||
// the key for fact-lookup queries and reminder creation.
|
// the key for fact-lookup queries and reminder creation.
|
||||||
@@ -66,8 +114,22 @@ func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) r
|
|||||||
|
|
||||||
// Case 1: same-intent inheritance (existing).
|
// Case 1: same-intent inheritance (existing).
|
||||||
if prev.Intent == dialogue.Intent(dec.Intent) {
|
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))
|
merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots))
|
||||||
dec.Slots = applyDialogueSlots(dec.Slots, merged)
|
dec.Slots = applyDialogueSlots(dec.Slots, merged)
|
||||||
|
if blockTime {
|
||||||
|
dec.Slots.Time, dec.Slots.HasTime = time.Time{}, false
|
||||||
|
}
|
||||||
return dec
|
return dec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
t.Run("current slot wins over prior (gaps only)", func(t *testing.T) {
|
||||||
own := base.Add(48 * time.Hour)
|
own := base.Add(48 * time.Hour)
|
||||||
cur := router.Decision{
|
cur := router.Decision{
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Command history — "что я тебе говорил?", "что ты записала сегодня?"
|
||||||
|
// (Vikunja #456).
|
||||||
|
//
|
||||||
|
// Read-only over the facts that already exist. No new mechanism and no new
|
||||||
|
// storage: everything he tapped in is already a row with a source and a
|
||||||
|
// timestamp, and this only reads them back.
|
||||||
|
|
||||||
|
// A history question needs three things in one utterance: the interrogative,
|
||||||
|
// whose turn is being asked about, and a verb of saying or recording. Any two of
|
||||||
|
// them are a different question. "что я говорил про сервер" names a topic and
|
||||||
|
// the notes pass answers it better; "записал молоко" is a capture.
|
||||||
|
//
|
||||||
|
// The verbs are matched by lemma through internal/morph, not by a truncated
|
||||||
|
// prefix (Vikunja #530). The pairs here used to hold "рассказ" and "записал",
|
||||||
|
// which is the defect V-528 fixed in complaint.go: "рассказ" is also the noun,
|
||||||
|
// so "что я рассказал ей" and "что я читал рассказ" were the same string test.
|
||||||
|
// Aspect pairs are separate lemmas in the dictionary, so both members are listed.
|
||||||
|
var (
|
||||||
|
// historySpokenVerbs — what HE did. "что я тебе говорил".
|
||||||
|
historySpokenVerbs = []string{"говорить", "сказать", "рассказать", "рассказывать", "отметить", "отмечать"}
|
||||||
|
|
||||||
|
// historyRecordedVerbs — what SHE did with it. "что ты записала сегодня".
|
||||||
|
historyRecordedVerbs = []string{"записать", "запомнить", "отметить", "отмечать"}
|
||||||
|
|
||||||
|
// firstPersonSubjects and secondPersonSubjects — whose turn the question is
|
||||||
|
// about. Only the subject forms: "что я тебе говорил" is his turn, and the
|
||||||
|
// dative "тебе" in it is not the subject.
|
||||||
|
firstPersonSubjects = []string{"я"}
|
||||||
|
secondPersonSubjects = []string{"ты"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// historyMarkersEn — the English pairs, kept as substrings because the
|
||||||
|
// dictionary is Russian. Each half alone is a different question, the same way
|
||||||
|
// the Russian test needs all three parts.
|
||||||
|
var historyMarkersEn = [][2]string{
|
||||||
|
{"what did i", "tell"},
|
||||||
|
{"what did you", "record"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyRecall — the word that turns a history question into a recall
|
||||||
|
// question. "что я говорил про сервер" names a topic, and the notes pass
|
||||||
|
// 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 historyAskedHim, false
|
||||||
|
}
|
||||||
|
for _, r := range historyRecall {
|
||||||
|
if strings.Contains(s, r) {
|
||||||
|
return historyAskedHim, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, pair := range historyMarkersEn {
|
||||||
|
if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) {
|
||||||
|
if strings.Contains(pair[0], "you") {
|
||||||
|
return historyAskedHer, true
|
||||||
|
}
|
||||||
|
return historyAskedHim, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toks := historyTokens(s)
|
||||||
|
if !hasAny(toks, "что", "чего") {
|
||||||
|
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 historyAskedHim, true
|
||||||
|
}
|
||||||
|
if hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) {
|
||||||
|
return historyAskedHer, true
|
||||||
|
}
|
||||||
|
return historyAskedHim, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyTokens splits an utterance into bare words. The punctuation goes
|
||||||
|
// because "говорил?" is the same word as "говорил".
|
||||||
|
func historyTokens(s string) []string {
|
||||||
|
toks := strings.Fields(s)
|
||||||
|
out := make([]string, 0, len(toks))
|
||||||
|
for _, t := range toks {
|
||||||
|
if t = strings.Trim(t, ".,!?;:—–-()\"'«»"); t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasAny(toks []string, want ...string) bool {
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, w := range want {
|
||||||
|
if t == w {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasVerbForm reports whether any token is a form of any of the lemmas. Both
|
||||||
|
// sides go through the dictionary, so a caller may name the infinitive and he
|
||||||
|
// may say the past tense.
|
||||||
|
func hasVerbForm(toks []string, lemmas []string) bool {
|
||||||
|
for _, t := range toks {
|
||||||
|
for _, l := range lemmas {
|
||||||
|
if morph.SameWord(t, l) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyScan — how many recent facts are read before filtering. Deliberately
|
||||||
|
// larger than historyReadOut: a poller writing every few minutes would
|
||||||
|
// otherwise push everything he said out of the window, the same way his own
|
||||||
|
// notes used to crowd out the feed headlines.
|
||||||
|
const historyScan = 100
|
||||||
|
|
||||||
|
// historyReadOut — how many she says out loud. Five is what fits in one spoken
|
||||||
|
// breath; the rest are on /history, which is the surface for reading a list.
|
||||||
|
const historyReadOut = 5
|
||||||
|
|
||||||
|
// historyWindow — how far back "recently" reaches. A day, because the question
|
||||||
|
// is about this conversation and not about the archive.
|
||||||
|
const historyWindow = 24 * time.Hour
|
||||||
|
|
||||||
|
// queryHistory answers what he told her, from the facts he tapped in.
|
||||||
|
//
|
||||||
|
// Only "tap:" sources. A fact written by a poller, an inference or the ambient
|
||||||
|
// relay is a thing she learned, not a thing he said, and reading those back
|
||||||
|
// under "что я тебе говорил?" would put words in his mouth.
|
||||||
|
//
|
||||||
|
// Placed with the other sources that read his own rows and above the recall
|
||||||
|
// 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) {
|
||||||
|
side, ok := historyAsks(t.dec.Utterance)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
facts, err := h.api.RecentFacts(ctx, historyScan)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: history: recent facts: %v", err)
|
||||||
|
return "не получилось посмотреть, что ты говорил.", true
|
||||||
|
}
|
||||||
|
cutoff := h.now().Add(-historyWindow)
|
||||||
|
var said []string
|
||||||
|
for _, f := range facts {
|
||||||
|
if !strings.HasPrefix(f.Source, "tap:") || f.Ts.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
said = append(said, historyLine(f.Key, f.Value, f.Ts))
|
||||||
|
if len(said) == historyReadOut {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// historyLine — one fact as she says it. The hour and minute, because the day
|
||||||
|
// is already bounded by historyWindow and a date would be noise.
|
||||||
|
func historyLine(key, value string, ts time.Time) string {
|
||||||
|
what := key
|
||||||
|
if value != "" {
|
||||||
|
what = key + " — " + value
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s (%s)", what, ts.Local().Format("15:04"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// historyAPI serves a fixed set of recent facts.
|
||||||
|
type historyAPI struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
facts []ipc.Fact
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *historyAPI) RecentFacts(context.Context, int) ([]ipc.Fact, error) {
|
||||||
|
a.calls++
|
||||||
|
return a.facts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyHandler(now time.Time, facts ...ipc.Fact) (*reactiveHandler, *historyAPI) {
|
||||||
|
api := &historyAPI{facts: facts}
|
||||||
|
return &reactiveHandler{api: api, now: func() time.Time { return now }}, api
|
||||||
|
}
|
||||||
|
|
||||||
|
func askHistory(h *reactiveHandler, u string) (string, bool) {
|
||||||
|
return h.queryHistory(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Intent: router.IntentQuery, Utterance: u},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHistoryQuery(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
text string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"что я тебе говорил?", true},
|
||||||
|
{"что ты записала сегодня?", true},
|
||||||
|
{"что я отмечал?", true},
|
||||||
|
// Forms the truncated prefixes did not reach. The dictionary answers
|
||||||
|
// these because it lemmatises both sides (V-530).
|
||||||
|
{"что я тебе рассказывал?", true},
|
||||||
|
{"что я сказала вчера", true},
|
||||||
|
{"что ты запомнила?", true},
|
||||||
|
// The noun, not the verb. "рассказ" was a prefix of the old pair, so
|
||||||
|
// this read as a history question — the same defect V-528 fixed in
|
||||||
|
// complaint.go, where "лаг" matched "лагерь".
|
||||||
|
{"что я читал рассказ", false},
|
||||||
|
// A verb of saying with nobody saying it.
|
||||||
|
{"что записать?", false},
|
||||||
|
// A named topic is a recall question, and the notes pass answers it
|
||||||
|
// better than a list of the last five facts does.
|
||||||
|
{"что я говорил про сервер?", false},
|
||||||
|
{"что у меня сегодня?", false},
|
||||||
|
{"", false},
|
||||||
|
} {
|
||||||
|
if got := isHistoryQuery(tc.text); got != tc.want {
|
||||||
|
t.Errorf("isHistoryQuery(%q) = %v, want %v", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoryReadsOnlyWhatHeSaid(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||||
|
h, api := historyHandler(now,
|
||||||
|
ipc.Fact{Key: "water", Value: "выпил", Source: "tap:voice", Ts: now.Add(-time.Hour)},
|
||||||
|
// Learned, not said: a poller writing this back under "что я тебе
|
||||||
|
// говорил?" would put words in his mouth.
|
||||||
|
ipc.Fact{Key: "spent_today", Value: "1200", Source: "poll:zenmoney", Ts: now.Add(-time.Hour)},
|
||||||
|
// Older than the window.
|
||||||
|
ipc.Fact{Key: "shower", Value: "принял", Source: "tap:voice", Ts: now.Add(-30 * time.Hour)},
|
||||||
|
)
|
||||||
|
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("the history question must be claimed before the recall sources")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "water") {
|
||||||
|
t.Errorf("reply = %q, want the fact he tapped in", reply)
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "spent_today") || strings.Contains(reply, "shower") {
|
||||||
|
t.Errorf("reply = %q, want only what he said inside the window", reply)
|
||||||
|
}
|
||||||
|
if api.calls != 1 {
|
||||||
|
t.Errorf("RecentFacts called %d times, want 1", api.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||||
|
h, _ := historyHandler(now)
|
||||||
|
reply, ok := askHistory(h, "что я тебе говорил?")
|
||||||
|
if !ok || !strings.Contains(reply, "ничего") {
|
||||||
|
t.Fatalf("reply = %q, ok = %v", reply, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Five is what fits in one spoken breath; the rest are on /history.
|
||||||
|
func TestHistoryStopsAtFive(t *testing.T) {
|
||||||
|
now := time.Date(2026, 8, 4, 20, 0, 0, 0, time.UTC)
|
||||||
|
var facts []ipc.Fact
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
facts = append(facts, ipc.Fact{Key: "k", Value: "v", Source: "tap:voice", Ts: now.Add(-time.Minute)})
|
||||||
|
}
|
||||||
|
h, _ := historyHandler(now, facts...)
|
||||||
|
reply, _ := askHistory(h, "что ты записала?")
|
||||||
|
if got := strings.Count(reply, ";"); got != historyReadOut-1 {
|
||||||
|
t.Fatalf("reply = %q has %d separators, want %d", reply, got, historyReadOut-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
client *kiwix.Client
|
||||||
rewriter *kiwix.Rewriter // nil ⇒ the question is searched verbatim
|
rewriter *kiwix.Rewriter // nil ⇒ the question is searched verbatim
|
||||||
book string
|
book string
|
||||||
max int
|
// bookRU — searched instead of book when the question is Cyrillic, and
|
||||||
runes int
|
// 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
|
// 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{
|
w := &kiwixWiring{
|
||||||
client: kiwix.New(kc.URL),
|
client: kiwix.New(kc.URL),
|
||||||
book: kc.Book,
|
book: kc.Book,
|
||||||
|
bookRU: kc.BookRU,
|
||||||
max: kc.MaxResults,
|
max: kc.MaxResults,
|
||||||
runes: kc.SnippetRunes,
|
runes: kc.SnippetRunes,
|
||||||
}
|
}
|
||||||
|
if kc.BookRU != "" {
|
||||||
|
log.Printf("voice: kiwix: russian questions read %q verbatim", kc.BookRU)
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case !kc.RewriteEnabled():
|
case !kc.RewriteEnabled():
|
||||||
log.Printf("voice: kiwix at %s (book %q, query rewriting off by config)", kc.URL, kc.Book)
|
log.Printf("voice: kiwix at %s (book %q, query rewriting off by config)", kc.URL, kc.Book)
|
||||||
|
|||||||
+185
-283
@@ -68,6 +68,11 @@ import (
|
|||||||
"github.com/kami/maven/internal/webauthn"
|
"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")
|
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
|
||||||
|
|
||||||
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
|
||||||
@@ -127,8 +132,12 @@ func run(args []string) error {
|
|||||||
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
cfgPath := flag.String("config", defaultConfigPath(), "path to mavend JSON config")
|
||||||
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
wrappedKeyPath := flag.String("wrapped-key-file", "", "path to wrapped encryption key blob (enables cold-start unlock)")
|
||||||
reembed := flag.Bool("reembed", false, "re-embed every stored note and fact with the configured embedder, then serve normally (run once after an embedder swap; the daemon does not answer until it finishes)")
|
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)
|
flag.CommandLine.Parse(args)
|
||||||
reembedOnStart = *reembed
|
reembedOnStart = *reembed
|
||||||
|
allowSeedOnStart = *allowSeed
|
||||||
cfg, err := config.Load(*cfgPath)
|
cfg, err := config.Load(*cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -208,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) -----
|
// ----- daemon components (only wired when unlocked) -----
|
||||||
// Pre-declare so the unlock path can wire them later.
|
// Pre-declare so the unlock path can wire them later.
|
||||||
var (
|
var (
|
||||||
@@ -235,44 +252,31 @@ func run(args []string) error {
|
|||||||
// envelope per successful intake write.
|
// envelope per successful intake write.
|
||||||
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
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 {
|
if !locked {
|
||||||
rules = wireRules(cfg)
|
rules = wireRules(cfg)
|
||||||
gatherer = loop.NewGatherer(st, rules)
|
gatherer = wireGatherer(st, cfg, rules)
|
||||||
if cfg.QuietHours != nil {
|
|
||||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
|
||||||
}
|
|
||||||
|
|
||||||
// phraser
|
// phraser
|
||||||
phr = phraser.NewStub()
|
phr, err = wirePhraser(ctx, cfg)
|
||||||
if cfg.Phraser != nil {
|
if err != nil {
|
||||||
pc := phraser.Config{
|
return fmt.Errorf("phraser: %w", err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ecosystem — nexus + hexis + praxis (all over HTTP; no direct DB access)
|
// ecosystem — nexus + hexis + praxis (all over HTTP; no direct DB access)
|
||||||
@@ -285,70 +289,19 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// delivery
|
// delivery
|
||||||
var ntfy delivery.Sink
|
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||||
if cfg.Ntfy != nil {
|
if err != nil {
|
||||||
s, err := ntfysink.New(*cfg.Ntfy)
|
return err
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
|
||||||
}
|
|
||||||
ntfy = s
|
|
||||||
}
|
}
|
||||||
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
|
// tick loop
|
||||||
tickInterval := time.Duration(cfg.TickInterval)
|
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||||
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)
|
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
|
||||||
coreAPI = &daemonAPI{
|
coreAPI = newDaemonAPI(depsNow())
|
||||||
CoreAPI: coreFor(),
|
|
||||||
getTrace: tl.trace,
|
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
|
||||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
|
||||||
getEvents: intakeEventsFn(evBus),
|
|
||||||
}
|
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
|
||||||
api := coreAPI.(*daemonAPI)
|
|
||||||
api.chatFn = voiceW.handler.handleText
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
// 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
|
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
||||||
@@ -365,7 +318,7 @@ func run(args []string) error {
|
|||||||
return fmt.Errorf("ipc listen: %w", err)
|
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
|
// Set Server.Check — the single authorization guard, run once by
|
||||||
// Server.dispatch before any CoreAPI method is called (see
|
// Server.dispatch before any CoreAPI method is called (see
|
||||||
@@ -409,6 +362,9 @@ func run(args []string) error {
|
|||||||
if !locked {
|
if !locked {
|
||||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||||
wireModelSwap(srv, phr, cfg)
|
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
|
// Vision + the media blob store (Vikunja #252). Both stay dark without a
|
||||||
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
||||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||||
@@ -513,40 +469,11 @@ func run(args []string) error {
|
|||||||
|
|
||||||
// Wire everything.
|
// Wire everything.
|
||||||
rules = wireRules(cfg)
|
rules = wireRules(cfg)
|
||||||
gatherer = loop.NewGatherer(st, rules)
|
gatherer = wireGatherer(st, cfg, rules)
|
||||||
if cfg.QuietHours != nil {
|
|
||||||
gatherer.SetQuietHours(cfg.QuietHours.Start, cfg.QuietHours.End)
|
|
||||||
}
|
|
||||||
|
|
||||||
phr = phraser.NewStub()
|
phr, err = wirePhraser(ctx, cfg)
|
||||||
if cfg.Phraser != nil {
|
if err != nil {
|
||||||
pc := phraser.Config{
|
return fmt.Errorf("phraser: %w", err)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
eco = wireEcosystem(cfg)
|
eco = wireEcosystem(cfg)
|
||||||
@@ -556,64 +483,26 @@ func run(args []string) error {
|
|||||||
return fmt.Errorf("wire voice: %w", err)
|
return fmt.Errorf("wire voice: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var ntfy delivery.Sink
|
dispatcher, err = wireDispatcher(st, cfg, voiceW)
|
||||||
if cfg.Ntfy != nil {
|
if err != nil {
|
||||||
s, err := ntfysink.New(*cfg.Ntfy)
|
return err
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("wire ntfy sink: %w", err)
|
|
||||||
}
|
|
||||||
ntfy = s
|
|
||||||
}
|
}
|
||||||
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)
|
tl = wireTickLoop(st, gatherer, dispatcher, phr, rules, cfg)
|
||||||
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)
|
|
||||||
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
|
||||||
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
evalWorker = newMemoryEvalWorker(st, phr, cfg)
|
||||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||||
|
|
||||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||||
newAPI := &daemonAPI{
|
newAPI := newDaemonAPI(depsNow())
|
||||||
CoreAPI: coreFor(),
|
|
||||||
getTrace: tl.trace,
|
|
||||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
|
||||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
|
||||||
getEvents: intakeEventsFn(evBus),
|
|
||||||
}
|
|
||||||
if voiceW != nil && voiceW.handler != nil {
|
|
||||||
newAPI.chatFn = voiceW.handler.handleText
|
|
||||||
voiceW.handler.upgradeAPI(newAPI)
|
|
||||||
}
|
|
||||||
srv.SetAPI(newAPI)
|
srv.SetAPI(newAPI)
|
||||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||||
wireModelSwap(srv, phr, cfg)
|
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)
|
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||||
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
||||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||||
@@ -621,59 +510,10 @@ func run(args []string) error {
|
|||||||
// block, so no wire path takes a voiceprint on a default box.
|
// block, so no wire path takes a voiceprint on a default box.
|
||||||
wireSpeaker(srv, st, cfg)
|
wireSpeaker(srv, st, cfg)
|
||||||
|
|
||||||
// Start voice server.
|
// The voice server and every background worker, on the outer wg
|
||||||
if voiceW != nil {
|
// so shutdown waits for them. This used to be nine bare
|
||||||
var wg sync.WaitGroup
|
// `go func()` calls and a shadowed WaitGroup (V-639).
|
||||||
wg.Add(1)
|
startBackground(ctx, &wg, depsNow())
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
dl.unlock(st)
|
dl.unlock(st)
|
||||||
log.Printf("mavend: unlocked via passkey assertion")
|
log.Printf("mavend: unlocked via passkey assertion")
|
||||||
@@ -681,72 +521,15 @@ func run(args []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Add(1)
|
goWorker(&wg, func() {
|
||||||
go func() {
|
|
||||||
defer wg.Done()
|
|
||||||
if err := srv.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
if err := srv.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||||
log.Printf("ipc serve: %v", err)
|
log.Printf("ipc serve: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
log.Printf("mavend: ipc listening on %s", srv.Path())
|
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 {
|
if !locked {
|
||||||
wg.Add(1)
|
startBackground(ctx, &wg, depsNow())
|
||||||
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)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
@@ -833,6 +616,125 @@ 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) {
|
||||||
|
var ntfy delivery.Sink
|
||||||
|
if cfg.Ntfy != nil {
|
||||||
|
s, err := ntfysink.New(*cfg.Ntfy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("wire ntfy sink: %w", err)
|
||||||
|
}
|
||||||
|
ntfy = s
|
||||||
|
}
|
||||||
|
var telegram delivery.Sink
|
||||||
|
if cfg.Telegram != nil {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// wireRules builds the nudge rule set, minus anything config turned off. The
|
||||||
// drop is logged because a rule vanishing silently is indistinguishable from a
|
// 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
|
// rule that is broken, and the next person to wonder why she stopped nudging
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/morning"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMorningNudgeBodySeparatesOptional — the one message a routine is allowed
|
||||||
|
// per day says what was not done, then what he could still do (Vikunja #473).
|
||||||
|
func TestMorningNudgeBodySeparatesOptional(t *testing.T) {
|
||||||
|
cand := morning.Candidate{
|
||||||
|
Routine: morning.Routine{Name: "утро"},
|
||||||
|
Missing: []morning.Item{
|
||||||
|
{Key: "meds", Label: "таблетки"},
|
||||||
|
{Key: "stretch", Label: "растяжка", Optional: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body := morningNudgeBody(cand)
|
||||||
|
if !strings.Contains(body, "не сделано — таблетки") {
|
||||||
|
t.Fatalf("the required item must be named as not done: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "если будет время — растяжка") {
|
||||||
|
t.Fatalf("the optional item must read softer: %q", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "не сделано — таблетки, растяжка") {
|
||||||
|
t.Fatalf("optional must not be folded into the required list: %q", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing optional missing: the sentence is what it always was.
|
||||||
|
only := morning.Candidate{
|
||||||
|
Routine: morning.Routine{Name: "утро"},
|
||||||
|
Missing: []morning.Item{{Key: "meds", Label: "таблетки"}},
|
||||||
|
}
|
||||||
|
if got, want := morningNudgeBody(only), "утро: не сделано — таблетки"; got != want {
|
||||||
|
t.Fatalf("morningNudgeBody = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-20
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/netscan"
|
"github.com/kami/maven/internal/netscan"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||||
@@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
|||||||
res, err := w.scan(ctx)
|
res, err := w.scan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("netscan: scan: %v", err)
|
log.Printf("netscan: scan: %v", err)
|
||||||
return "не получилось просканировать сеть.", true
|
return phraser.Q(phraser.QueryFailNetscan, nil), true
|
||||||
}
|
}
|
||||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||||
// устройств" after stopping two thirds of the way through the range is a
|
// устройств" after stopping two thirds of the way through the range is a
|
||||||
@@ -116,9 +117,9 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
|||||||
tail = ", но успела посмотреть не всю сеть"
|
tail = ", но успела посмотреть не всю сеть"
|
||||||
}
|
}
|
||||||
if len(res.Hosts) == 0 {
|
if len(res.Hosts) == 0 {
|
||||||
return "в сети никого не нашла" + tail + ".", true
|
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
|
||||||
}
|
}
|
||||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||||
if shape := scanShape(res.Hosts); shape != "" {
|
if shape := scanShape(res.Hosts); shape != "" {
|
||||||
out += ", " + shape
|
out += ", " + shape
|
||||||
}
|
}
|
||||||
@@ -179,7 +180,7 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
|||||||
if w.api == nil {
|
if w.api == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
head := fmt.Sprintf("сканирование сети: %d %s", len(res.Hosts), phraser.Devices(len(res.Hosts)))
|
||||||
if res.Truncated {
|
if res.Truncated {
|
||||||
head += " (не вся сеть)"
|
head += " (не вся сеть)"
|
||||||
}
|
}
|
||||||
@@ -210,22 +211,6 @@ func (w *netWiring) writeScanRecord(ctx context.Context, res netscan.Result) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostWord — Russian counts inflect the noun: 1 устройство, 2-4 устройства,
|
|
||||||
// 5+ устройств, and the teens are all the last form.
|
|
||||||
func hostWord(n int) string {
|
|
||||||
if n%100 >= 11 && n%100 <= 14 {
|
|
||||||
return "устройств"
|
|
||||||
}
|
|
||||||
switch n % 10 {
|
|
||||||
case 1:
|
|
||||||
return "устройство"
|
|
||||||
case 2, 3, 4:
|
|
||||||
return "устройства"
|
|
||||||
default:
|
|
||||||
return "устройств"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
// isNetworkQuery recognises a question about the LAN, narrowly. It needs a
|
||||||
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
// network word AND an ask: "интернет не работает" is a complaint, not a request
|
||||||
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
// to scan, and a scan she runs unasked is exactly the noisy behaviour the
|
||||||
|
|||||||
+21
-12
@@ -10,6 +10,8 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/config"
|
"github.com/kami/maven/internal/config"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
func TestWireNetScanOffUnlessEnabled(t *testing.T) {
|
||||||
@@ -74,18 +76,6 @@ func TestScanSummaryOnAnEmptyRange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHostWordAgreesWithTheCount(t *testing.T) {
|
|
||||||
for n, want := range map[int]string{
|
|
||||||
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
|
|
||||||
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
|
|
||||||
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
|
|
||||||
} {
|
|
||||||
if got := hostWord(n); got != want {
|
|
||||||
t.Errorf("hostWord(%d) = %q, want %q", n, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsNetworkQuery(t *testing.T) {
|
func TestIsNetworkQuery(t *testing.T) {
|
||||||
yes := []string{
|
yes := []string{
|
||||||
"какие устройства в сети?",
|
"какие устройства в сети?",
|
||||||
@@ -169,3 +159,22 @@ func TestScanSummarySpeaksACountAndWritesTheAddresses(t *testing.T) {
|
|||||||
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
t.Errorf("a repeat question rescanned and rewrote the record (%d notes)", api.n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An unconfigured scanner names the gap instead of declining the turn.
|
||||||
|
//
|
||||||
|
// Falling through sent "какие устройства в сети?" to the search leg, which
|
||||||
|
// answered with a paragraph about routers in general — and put a question about
|
||||||
|
// his own LAN on an upstream engine, which the personal boundary exists to
|
||||||
|
// prevent (Vikunja #479).
|
||||||
|
func TestQueryNetworkNamesTheGapWhenNotConfigured(t *testing.T) {
|
||||||
|
h := &reactiveHandler{}
|
||||||
|
reply, ok := h.queryNetwork(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "какие устройства в сети?"},
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("an unconfigured scanner let the question fall through to search")
|
||||||
|
}
|
||||||
|
if !phraser.IsQ(phraser.QueryNetOff, nil, reply) {
|
||||||
|
t.Errorf("got %q, want the gap named", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
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,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ordinal selection over a list she just read (Vikunja #448).
|
||||||
|
//
|
||||||
|
// The dialogue session already carried the intent, the slots and the history.
|
||||||
|
// What it did not carry was the list: she recited five tasks, he said "второй",
|
||||||
|
// and there was nothing for that word to point at, so it routed as a fresh
|
||||||
|
// utterance and meant nothing.
|
||||||
|
//
|
||||||
|
// Candidates are bound when she speaks the list, in the order she spoke it (see
|
||||||
|
// tasks.Spoken). Binding afterwards would resolve "второй" against a fresh
|
||||||
|
// query, and the list can change between two turns.
|
||||||
|
//
|
||||||
|
// An ordinal with no verb is read back, not acted on: "второй" names a task, it
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
// 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. 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}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
toks := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
})
|
||||||
|
for i, tok := range toks {
|
||||||
|
if n, ok := candidateDigits[tok]; ok {
|
||||||
|
return n, 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
var candidateVerbs = []struct {
|
||||||
|
words []string
|
||||||
|
status string
|
||||||
|
say string
|
||||||
|
}{
|
||||||
|
{[]string{"готов", "сделал", "выполнил", "закрыл", "done"}, store.TaskDone, "закрыла"},
|
||||||
|
{[]string{"не надо", "убери", "отмени", "не буду", "drop"}, store.TaskDropped, "убрала"},
|
||||||
|
{[]string{"подтвержда", "беру", "да,", "буду делать"}, store.TaskOpen, "взяла в работу"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCandidateVerb(text string) (status, say string, ok bool) {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
for _, v := range candidateVerbs {
|
||||||
|
for _, w := range v.words {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return v.status, v.say, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(ctx context.Context, cands []dialogue.Candidate) {
|
||||||
|
if h.dialogueSessions == nil || len(cands) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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, src turnSource) (string, bool) {
|
||||||
|
if h.dialogueSessions == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
sess := h.dialogueSessions.Get(dialogueIDOf(ctx), h.now())
|
||||||
|
if sess == nil || len(sess.Candidates) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
nth, ok := parseOrdinal(text)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if nth < 0 {
|
||||||
|
nth = len(sess.Candidates)
|
||||||
|
}
|
||||||
|
if nth > len(sess.Candidates) {
|
||||||
|
// Claim the turn: he is picking from her list and named a position she
|
||||||
|
// did not read. Routing it fresh would answer something else entirely.
|
||||||
|
return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true
|
||||||
|
}
|
||||||
|
pick := sess.Candidates[nth-1]
|
||||||
|
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(), 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(dialogueIDOf(ctx), h.now(), nil)
|
||||||
|
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
||||||
|
return say + ": " + pick.Label, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
want int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"второй", 2, true},
|
||||||
|
{"вторую сделал", 2, true},
|
||||||
|
{"первую убери", 1, true},
|
||||||
|
{"последнюю не надо", -1, true},
|
||||||
|
{"3", 3, true},
|
||||||
|
{"the second one", 2, true},
|
||||||
|
// No position named.
|
||||||
|
{"какие у меня задачи", 0, false},
|
||||||
|
{"", 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)
|
||||||
|
if ok != c.ok || (ok && got != c.want) {
|
||||||
|
t.Errorf("parseOrdinal(%q) = %d,%v; want %d,%v", c.text, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
if _, handled := h.resolveCandidate(context.Background(), "второй", sourceVoice); handled {
|
||||||
|
t.Error("an ordinal with no list behind it was claimed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||||
|
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||||
|
|
||||||
|
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, "первый", sourceVoice); !handled {
|
||||||
|
t.Error("the list was spent by a read-back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||||
|
putCandidates(h, context.Background(), ids, "купить хлеб", "позвонить маме")
|
||||||
|
|
||||||
|
reply, handled := h.resolveCandidate(ctx, "первую сделал", sourceVoice)
|
||||||
|
if !handled || !strings.Contains(reply, "купить хлеб") {
|
||||||
|
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
|
||||||
|
}
|
||||||
|
live, err := st.ListTasks(ctx, "live")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list tasks: %v", err)
|
||||||
|
}
|
||||||
|
for _, task := range live {
|
||||||
|
if task.ID == ids[0] {
|
||||||
|
t.Fatalf("task %d is still live after he closed it", task.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Spent: a second ordinal against a list that no longer holds would close
|
||||||
|
// the wrong task.
|
||||||
|
if _, handled := h.resolveCandidate(ctx, "второй", sourceVoice); handled {
|
||||||
|
t.Error("the list survived the pick it was spent on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ids := seedTasks(t, st, "купить хлеб")
|
||||||
|
putCandidates(h, context.Background(), ids, "купить хлеб")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ordinalNow — a fixed capture time; the ranker only needs the rows to exist.
|
||||||
|
var ordinalNow = time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var ids []int64
|
||||||
|
for _, text := range texts {
|
||||||
|
res, err := st.CaptureTask(ctx, store.Task{
|
||||||
|
Text: text,
|
||||||
|
Source: "tap:voice",
|
||||||
|
Status: store.TaskOpen,
|
||||||
|
CreatedTs: ordinalNow,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("capture task: %v", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, res.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(dialogueIDOf(ctx), &dialogue.Session{Timestamp: h.now()})
|
||||||
|
h.offerCandidates(ctx, cands)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/phraser/eval"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The persona checks, run before she speaks (Vikunja #399).
|
||||||
|
//
|
||||||
|
// RunChecks and RunTalkChecks only ever ran from the eval package, so
|
||||||
|
// everything the fixtures measured was offline knowledge: we could say "about
|
||||||
|
// one reply in three is broken" and still ship every one of them. This runs the
|
||||||
|
// cheap half of that on the live path, and replaces a failing message with the
|
||||||
|
// deterministic floor.
|
||||||
|
//
|
||||||
|
// Which checks: the unambiguous string tests only — feminine self-reference,
|
||||||
|
// how she addresses him, and a leaked-reasoning test. Not length, which is
|
||||||
|
// path-specific, and not ontopic, which compares against fragments the fixture
|
||||||
|
// supplies and runtime does not have. Not hisgender either — see guardSpoken.
|
||||||
|
//
|
||||||
|
// No retry. A retry doubles the latency on the exact turn that is already going
|
||||||
|
// badly, and on the nudge path the moment has passed.
|
||||||
|
//
|
||||||
|
// The known cost, written down because it is real: a wrongly flagged good reply
|
||||||
|
// is replaced by a flatter stub one. That is the right trade — a stub sentence
|
||||||
|
// is dull, a leaked reasoning trace is broken — but it means these checks can
|
||||||
|
// no longer be tuned for sensitivity alone.
|
||||||
|
|
||||||
|
// checkLeak — the name reported when the model's scaffolding reaches the text.
|
||||||
|
const checkLeak = "leak"
|
||||||
|
|
||||||
|
// leakPatterns — reasoning and protocol that belongs to the model, not to him.
|
||||||
|
// The resident model is a Thinking variant, so an unclosed reasoning block is
|
||||||
|
// the failure mode, not a hypothetical (Vikunja #398).
|
||||||
|
var leakPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)<\s*/?\s*think`),
|
||||||
|
regexp.MustCompile(`(?i)thinking\s*(process|:)`),
|
||||||
|
regexp.MustCompile(`(?i)^\s*(assistant|user|system)\s*:`),
|
||||||
|
// Raw contract JSON: the parser already unwraps a good one, so a body that
|
||||||
|
// still carries the keys is one it could not read.
|
||||||
|
regexp.MustCompile(`"(response|mood|body|summary)"\s*:`),
|
||||||
|
// The persona block quoted back at him.
|
||||||
|
regexp.MustCompile(`(?i)(ты\s+—?\s*мэйвен|системный промпт|system prompt)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPersonaLeak reports whether the model's own scaffolding is in the text.
|
||||||
|
func checkPersonaLeak(body string) (string, bool) {
|
||||||
|
for _, re := range leakPatterns {
|
||||||
|
if m := re.FindString(body); m != "" {
|
||||||
|
return "leaked " + strings.TrimSpace(m), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// personaRejects counts what the guard caught, by check name, so the real
|
||||||
|
// production rate is knowable rather than inferred from the fixture.
|
||||||
|
var personaRejects = struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
by map[string]int
|
||||||
|
}{by: map[string]int{}}
|
||||||
|
|
||||||
|
func personaRejectCounts() map[string]int {
|
||||||
|
personaRejects.mu.Lock()
|
||||||
|
defer personaRejects.mu.Unlock()
|
||||||
|
out := make(map[string]int, len(personaRejects.by))
|
||||||
|
for k, v := range personaRejects.by {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardSpoken checks a phrased message. It returns the failed check and false
|
||||||
|
// when the message must not be said; path names the caller, for the log.
|
||||||
|
//
|
||||||
|
// An empty message passes: the caller already treats that as a failure and
|
||||||
|
// falls back on its own, and reporting it as a persona breach would put a
|
||||||
|
// misleading line in the count.
|
||||||
|
func guardSpoken(path, body string) (string, bool) {
|
||||||
|
if strings.TrimSpace(body) == "" {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
if detail, ok := checkPersonaLeak(body); !ok {
|
||||||
|
return rejectSpoken(path, checkLeak, detail, body), false
|
||||||
|
}
|
||||||
|
// Feminine and address only. HisGender is not run here: it reads a
|
||||||
|
// sentence-initial feminine verb with no pronoun — "записала, что ты выпил
|
||||||
|
// воды" — as a woman being addressed, when it is her own correct
|
||||||
|
// self-reference. Offline that is a point of score; on this path it would
|
||||||
|
// replace a good reply with a stub one on every fact she confirms.
|
||||||
|
for _, r := range []eval.Result{eval.Feminine(body), eval.Address(body)} {
|
||||||
|
if !r.Pass {
|
||||||
|
return rejectSpoken(path, r.Name, r.Detail, body), false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// rejectSpoken logs what she nearly said and counts it. The whole text, not a
|
||||||
|
// prefix: the point of the log line is that the failure can be read back later
|
||||||
|
// and argued with.
|
||||||
|
func rejectSpoken(path, check, detail, body string) string {
|
||||||
|
personaRejects.mu.Lock()
|
||||||
|
personaRejects.by[check]++
|
||||||
|
personaRejects.mu.Unlock()
|
||||||
|
log.Printf("persona: %s rejected on %s (%s): %q", path, check, detail, body)
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardNudge checks a phrased nudge and falls back to the deterministic floor
|
||||||
|
// when it fails. The nudge path, unlike the reply path, cannot ask again: the
|
||||||
|
// tick has already decided she speaks, so the choice is the floor's wording or
|
||||||
|
// a broken sentence.
|
||||||
|
func guardNudge(pn delivery.PhrasedNudge, cand loop.Candidate) delivery.PhrasedNudge {
|
||||||
|
if _, ok := guardSpoken("nudge", pn.Body); ok {
|
||||||
|
return pn
|
||||||
|
}
|
||||||
|
stub, err := phraser.NewStub().PhraseNudge(context.Background(), cand)
|
||||||
|
if err != nil {
|
||||||
|
// The Stub is templates over the candidate and does not fail. If it
|
||||||
|
// somehow does, the model's text is still what the rule decided to
|
||||||
|
// say, and saying nothing is the worse outcome.
|
||||||
|
return pn
|
||||||
|
}
|
||||||
|
return stub
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGuardPassesWhatSheShouldSay(t *testing.T) {
|
||||||
|
good := []string{
|
||||||
|
"записала: купить хлеб.",
|
||||||
|
"поняла, напомню в 11:00.",
|
||||||
|
"ты не пил воду с утра.",
|
||||||
|
"я рада, что получилось.",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
for _, body := range good {
|
||||||
|
if check, ok := guardSpoken("test", body); !ok {
|
||||||
|
t.Errorf("guardSpoken(%q) rejected on %s", body, check)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardStopsWhatSheShouldNot(t *testing.T) {
|
||||||
|
bad := []struct {
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"<think>он просил воду</think> попей воды.", checkLeak},
|
||||||
|
{"Thinking Process: он давно не пил.", checkLeak},
|
||||||
|
{`{"response": "попей воды", "mood": "neutral"}`, checkLeak},
|
||||||
|
{"я напомнил тебе про воду.", "feminine"},
|
||||||
|
{"вы давно не пили воду.", "address"},
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
check, ok := guardSpoken("test", c.body)
|
||||||
|
if ok {
|
||||||
|
t.Errorf("guardSpoken(%q) let it through", c.body)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if check != c.want {
|
||||||
|
t.Errorf("guardSpoken(%q) failed on %s; want %s", c.body, check, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGuardCountsWhatItCaught(t *testing.T) {
|
||||||
|
before := personaRejectCounts()[checkLeak]
|
||||||
|
if _, ok := guardSpoken("test", "<think>…"); ok {
|
||||||
|
t.Fatal("a leaked reasoning block was let through")
|
||||||
|
}
|
||||||
|
if after := personaRejectCounts()[checkLeak]; after != before+1 {
|
||||||
|
t.Errorf("leak count %d; want %d", after, before+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGuardNudgeFallsBackToTheFloor — a broken nudge is replaced by the
|
||||||
|
// deterministic wording, not dropped and not retried.
|
||||||
|
func TestGuardNudgeFallsBackToTheFloor(t *testing.T) {
|
||||||
|
cand := loop.Candidate{Rule: loop.Rule{Name: "water"}}
|
||||||
|
bad := delivery.PhrasedNudge{Candidate: cand, Body: "Thinking Process: он не пил.", Mood: "neutral"}
|
||||||
|
got := guardNudge(bad, cand)
|
||||||
|
if got.Body == bad.Body {
|
||||||
|
t.Fatal("the broken nudge was delivered unchanged")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(got.Body) == "" {
|
||||||
|
t.Fatal("the nudge was dropped rather than re-worded")
|
||||||
|
}
|
||||||
|
good := delivery.PhrasedNudge{Candidate: cand, Body: "попей воды.", Mood: "neutral"}
|
||||||
|
if guardNudge(good, cand).Body != good.Body {
|
||||||
|
t.Error("a good nudge was replaced")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,32 @@ var worldSeeds = []string{
|
|||||||
"что мне почитать про историю",
|
"что мне почитать про историю",
|
||||||
"что я должен знать про питон",
|
"что я должен знать про питон",
|
||||||
"what can i watch tonight",
|
"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",
|
||||||
}
|
}
|
||||||
|
|
||||||
// personalBoundary holds the embedded seeds. Zero value is usable and means
|
// personalBoundary holds the embedded seeds. Zero value is usable and means
|
||||||
|
|||||||
@@ -68,9 +68,30 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
|||||||
{"я хочу узнать про рим", false},
|
{"я хочу узнать про рим", false},
|
||||||
{"кто такой гагарин", false},
|
{"кто такой гагарин", false},
|
||||||
{"how do i boil an egg", 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{embedder: emb}
|
h := &reactiveHandler{recall: recallWiring{embedder: emb}}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
wrong := 0
|
wrong := 0
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
@@ -80,7 +101,7 @@ func TestONNXPersonalBoundary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec}
|
turn := &queryTurn{dec: router.Decision{Utterance: c.utterance}, vec: vec}
|
||||||
got := h.isPersonalTurn(ctx, turn)
|
got := h.isPersonalTurn(ctx, turn)
|
||||||
p, w, ok := h.boundary.score(vec)
|
p, w, ok := h.recall.boundary.score(vec)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("seeds did not load with a working embedder")
|
t.Fatal("seeds did not load with a working embedder")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A Praxis lifecycle failure used to return a hardcoded constant that named the
|
||||||
|
// verb and never the service, so an outage, a refused token and a contract
|
||||||
|
// mismatch all produced the identical sentence (Vikunja #588). The helpers and
|
||||||
|
// the Hexis half of the same defect are in ecosystem_gap_test.go.
|
||||||
|
|
||||||
|
func TestPraxisLifecycle401NamesPraxis(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
praxis.SetFault(401)
|
||||||
|
reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "item_1"))
|
||||||
|
if !strings.Contains(reply, servicePraxis) {
|
||||||
|
t.Fatalf("praxis failure does not name Praxis: %q", reply)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, phraser.A(phraser.EcoDenied, serviceVars(servicePraxis))) {
|
||||||
|
t.Fatalf("401 from praxis: got %q, want the denied line", reply)
|
||||||
|
}
|
||||||
|
// The verb that did not happen is still said: the trace is the only other
|
||||||
|
// place it exists and he is not reading the trace.
|
||||||
|
if !strings.Contains(reply, "не получилось отметить сделанным.") {
|
||||||
|
t.Errorf("reply dropped the operation that failed: %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPraxisLifecycleOutageDiffersFrom401 — the identity that was the bug.
|
||||||
|
func TestPraxisLifecycleOutageDiffersFrom401(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
praxis.SetFault(401)
|
||||||
|
refused := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||||
|
|
||||||
|
h.ecosystem = &ecosystemWiring{praxis: newPraxisClient(unreachableURL)}
|
||||||
|
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||||
|
|
||||||
|
if refused == outage {
|
||||||
|
t.Fatalf("a refused token and an outage still say the same thing: %q", refused)
|
||||||
|
}
|
||||||
|
if !strings.Contains(outage, phraser.A(phraser.EcoDown, serviceVars(servicePraxis))) {
|
||||||
|
t.Fatalf("praxis outage: got %q, want the outage line naming Praxis", outage)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "отметь второй пункт" names a position, and only the daemon knows which item
|
||||||
|
// that is. The router fills the value slot with "2"; this is where it becomes an
|
||||||
|
// item id (Vikunja #516).
|
||||||
|
func TestPositionResolvesAgainstTheLastSpokenList(t *testing.T) {
|
||||||
|
praxis := newFakePraxis(t, `[
|
||||||
|
{"id":"item_a","title":"диск заканчивается"},
|
||||||
|
{"id":"item_b","title":"бэкап не прошёл"},
|
||||||
|
{"id":"item_c","title":"сертификат истекает"}
|
||||||
|
]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
if reply := h.handlePraxisAct(context.Background(), praxisActDec("list_attention")); reply == "" {
|
||||||
|
t.Fatal("attention returned nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct{ ref, wantItem string }{
|
||||||
|
{"2", "item_b"},
|
||||||
|
{"1", "item_a"},
|
||||||
|
{"last", "item_c"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
praxis.ResetRequests()
|
||||||
|
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", c.ref))
|
||||||
|
if !strings.Contains(reply, "принято") {
|
||||||
|
t.Errorf("ref %q: reply %q", c.ref, reply)
|
||||||
|
}
|
||||||
|
if !requestedPathContaining(praxis, c.wantItem) {
|
||||||
|
t.Errorf("ref %q did not acknowledge %s; paths %v", c.ref, c.wantItem, paths(praxis))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A position past the end must not acknowledge the wrong item. It asks.
|
||||||
|
func TestPositionPastTheEndAsksInsteadOfGuessing(t *testing.T) {
|
||||||
|
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск заканчивается"}]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
|
||||||
|
praxis.ResetRequests()
|
||||||
|
reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "4"))
|
||||||
|
if !strings.Contains(reply, "какой пункт") {
|
||||||
|
t.Errorf("a position with no item should ask, got %q", reply)
|
||||||
|
}
|
||||||
|
if requestedPathContaining(praxis, "item_a") {
|
||||||
|
t.Error("the only surfaced item was resolved for a position that did not name it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No digest yet means no positions. Nothing is mutated.
|
||||||
|
func TestPositionWithNoSpokenListAsks(t *testing.T) {
|
||||||
|
praxis := newFakePraxis(t, `[]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||||
|
if !strings.Contains(reply, "какой пункт") {
|
||||||
|
t.Errorf("want the ask, got %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit id is not a position and passes through untouched.
|
||||||
|
func TestExplicitItemIDIsNotRewritten(t *testing.T) {
|
||||||
|
praxis := newFakePraxis(t, `[{"id":"item_a","title":"диск"}]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
|
||||||
|
praxis.ResetRequests()
|
||||||
|
h.handlePraxisAct(context.Background(), praxisItemDec("pin_item", "item_zz"))
|
||||||
|
if !requestedPathContaining(praxis, "item_zz") {
|
||||||
|
t.Errorf("the id he gave was not the one called; paths %v", paths(praxis))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An item Praxis sent without a title is never spoken, so it holds no position.
|
||||||
|
func TestUnspokenItemsHoldNoPosition(t *testing.T) {
|
||||||
|
praxis := newFakePraxis(t, `[
|
||||||
|
{"id":"item_silent"},
|
||||||
|
{"id":"item_said","title":"бэкап не прошёл"}
|
||||||
|
]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
|
||||||
|
praxis.ResetRequests()
|
||||||
|
h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "1"))
|
||||||
|
if !requestedPathContaining(praxis, "item_said") {
|
||||||
|
t.Errorf("position 1 is the first item she SAID; paths %v", paths(praxis))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The item id travels in the POST body, so that is what these read.
|
||||||
|
func paths(f *fakeServer) []string {
|
||||||
|
var out []string
|
||||||
|
for _, r := range f.Requests() {
|
||||||
|
out = append(out, r.Path+" "+string(r.Body))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func requestedPathContaining(f *fakeServer, want string) bool {
|
||||||
|
for _, r := range f.Requests() {
|
||||||
|
if strings.Contains(string(r.Body), want) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// "отметь это как сделанное" after a one-item digest points at that item.
|
||||||
|
func TestDemonstrativeResolvesWhenOneItemWasSpoken(t *testing.T) {
|
||||||
|
praxis := newFakePraxis(t, `[{"id":"item_only","title":"бэкап не прошёл"}]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
|
||||||
|
praxis.ResetRequests()
|
||||||
|
reply := h.handlePraxisAct(context.Background(), praxisItemDec("acknowledge_item", "this"))
|
||||||
|
if !strings.Contains(reply, "принято") {
|
||||||
|
t.Errorf("reply %q", reply)
|
||||||
|
}
|
||||||
|
if !requestedPathContaining(praxis, "item_only") {
|
||||||
|
t.Errorf("the one surfaced item was not acknowledged; paths %v", paths(praxis))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointing at one of several is a guess, and a wrong guess transitions the wrong
|
||||||
|
// item. The turn goes back to the cascade instead.
|
||||||
|
func TestDemonstrativeWithSeveralItemsGivesTheTurnBack(t *testing.T) {
|
||||||
|
praxis := newFakePraxis(t, `[
|
||||||
|
{"id":"item_a","title":"диск"},
|
||||||
|
{"id":"item_b","title":"бэкап"}
|
||||||
|
]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
h.handlePraxisAct(context.Background(), praxisActDec("list_attention"))
|
||||||
|
|
||||||
|
praxis.ResetRequests()
|
||||||
|
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||||
|
t.Errorf("want a fall-through, got %q", reply)
|
||||||
|
}
|
||||||
|
for _, p := range paths(praxis) {
|
||||||
|
if strings.Contains(p, "resolve") {
|
||||||
|
t.Error("an ambiguous demonstrative resolved an item anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "я это сделал" with no digest behind it is a sentence about his day.
|
||||||
|
func TestDemonstrativeWithNoDigestGivesTheTurnBack(t *testing.T) {
|
||||||
|
praxis := newFakePraxis(t, `[]`)
|
||||||
|
h := newPraxisTestHandler(t, praxis)
|
||||||
|
|
||||||
|
if reply := h.handlePraxisAct(context.Background(), praxisItemDec("resolve_item", "this")); reply != "" {
|
||||||
|
t.Errorf("want a fall-through, got %q", reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/loop"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The row is the whole mechanism, and nothing wrote it (Vikunja #532).
|
||||||
|
//
|
||||||
|
// The existing hysteresis test in internal/store scores the pure function and
|
||||||
|
// passed throughout, which is exactly why this went unnoticed: Resolve was
|
||||||
|
// always correct and was always handed the cold-start Away. So this test asserts
|
||||||
|
// the round trip — the tick writes what gather resolved, and the next load
|
||||||
|
// reads it back — rather than re-testing the function.
|
||||||
|
func TestTickPersistsTheResolvedBucket(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Cold start: no row, so a load must say Away and the zero time.
|
||||||
|
b, score, updated, err := st.LoadPresenceState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load before: %v", err)
|
||||||
|
}
|
||||||
|
if b != store.Away || score != 0 || !updated.IsZero() {
|
||||||
|
t.Fatalf("cold start = %s/%v/%v, want away/0/zero", b, score, updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
tl := &tickLoop{store: st}
|
||||||
|
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
|
||||||
|
|
||||||
|
b, score, updated, err = st.LoadPresenceState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load after: %v", err)
|
||||||
|
}
|
||||||
|
if b != store.Present {
|
||||||
|
t.Errorf("bucket = %s, want present", b)
|
||||||
|
}
|
||||||
|
if score != 0.9 {
|
||||||
|
t.Errorf("score = %v, want 0.9", score)
|
||||||
|
}
|
||||||
|
if updated.IsZero() {
|
||||||
|
t.Error("updated_ts was not written, so /dash still reads (never)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The singleton stays a singleton, and a later tick overwrites rather than
|
||||||
|
// accumulating. A row per tick would make LoadPresenceState's single-row query
|
||||||
|
// return whichever one SQLite felt like.
|
||||||
|
func TestPresenceStateIsOverwrittenNotAppended(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
tl := &tickLoop{store: st}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, now)
|
||||||
|
tl.savePresence(ctx, loop.State{Presence: store.Away, PresenceScore: 0.1}, now.Add(time.Minute))
|
||||||
|
|
||||||
|
b, score, _, err := st.LoadPresenceState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if b != store.Away || score != 0.1 {
|
||||||
|
t.Fatalf("got %s/%v, want the second write (away/0.1)", b, score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the persisted row buys: the hold band. A score sitting between Exit and
|
||||||
|
// Enter holds Present when the last bucket was Present, and stays Away when it
|
||||||
|
// was Away. Before the write existed the second arm was the only one that could
|
||||||
|
// ever run, so presence dropped at roughly four minutes of idle instead of
|
||||||
|
// holding to the exit threshold at about nine.
|
||||||
|
func TestPersistedBucketIsWhatFeedsHysteresis(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
tl := &tickLoop{store: st}
|
||||||
|
|
||||||
|
mid := (store.PresenceExit + store.PresenceEnter) / 2
|
||||||
|
if mid <= store.PresenceExit || mid >= store.PresenceEnter {
|
||||||
|
t.Fatalf("%v is not inside the hold band", mid)
|
||||||
|
}
|
||||||
|
|
||||||
|
tl.savePresence(ctx, loop.State{Presence: store.Present, PresenceScore: 0.9}, time.Now())
|
||||||
|
last, _, _, err := st.LoadPresenceState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if got := store.Resolve(mid, last); got != store.Present {
|
||||||
|
t.Errorf("Resolve(%v, %s) = %s, want present — the hold band did not apply", mid, last, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,6 +34,57 @@ func (f *fixedEmbedder) Embed(_ context.Context, text string) ([]float32, error)
|
|||||||
return v, nil
|
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
|
// scoreVec builds a unit vector whose cosine against the query vector
|
||||||
// (1,0,0,0) is exactly score.
|
// (1,0,0,0) is exactly score.
|
||||||
func scoreVec(score float64) []float32 {
|
func scoreVec(score float64) []float32 {
|
||||||
@@ -85,15 +139,17 @@ func buildRecallHandler(t *testing.T, question string, mems []recallCase) (*reac
|
|||||||
|
|
||||||
phr := &recordingPhraser{Stub: phraser.NewStub()}
|
phr := &recordingPhraser{Stub: phraser.NewStub()}
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: ipc.NewStoreAPI(st),
|
api: ipc.NewStoreAPI(st),
|
||||||
embedder: emb,
|
recall: recallWiring{
|
||||||
|
embedder: emb,
|
||||||
|
memStore: mem,
|
||||||
|
minScore: 0.55,
|
||||||
|
minMargin: 0.008,
|
||||||
|
},
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
phraser: phr,
|
phraser: phr,
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: mem,
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
queryMinScore: 0.55,
|
|
||||||
queryMinMargin: 0.008,
|
|
||||||
weatherProvider: nil,
|
weatherProvider: nil,
|
||||||
}
|
}
|
||||||
return h, phr
|
return h, phr
|
||||||
@@ -121,8 +177,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
|||||||
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
||||||
})
|
})
|
||||||
reply := askQuery(t, h, q)
|
reply := askQuery(t, h, q)
|
||||||
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
|
if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
|
||||||
t.Errorf("reply %q, want %q", reply, want)
|
t.Errorf("reply %q, want the note read back", reply)
|
||||||
}
|
}
|
||||||
// One text, the winning memory's — the answer came from the memory
|
// One text, the winning memory's — the answer came from the memory
|
||||||
// pass, not from handing the phraser every note in the table.
|
// pass, not from handing the phraser every note in the table.
|
||||||
@@ -151,7 +207,7 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
|||||||
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
||||||
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
||||||
})
|
})
|
||||||
if reply := askQuery(t, h, q); reply != "не знаю." {
|
if reply := askQuery(t, h, q); !phraser.IsUnknownFallback(reply) {
|
||||||
t.Errorf("reply %q, want silence", reply)
|
t.Errorf("reply %q, want silence", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+60
-39
@@ -11,6 +11,8 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||||
@@ -32,10 +34,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
val := "false"
|
val := "false"
|
||||||
reply := "тихий режим выключен."
|
reply := phraser.Ack(phraser.AckQuietOff, nil)
|
||||||
if on {
|
if on {
|
||||||
val = "true"
|
val = "true"
|
||||||
reply = "тихий режим включён. буду реже напоминать."
|
reply = phraser.Ack(phraser.AckQuietOn, nil)
|
||||||
}
|
}
|
||||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||||
Ts: h.now(),
|
Ts: h.now(),
|
||||||
@@ -46,34 +48,44 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
|||||||
Confidence: 1.0,
|
Confidence: 1.0,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: write quiet_hours: %v", err)
|
log.Printf("voice: write quiet_hours: %v", err)
|
||||||
return "не получилось переключить тихий режим.", true
|
return phraser.Ack(phraser.FailQuiet, nil), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// quietInflections — the inflectional endings a stem may carry and still be
|
// quietStem reports whether tok is one of the words a vocabulary slot accepts.
|
||||||
// the same word. Adjective/adverb/noun/verb endings, all ≤3 letters. This is
|
// A slot is written as alternatives joined by "|", and an alternative comes in
|
||||||
// what separates "тихий"/"тихом"/"тихо" (stem "тих" + a real ending) from
|
// two flavours:
|
||||||
// "тихонько"/"потихоньку", which are different words: "онько" is not an
|
//
|
||||||
// ending, and "потихоньку" doesn't start with the stem at all.
|
// - a dictionary form, matched through the dictionary, so every case and
|
||||||
var quietInflections = []string{
|
// gender of it counts. This is what the nouns and adjectives want: "тихий",
|
||||||
"", "а", "е", "и", "й", "о", "у", "ы", "ю", "я",
|
// "тихом", "тихо" and "тише" are one word.
|
||||||
"ая", "ее", "ей", "ем", "ие", "ий", "им", "их", "ия", "ию", "ое", "ой", "ом", "ую", "ые", "ый", "ым", "ых", "ья",
|
// - a form prefixed with "=", matched as the exact token. This is what the
|
||||||
"ами", "ого", "ому", "ыми", "ать", "ить", "ять",
|
// VERBS want, and it is not a shortcut. A command is an imperative, and the
|
||||||
}
|
// dictionary quite correctly files "говори" and "говорил" under one lemma —
|
||||||
|
// so lemma-matching a verb slot read "он говорил тихим голосом весь вечер",
|
||||||
// quietStem reports whether tok is the given stem carrying at most one
|
// a remark about his evening, as an order to go quiet. Aspect pairs are two
|
||||||
// inflectional ending. Word boundaries come from tokenisation (see
|
// separate verbs, which is why several imperatives are listed by hand.
|
||||||
// quietTokens), not from a regexp — Go's \b is ASCII-oriented and treats every
|
//
|
||||||
// Cyrillic letter as a non-word character, so `\bтих\b` would happily match
|
// Word boundaries come from tokenisation (see quietTokens), not from a regexp —
|
||||||
// inside "тихонько". Comparing whole tokens sidesteps that entirely.
|
// Go's \b is ASCII-oriented and treats every Cyrillic letter as a non-word
|
||||||
func quietStem(tok, stem string) bool {
|
// character, so `\bтих\b` would happily match inside "тихонько". Comparing whole
|
||||||
if !strings.HasPrefix(tok, stem) {
|
// tokens sidesteps that entirely.
|
||||||
return false
|
//
|
||||||
}
|
// The comparison is a dictionary lookup, not a stem plus a list of 36 endings
|
||||||
suffix := tok[len(stem):]
|
// (Vikunja #526). The distinction the old comment described is exactly the one a
|
||||||
for _, e := range quietInflections {
|
// dictionary makes: "тихий", "тихом", "тихо" and "тише" are one word inflected,
|
||||||
if suffix == e {
|
// while "тихонько" and "потихоньку" are different words — and the dictionary
|
||||||
|
// knows that without anybody deciding that "онько" is not an ending.
|
||||||
|
func quietStem(tok, slot string) bool {
|
||||||
|
for _, form := range strings.Split(slot, "|") {
|
||||||
|
if exact, ok := strings.CutPrefix(form, "="); ok {
|
||||||
|
if tok == exact {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if morph.SameWord(tok, form) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +128,10 @@ func quietPhrase(tokens, pattern []string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as stem sequences.
|
// quietOffPhrases / quietOnPhrases — the toggle vocabulary, as sequences of
|
||||||
|
// dictionary forms. They used to be truncated stems ("тих", "выключ"), which is
|
||||||
|
// what the ending list existed to complete; a dictionary form needs no
|
||||||
|
// completing (Vikunja #526).
|
||||||
//
|
//
|
||||||
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
// Note what is NOT here any more: the OFF list used to carry {"не", "тих"} and
|
||||||
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
// the ON list {"не", "шум"} / {"не", "беспоко"}. Both were adjacency patterns,
|
||||||
@@ -127,36 +142,42 @@ func quietPhrase(tokens, pattern []string) bool {
|
|||||||
var (
|
var (
|
||||||
quietOffPhrases = [][]string{
|
quietOffPhrases = [][]string{
|
||||||
{"quiet", "off"}, {"quiet", "end"},
|
{"quiet", "off"}, {"quiet", "end"},
|
||||||
{"громк", "режим"}, {"шумн", "режим"},
|
{"громкий", "режим"}, {"шумный", "режим"},
|
||||||
{"отмен", "тих"}, {"выключ", "тих"},
|
{"=отмени|=отменяй|=отменить", "тихий"},
|
||||||
|
{"=выключи|=выключай|=выключить", "тихий"},
|
||||||
}
|
}
|
||||||
quietOnPhrases = [][]string{
|
quietOnPhrases = [][]string{
|
||||||
{"quiet", "on"}, {"quiet", "mode"},
|
{"quiet", "on"}, {"quiet", "mode"},
|
||||||
{"тих", "режим"}, {"не", "шум"}, {"не", "беспоко"},
|
{"тихий", "режим"}, {"не", "=шуми|=шумите"}, {"не", "=беспокой|=беспокоить"},
|
||||||
// The noun form and the comparative. "режим тишины" is how the
|
// The noun form and the comparative. "режим тишины" is how the
|
||||||
// setting is named half the time, and "сделай потише" is how it is
|
// setting is named half the time, and "сделай потише" is how it is
|
||||||
// actually asked for out loud. Both used to fall through to the
|
// actually asked for out loud. Both used to fall through to the
|
||||||
// router, which has no quiet intent, so the command did nothing.
|
// router, which has no quiet intent, so the command did nothing.
|
||||||
{"режим", "тишин"}, {"сделай", "тише"}, {"сделай", "потише"},
|
{"режим", "тишина"}, {"=сделай", "тихий"}, {"=сделай", "потише"},
|
||||||
{"говори", "тише"}, {"будь", "потише"},
|
{"=говори", "тихий"}, {"=будь", "потише"},
|
||||||
{"тих"}, {"потише"},
|
{"тихий"}, {"потише"},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// quietWordStems — every stem that names the setting. Used by the
|
// quietWordStems — every word that names the setting. Used by the
|
||||||
// negated-but-unmatched fallback in classifyQuietToggle, which has to
|
// negated-but-unmatched fallback in classifyQuietToggle, which has to
|
||||||
// recognise "хватит тишины" without an ON phrase having matched.
|
// recognise "хватит тишины" without an ON phrase having matched.
|
||||||
var quietWordStems = []string{"тих", "тишин", "потише"}
|
var quietWordStems = []string{"тихий", "тишина", "потише"}
|
||||||
|
|
||||||
// quietNegatorWords — negators that are whole words with no useful stem.
|
// quietNegatorWords — negators that are whole words with no useful stem.
|
||||||
var quietNegatorWords = map[string]bool{
|
var quietNegatorWords = map[string]bool{
|
||||||
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
"не": true, "нет": true, "хватит": true, "no": true, "not": true, "off": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// quietNegatorStems — negators that inflect. Matched through quietStem, the
|
// quietNegatorStems — negators that inflect. Imperatives, matched exactly for
|
||||||
// same one-ending rule the toggle vocabulary uses, so "выключи", "выключить"
|
// the reason quietStem gives: "выключи" is a command and "выключил" is a report
|
||||||
// and "выключай" all count and "выключатель" does not.
|
// about earlier, and one lemma covers both. "выключатель" was never a negator
|
||||||
var quietNegatorStems = []string{"выключ", "отмен", "прекрат", "убер", "stop", "cancel", "disable"}
|
// and is not one now.
|
||||||
|
var quietNegatorStems = []string{
|
||||||
|
"=выключи|=выключай|=выключить", "=отмени|=отменяй|=отменить",
|
||||||
|
"=прекрати|=прекращай|=прекратить", "=убери|=убирай|=убрать",
|
||||||
|
"stop", "cancel", "disable",
|
||||||
|
}
|
||||||
|
|
||||||
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
// quietNegated reports whether the utterance carries a negator. Two ON phrases
|
||||||
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
// are themselves built on "не" — "не шуми", "не беспокой" — and those are
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
"github.com/kami/maven/internal/tool"
|
"github.com/kami/maven/internal/tool"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
@@ -24,11 +26,10 @@ func TestReactiveNotesReminders(t *testing.T) {
|
|||||||
|
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
embedder: emb,
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: rtr,
|
router: rtr,
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
memStore: memory.NewInMemoryStore(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,9 +44,12 @@ func TestReactiveNotesReminders(t *testing.T) {
|
|||||||
HasTime: true,
|
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)
|
reply := h.applyAction(ctx, dec)
|
||||||
if reply != "" {
|
if want := "хорошо, напомню завтра в " + fireAt.Format("15:04") + "."; reply != want {
|
||||||
t.Errorf("expected empty reply from applyAction, got %q", reply)
|
t.Errorf("reply = %q, want %q", reply, want)
|
||||||
}
|
}
|
||||||
reminders, err := st.ListReminders(ctx, 10)
|
reminders, err := st.ListReminders(ctx, 10)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -85,3 +89,37 @@ func TestReactiveNotesReminders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// reached and every capture answered "Что сделать?" (Vikunja #467).
|
||||||
|
func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st := newTestStore(t)
|
||||||
|
api := ipc.NewStoreAPI(st)
|
||||||
|
now := time.Now()
|
||||||
|
emb := router.NewHashEmbedder(1024)
|
||||||
|
matcher := tool.NewMatcher(api)
|
||||||
|
h := &reactiveHandler{
|
||||||
|
api: api,
|
||||||
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
|
router: buildRouter(emb, matcher, 0.55, nil),
|
||||||
|
replier: voice.NewStubReplier(),
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
dataStore: st,
|
||||||
|
}
|
||||||
|
|
||||||
|
reply := h.handleText(ctx, "web", "добавь в задачи купить молоко")
|
||||||
|
if !strings.Contains(reply, "купить молоко") {
|
||||||
|
t.Fatalf("capture did not claim the turn: %q", reply)
|
||||||
|
}
|
||||||
|
open, err := st.ListTasks(ctx, store.TaskOpen)
|
||||||
|
if err != nil || len(open) != 1 {
|
||||||
|
t.Fatalf("task was not filed: tasks=%v err=%v", open, err)
|
||||||
|
}
|
||||||
|
// The words he said, not the model's rewrite of them.
|
||||||
|
if open[0].Text != "купить молоко" {
|
||||||
|
t.Fatalf("task text was rewritten: %q", open[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+41
-1
@@ -1,6 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/kami/maven/internal/memory"
|
import (
|
||||||
|
"github.com/kami/maven/internal/memory"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
// bestRecall is the read side of the long-term memory store: the top hit when
|
// bestRecall is the read side of the long-term memory store: the top hit when
|
||||||
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
// it clears the confidence gate. The index holds BOTH notes and facts, and
|
||||||
@@ -23,3 +26,40 @@ func bestRecall(results []memory.Result, minScore, minMargin float64) (memory.Re
|
|||||||
}
|
}
|
||||||
return results[0], true
|
return results[0], true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recallWiring — the recall subsystem's dependencies, held as one group on
|
||||||
|
// reactiveHandler (Vikunja #433). It is the worked example for the wiring
|
||||||
|
// decision in docs/handler-wiring.md: cohesive groups of fields, not thirty
|
||||||
|
// loose ones, so a handler names what it needs and the package can be split
|
||||||
|
// later without exporting the whole struct.
|
||||||
|
//
|
||||||
|
// The zero value is usable and means "no recall": no embedder, no vector
|
||||||
|
// store, and a gate that is never consulted because nothing is ever searched.
|
||||||
|
type recallWiring struct {
|
||||||
|
// embedder — reused for note write/query (same model as the classifier).
|
||||||
|
embedder router.Embedder
|
||||||
|
|
||||||
|
// memStore — the vector index over notes and facts.
|
||||||
|
memStore memory.Store
|
||||||
|
|
||||||
|
// topics — the embedded seed sets behind the weather, house and LAN
|
||||||
|
// recognisers (topics.go). Same lifecycle as boundary below: zero value is
|
||||||
|
// usable, loads on first query, and with no embedder it never loads and
|
||||||
|
// each source falls back to its own keyword test.
|
||||||
|
topics topicIndex
|
||||||
|
|
||||||
|
// boundary — the embedded seed sets behind the personal boundary
|
||||||
|
// (personalboundary.go). Zero value is usable and loads on first query;
|
||||||
|
// with no embedder it never loads and the boundary uses personalMarkers.
|
||||||
|
boundary personalBoundary
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
minScore float64
|
||||||
|
|
||||||
|
// minMargin — the second half of that gate: how far the top hit must beat
|
||||||
|
// the runner-up. 0 ⇒ margin off.
|
||||||
|
minMargin float64
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reminderMarker — the words that open a reminder. Stripped because they are
|
||||||
|
// the instruction, not the thing to say at the hour.
|
||||||
|
//
|
||||||
|
// The verbs come from the lexicon (Vikunja #530). They are a closed set of the
|
||||||
|
// commands she answers to, exactly like capture_verbs, and the literal that
|
||||||
|
// stood here knew four of them.
|
||||||
|
var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:` + alternation(lexicon.ReminderVerbs()) +
|
||||||
|
`)\s*(?:мне|me)?[\s,:—-]*`)
|
||||||
|
|
||||||
|
// reminderTimeWords — the time expressions a reminder carries, removed from
|
||||||
|
// the body because the fire time is already a column. Ordered longest-first
|
||||||
|
// where two could match the same words, so "через полтора часа" does not leave
|
||||||
|
// "полтора" behind.
|
||||||
|
//
|
||||||
|
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word
|
||||||
|
// boundaries here are written out as whitespace or an end of string — the same
|
||||||
|
// trap the agenda grammars hit.
|
||||||
|
//
|
||||||
|
// The Russian word lists are gone (Vikunja #530). The day words are
|
||||||
|
// lexicon.DayOffsetWords, which is why "вчера" and "позавчера" are stripped now
|
||||||
|
// and were not before, and the times of day are lexicon.PartsOfDay. What is
|
||||||
|
// still written out here is the shape of a clock reading — a preposition, digits,
|
||||||
|
// a colon — which is structured input rather than a claim about Russian.
|
||||||
|
var reminderTimeWords = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.DayOffsetWords()) + `)(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.PartsOfDay()) + `)(\s|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// alternation folds a lexicon set into one regexp branch, longest member first
|
||||||
|
// so "послезавтра" is not matched as "завтра" with a tail left behind. Sorted
|
||||||
|
// rather than taken as given, because two members of equal length must still
|
||||||
|
// produce the same pattern on every build.
|
||||||
|
func alternation(set []string) string {
|
||||||
|
out := make([]string, 0, len(set))
|
||||||
|
for _, w := range set {
|
||||||
|
out = append(out, regexp.QuoteMeta(w))
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
if len(out[i]) != len(out[j]) {
|
||||||
|
return len(out[i]) > len(out[j])
|
||||||
|
}
|
||||||
|
return out[i] < out[j]
|
||||||
|
})
|
||||||
|
return strings.Join(out, "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reminderBody is what she says at the hour.
|
||||||
|
//
|
||||||
|
// The whole utterance used to be stored, so /reminders read "напомни завтра в
|
||||||
|
// 9 утра выпить таблетки" where it should read "выпить таблетки", and the
|
||||||
|
// agenda recited the marker back at him (Vikunja #469). The fire time is
|
||||||
|
// already a column, and the marker is an instruction that was carried out.
|
||||||
|
//
|
||||||
|
// Falls back to the fuller text whenever stripping would leave nothing: an
|
||||||
|
// empty body is a reminder that fires and says nothing, which is worse than a
|
||||||
|
// wordy one.
|
||||||
|
func reminderBody(utterance, text string) string {
|
||||||
|
body := strings.TrimSpace(text)
|
||||||
|
if body == "" {
|
||||||
|
body = strings.TrimSpace(utterance)
|
||||||
|
}
|
||||||
|
stripped := reminderMarker.ReplaceAllString(body, "")
|
||||||
|
for _, re := range reminderTimeWords {
|
||||||
|
stripped = re.ReplaceAllString(stripped, " ")
|
||||||
|
}
|
||||||
|
stripped = strings.TrimSpace(strings.Join(strings.Fields(stripped), " "))
|
||||||
|
stripped = strings.Trim(stripped, " ,;:—-")
|
||||||
|
if stripped == "" {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
return stripped
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestReminderBody(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ utterance, text, want string }{
|
||||||
|
// The row from the QA sitting: the whole utterance was the body.
|
||||||
|
{"напомни завтра в 9 утра выпить таблетки", "завтра в 9 утра выпить таблетки", "выпить таблетки"},
|
||||||
|
{"напомни мне позвонить маме в семь вечера", "позвонить маме в 7 вечера", "позвонить маме"},
|
||||||
|
{"напомни через полчаса проверить бэкап", "через полчаса проверить бэкап", "проверить бэкап"},
|
||||||
|
{"remind me to call mom at 7pm", "to call mom at 7pm", "to call mom"},
|
||||||
|
// Nothing left after stripping ⇒ keep what there was. A reminder that
|
||||||
|
// fires and says nothing is worse than a wordy one.
|
||||||
|
{"напомни завтра", "завтра", "завтра"},
|
||||||
|
{"", "", ""},
|
||||||
|
} {
|
||||||
|
if got := reminderBody(tc.utterance, tc.text); got != tc.want {
|
||||||
|
t.Errorf("reminderBody(%q, %q) = %q, want %q", tc.utterance, tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
"github.com/kami/maven/internal/morph"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Conversation repair (Vikunja #455).
|
||||||
|
//
|
||||||
|
// The classifier has been able to learn from a correction since it was
|
||||||
|
// written — CorrectMisroute appends the utterance as a new example for the
|
||||||
|
// intent he names, append-only, no retrain. Nothing in the daemon could reach
|
||||||
|
// it: the only caller was a test. So the mechanism existed and the behaviour
|
||||||
|
// did not.
|
||||||
|
//
|
||||||
|
// This is the reachable half. He says she got it wrong and names what it
|
||||||
|
// should have been, she redoes the previous utterance under that intent, and
|
||||||
|
// she says out loud that the correction landed — because a correction he
|
||||||
|
// cannot see is indistinguishable from one that was dropped.
|
||||||
|
//
|
||||||
|
// Taken before routing, like the confirm and clarify turns: "нет, это была
|
||||||
|
// заметка" is an answer to the previous turn, not a fresh command, and routing
|
||||||
|
// it as one files the correction itself as a note.
|
||||||
|
|
||||||
|
// routedTurn — the previous utterance and where it went, which is all a
|
||||||
|
// correction needs to point at.
|
||||||
|
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
|
||||||
|
// hear the wrong answer, think, and say so; short enough that "это заметка"
|
||||||
|
// half an hour later is a fresh sentence and not a verdict on something he has
|
||||||
|
// forgotten.
|
||||||
|
const repairWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
// repairMarkers — the ways he says she got it wrong. One of these must appear:
|
||||||
|
// naming an intent alone is an ordinary sentence ("напиши заметку"), and
|
||||||
|
// treating it as a correction would rewrite the last turn every time he used
|
||||||
|
// the word.
|
||||||
|
//
|
||||||
|
// From the lexicon, and staying a list rather than becoming seeds (Vikunja
|
||||||
|
// #528). This runs pre-route, before the turn vector exists, and a correction
|
||||||
|
// redoes the previous request — so a near-miss would act on something he never
|
||||||
|
// 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
|
||||||
|
// compares the words themselves (Vikunja #528).
|
||||||
|
var repairIntents = []struct {
|
||||||
|
word string
|
||||||
|
intent router.Intent
|
||||||
|
say string
|
||||||
|
}{
|
||||||
|
{"заметка", router.IntentNote, "заметка"},
|
||||||
|
{"напоминание", router.IntentReminder, "напоминание"},
|
||||||
|
{"напомнить", router.IntentReminder, "напоминание"},
|
||||||
|
{"факт", router.IntentFact, "факт"},
|
||||||
|
{"вопрос", router.IntentQuery, "вопрос"},
|
||||||
|
{"команда", router.IntentAct, "команда"},
|
||||||
|
{"note", router.IntentNote, "заметка"},
|
||||||
|
{"reminder", router.IntentReminder, "напоминание"},
|
||||||
|
{"fact", router.IntentFact, "факт"},
|
||||||
|
{"question", router.IntentQuery, "вопрос"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRepair reads a spoken correction: a marker saying she was wrong, plus
|
||||||
|
// the intent it should have been.
|
||||||
|
//
|
||||||
|
// The negated half is skipped. "это заметка, а не напоминание" names both
|
||||||
|
// intents, and the one he is correcting TO is the one he did not put "не" in
|
||||||
|
// front of.
|
||||||
|
func parseRepair(utterance string) (router.Intent, string, bool) {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||||
|
if s == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// A leading "нет" is a marker on its own — "нет, это заметка" is the
|
||||||
|
// shortest correction he actually says. Only leading: "нет" in the middle
|
||||||
|
// of a sentence is an ordinary word.
|
||||||
|
marked := strings.HasPrefix(s, "нет") || strings.HasPrefix(s, "no,")
|
||||||
|
for _, m := range repairMarkers {
|
||||||
|
if marked || strings.Contains(s, m) {
|
||||||
|
marked = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !marked {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// Over tokens, not byte offsets. The negation test used to read the string
|
||||||
|
// immediately before a match, which meant it could only see "не" spelled
|
||||||
|
// exactly there; a token list makes the previous word plain to read.
|
||||||
|
toks := repairTokens(s)
|
||||||
|
best, say, at := router.Intent(""), "", -1
|
||||||
|
for i, tok := range toks {
|
||||||
|
if at >= 0 && i > at {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for _, w := range repairIntents {
|
||||||
|
if !morph.SameWord(tok, w.word) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i > 0 && (toks[i-1] == "не" || toks[i-1] == "not") {
|
||||||
|
// The one he is ruling out: "не напоминание, а заметка".
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Leftmost wins: "это заметка, а не напоминание" corrects to the
|
||||||
|
// first.
|
||||||
|
if at < 0 || i < at {
|
||||||
|
best, say, at = w.intent, w.say, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return best, say, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairTokens splits a correction into lowercase word tokens. Punctuation goes,
|
||||||
|
// because "это заметка, а не напоминание" glues a comma to the word the negation
|
||||||
|
// test has to look past.
|
||||||
|
func repairTokens(s string) []string {
|
||||||
|
return strings.FieldsFunc(s, func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordTurn keeps the utterance a correction would point at. Only turns she
|
||||||
|
// acted on: a clarify asked instead of acting, so there is nothing yet to be
|
||||||
|
// wrong about.
|
||||||
|
func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
if h.lastRouted == nil || h.lastRouted.utterance != utterance {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.lastRouted.traceID = traceID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||||
|
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.
|
||||||
|
h.lastRouted = nil
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
last := h.takeLastTurn()
|
||||||
|
if last == nil || h.now().Sub(last.at) > repairWindow {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if last.traceID == 0 {
|
||||||
|
// 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.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
|
||||||
|
// classifier, redo the request under the corrected intent, and say so.
|
||||||
|
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
|
||||||
|
corrected, say, ok := parseRepair(text)
|
||||||
|
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.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// happen. Only the learning half is lost, and he is told so.
|
||||||
|
log.Printf("voice: repair: could not learn %q as %s: %v", last.utterance, corrected, err)
|
||||||
|
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,
|
||||||
|
Stage: 2,
|
||||||
|
Intent: corrected,
|
||||||
|
Slots: h.extractor.Extract(ctx, corrected, last.utterance, h.now()),
|
||||||
|
}
|
||||||
|
// A reminder's Text is what she says at the hour and stays empty when it
|
||||||
|
// was not spoken, so the gap is asked about rather than filled with the
|
||||||
|
// whole sentence. Everywhere else the utterance IS the payload.
|
||||||
|
if dec.Slots.Text == "" && corrected != router.IntentReminder {
|
||||||
|
dec.Slots.Text = last.utterance
|
||||||
|
}
|
||||||
|
return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairLine — what she says before redoing it, so the correction is visible
|
||||||
|
// and not just filed. Feminine, informal, no apology: he corrected a routing
|
||||||
|
// call, he did not complain about her.
|
||||||
|
func repairLine(say string, learned bool) string {
|
||||||
|
if !learned {
|
||||||
|
return "поняла, это " + say + " — переделываю, но запомнить поправку не вышло."
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
want router.Intent
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"нет, ты не поняла, это заметка", router.IntentNote, true},
|
||||||
|
{"нет, это заметка", router.IntentNote, true},
|
||||||
|
{"это не напоминание, а заметка", router.IntentNote, true},
|
||||||
|
{"это заметка, а не напоминание", router.IntentNote, true},
|
||||||
|
{"ты не так поняла — это факт", router.IntentFact, true},
|
||||||
|
{"неправильно поняла, это был вопрос", router.IntentQuery, true},
|
||||||
|
{"you got it wrong, that was a note", router.IntentNote, true},
|
||||||
|
// No marker: an ordinary request that happens to name an intent.
|
||||||
|
{"запиши заметку купить хлеб", "", false},
|
||||||
|
{"напомни мне про заметку", "", false},
|
||||||
|
// A marker with no intent named: nothing to correct to.
|
||||||
|
{"ты не так поняла", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, _, ok := parseRepair(c.utterance)
|
||||||
|
if ok != c.ok || (ok && got != c.want) {
|
||||||
|
t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the
|
||||||
|
// previous utterance is filed under the intent he named, the classifier keeps
|
||||||
|
// it as an example, and he hears that it landed.
|
||||||
|
func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
|
||||||
|
h, st, now := newClarifyHandler(t)
|
||||||
|
emb := router.NewHashEmbedder(256)
|
||||||
|
cls := router.NewClassifier(emb)
|
||||||
|
h.recall.embedder = emb
|
||||||
|
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка")
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("a spoken correction was not handled")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "заметка") {
|
||||||
|
t.Errorf("the correction is not named out loud: %q", reply)
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "не вышло") {
|
||||||
|
t.Errorf("learning failed unexpectedly: %q", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := cls.Examples(router.IntentNote)
|
||||||
|
if len(ex) != 1 || ex[0].Text != "купить хлеб" {
|
||||||
|
t.Fatalf("the classifier did not learn the correction: %+v", ex)
|
||||||
|
}
|
||||||
|
notes, err := st.RecentNotes(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent notes: %v", err)
|
||||||
|
}
|
||||||
|
if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") {
|
||||||
|
t.Fatalf("the request was not redone as a note: %+v", notes)
|
||||||
|
}
|
||||||
|
_ = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
|
||||||
|
h, _, now := newClarifyHandler(t)
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Nothing said yet.
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction with no previous turn was handled")
|
||||||
|
}
|
||||||
|
// Said, but long ago.
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
*now = now.Add(repairWindow + time.Minute)
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction outside the window was handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairIsSpentOnce(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 _, 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There
|
||||||
|
// is nothing to teach and redoing it would file the request a second time.
|
||||||
|
func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||||
|
h.recordTurn("купить хлеб", router.IntentNote)
|
||||||
|
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction to the intent she already used was handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairIntentWordCollisions — the prefix list matched more than the word
|
||||||
|
// (Vikunja #528). "команд" is inside "командировка" and "факт" inside
|
||||||
|
// "фактически", and either one used to name an intent she would redo the turn
|
||||||
|
// under.
|
||||||
|
func TestRepairIntentWordCollisions(t *testing.T) {
|
||||||
|
for _, s := range []string{
|
||||||
|
"нет, это про командировку",
|
||||||
|
"нет, фактически всё нормально",
|
||||||
|
} {
|
||||||
|
if _, _, ok := parseRepair(s); ok {
|
||||||
|
t.Errorf("parseRepair(%q) claimed a correction", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The declined forms the prefixes existed to cover still work, and the
|
||||||
|
// negated half is still skipped.
|
||||||
|
for _, tc := range []struct {
|
||||||
|
utterance string
|
||||||
|
want router.Intent
|
||||||
|
}{
|
||||||
|
{"нет, это заметка", router.IntentNote},
|
||||||
|
{"ты не так поняла, это заметку надо было", router.IntentNote},
|
||||||
|
{"нет, это напоминание, а не заметка", router.IntentReminder},
|
||||||
|
{"нет, не напоминание, а заметка", router.IntentNote},
|
||||||
|
{"нет, это командой было", router.IntentAct},
|
||||||
|
} {
|
||||||
|
got, _, ok := parseRepair(tc.utterance)
|
||||||
|
if !ok || got != tc.want {
|
||||||
|
t.Errorf("parseRepair(%q) = %q, %v; want %q, true", tc.utterance, got, ok, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,13 +22,31 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
|
|||||||
|
|
||||||
// Reply never fails: a clarify, a model error and an unusable generation all
|
// 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.
|
// 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 {
|
if d.Clarify {
|
||||||
return r.stub.Reply(d)
|
// 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,
|
||||||
|
// and it used to be the same words every time (Vikunja #457). Still no
|
||||||
|
// model call — this text has to be right every time, and it is not worth
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
out, err := r.p.PhraseReply(ctx, d)
|
||||||
if err != nil || out == "" {
|
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(ctx, d)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/llm"
|
"github.com/kami/maven/internal/llm"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/voice"
|
"github.com/kami/maven/internal/voice"
|
||||||
)
|
)
|
||||||
@@ -21,7 +22,7 @@ func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) {
|
|||||||
|
|
||||||
func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
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.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||||
if got != "записала, кофе закончился" {
|
if got != "записала, кофе закончился" {
|
||||||
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
t.Errorf("got %q, want %q", got, "записала, кофе закончился")
|
||||||
}
|
}
|
||||||
@@ -29,22 +30,44 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
|||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
||||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
|
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
||||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
// 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)
|
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||||
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
got := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "мгм"})
|
||||||
|
if got == "я всё поняла" {
|
||||||
|
t.Fatal("a clarify must not be phrased by the model")
|
||||||
|
}
|
||||||
|
if want := clarifyMissedFor("мгм"); got != want {
|
||||||
|
t.Errorf("on clarify: got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
// Two different misses do not sound identical.
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertAck — the stub picks between variants now, so two calls to it are not
|
||||||
|
// expected to match. What must hold is that the reply is a line that entry can
|
||||||
|
// 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(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) {
|
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||||
t.Helper()
|
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 {
|
if got != want {
|
||||||
t.Errorf("on %s: got %q, want stub %q", what, got, want)
|
t.Errorf("on %s: got %q, want stub %q", what, got, want)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// mavend/routingtrace.go — persisting the per-turn decision record (V-629).
|
||||||
|
//
|
||||||
|
// internal/decision keeps a 25-turn in-memory ring and persisted nothing, on the
|
||||||
|
// argument that a turn record is read minutes later or never. The owner reversed
|
||||||
|
// that on 06-08-2026, because the routing heads (V-546) cannot be fitted or
|
||||||
|
// calibrated without real utterances and there is no other source of them. The
|
||||||
|
// reversal is written down in docs/plans/21-persisting-the-routing-trace.md.
|
||||||
|
//
|
||||||
|
// The ring stays. It is what /trace reads, it is fast, and it is what a test that
|
||||||
|
// wired no store still gets. This file is the second sink beside it, and it is
|
||||||
|
// nil unless the daemon has a database — no store, no trace, no error.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/decision"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// traceWriter is the seam the handler persists through. store.Store satisfies
|
||||||
|
// it. nil ⇒ the ring is the only sink, which is the pre-V-629 behaviour exactly.
|
||||||
|
type traceWriter interface {
|
||||||
|
WriteRoutingTrace(ctx context.Context, tr store.RoutingTrace) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// traceSink wraps the store, or returns nil when there is none. A typed nil
|
||||||
|
// pointer assigned straight into the interface would be non-nil and would panic
|
||||||
|
// on the first turn, which is the classic shape of this bug.
|
||||||
|
func traceSink(s *store.Store) traceWriter {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trace id rides the context, the same seam querysource.go uses and for the
|
||||||
|
// same reason: handleText answers every reach through one string, and threading
|
||||||
|
// a second value through the whole action dispatch would change a signature the
|
||||||
|
// mic, telegram and the web all share. A caller that wants the id asks for a
|
||||||
|
// sink; the mic path does not, and pays nothing.
|
||||||
|
type traceIDKey struct{}
|
||||||
|
|
||||||
|
type traceIDSink struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *traceIDSink) note(id int64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID is the persisted trace for the turn, or 0 when nothing was persisted.
|
||||||
|
func (s *traceIDSink) ID() int64 {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// withTraceIDSink returns a context that collects the persisted trace id, and
|
||||||
|
// the sink to read after the turn has answered.
|
||||||
|
func withTraceIDSink(ctx context.Context) (context.Context, *traceIDSink) {
|
||||||
|
sink := &traceIDSink{}
|
||||||
|
return context.WithValue(ctx, traceIDKey{}, sink), sink
|
||||||
|
}
|
||||||
|
|
||||||
|
func noteTraceID(ctx context.Context, id int64) {
|
||||||
|
if sink, ok := ctx.Value(traceIDKey{}).(*traceIDSink); ok {
|
||||||
|
sink.note(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneTracesOnStart enforces retention once at wiring time. Pruning on write
|
||||||
|
// alone is not enough: a box that goes quiet for a month keeps every row until
|
||||||
|
// the next sixty-fourth turn, and "kept for fourteen days" would then be true
|
||||||
|
// only of a box in daily use. Called for its effect and never blocks a start.
|
||||||
|
func pruneTracesOnStart(s *store.Store, now time.Time) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := s.PruneRoutingTraces(ctx, now.Add(-store.RoutingTraceRetention)); err != nil {
|
||||||
|
log.Printf("routing trace: prune on start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistDecision writes one finished record. It takes the same *decision.Record
|
||||||
|
// the ring takes, so the two sinks cannot disagree about what the turn did.
|
||||||
|
//
|
||||||
|
// Errors are logged and swallowed. A trace is diagnostic and training data, and
|
||||||
|
// a failed insert must never change what the owner hears.
|
||||||
|
func (h *reactiveHandler) persistDecision(turnCtx context.Context, rec *decision.Record, src turnSource) {
|
||||||
|
ctx := turnCtx
|
||||||
|
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Detached from the turn's context, and bounded on its own. Two reasons, and
|
||||||
|
// the first is the one that matters: the turn is over by the time this runs,
|
||||||
|
// so a caller that hung up or timed out would cancel the insert, and the turn
|
||||||
|
// he abandoned halfway is exactly the one worth having. The second is that a
|
||||||
|
// write must not hold the reply, so it gets a second and no more.
|
||||||
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
claims, err := json.Marshal(rec.Claims)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("routing trace: marshal claims: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr := store.RoutingTrace{
|
||||||
|
Ts: rec.Ts,
|
||||||
|
Utterance: rec.Utterance,
|
||||||
|
Source: string(src),
|
||||||
|
Winner: rec.Winner,
|
||||||
|
Intent: wonIntent(rec),
|
||||||
|
ClaimedBeforeHead: claimedBeforeHead(rec),
|
||||||
|
EncoderID: h.encoderID,
|
||||||
|
Outcome: wonAt(rec, decision.StageAction),
|
||||||
|
Claims: claims,
|
||||||
|
}
|
||||||
|
id, err := h.traces.WriteRoutingTrace(ctx, tr)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("routing trace: write: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The id goes back to whoever asked for it, so /chat can offer a correction
|
||||||
|
// on the turn it is already showing (V-630). Noted on the ORIGINAL context,
|
||||||
|
// not the detached one above: the sink belongs to the caller's turn.
|
||||||
|
noteTraceID(turnCtx, id)
|
||||||
|
// And the spoken path, which has no reply to hang a badge on: a correction
|
||||||
|
// said out loud points at the previous turn, so it needs that turn's row
|
||||||
|
// (V-636, repair.go).
|
||||||
|
h.stampLastTurn(rec.Utterance, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wonIntent — what the winning claimant made the turn. Read from the claim
|
||||||
|
// rather than from the route, because a pre-route resolver wins without routing
|
||||||
|
// and its intent is the honest answer to "what was this turn".
|
||||||
|
func wonIntent(rec *decision.Record) string {
|
||||||
|
for _, c := range rec.Claims {
|
||||||
|
if c.Outcome == decision.Won && c.Intent != "" {
|
||||||
|
return c.Intent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// wonAt — the claimant that won at one stage. The action stage is what actually
|
||||||
|
// produced the reply, which is a different question from what was routed: a
|
||||||
|
// route that reached a gap and a route that ran are not the same turn.
|
||||||
|
func wonAt(rec *decision.Record, stage string) string {
|
||||||
|
for _, c := range rec.Claims {
|
||||||
|
if c.Stage == stage && c.Outcome == decision.Won {
|
||||||
|
return c.Claimant
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimedBeforeHead — a pre-route resolver or a stage-0 grammar answered, so the
|
||||||
|
// turn teaches nothing about the classifier. Those are a large share of real
|
||||||
|
// traffic, and fitting a head on them would fit it to the grammars rather than
|
||||||
|
// to him. Recorded per turn rather than filtered on write, because which share
|
||||||
|
// that is happens to be the number V-632 needs to know.
|
||||||
|
func claimedBeforeHead(rec *decision.Record) bool {
|
||||||
|
stage, _, ok := strings.Cut(rec.Winner, ":")
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return stage == decision.StagePreRoute || stage == decision.StageZero
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/decision"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A real turn leaves a persisted trace, not only a ring entry. This is the whole
|
||||||
|
// of V-629: without one there is nothing to fit the routing heads from.
|
||||||
|
func TestTurnPersistsTrace(t *testing.T) {
|
||||||
|
ring := decision.NewRing()
|
||||||
|
h := traceHandler(t, ring)
|
||||||
|
h.traces = traceSink(h.dataStore)
|
||||||
|
h.encoderID = "hash-1024"
|
||||||
|
|
||||||
|
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||||
|
t.Fatal("turn produced no reply")
|
||||||
|
}
|
||||||
|
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("persisted %d traces, want 1", len(got))
|
||||||
|
}
|
||||||
|
tr := got[0]
|
||||||
|
if tr.Utterance != "сколько сейчас времени" {
|
||||||
|
t.Errorf("utterance %q", tr.Utterance)
|
||||||
|
}
|
||||||
|
if tr.Source != string(sourceText) {
|
||||||
|
t.Errorf("source %q, want %q", tr.Source, sourceText)
|
||||||
|
}
|
||||||
|
// A stage-0 clock rule answers this one, so the turn teaches the classifier
|
||||||
|
// nothing and the trace has to say so.
|
||||||
|
if !tr.ClaimedBeforeHead {
|
||||||
|
t.Errorf("claimed_before_head false on winner %q", tr.Winner)
|
||||||
|
}
|
||||||
|
if tr.EncoderID != "hash-1024" {
|
||||||
|
t.Errorf("encoder_id %q", tr.EncoderID)
|
||||||
|
}
|
||||||
|
if len(tr.Claims) < 3 {
|
||||||
|
t.Errorf("claims %s: the losers and the never-asked are the point", tr.Claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No store, no trace, and no panic. A typed nil pointer in the interface would
|
||||||
|
// pass the nil check and die on the first turn.
|
||||||
|
func TestNoStoreNoTrace(t *testing.T) {
|
||||||
|
ring := decision.NewRing()
|
||||||
|
h := traceHandler(t, ring)
|
||||||
|
h.traces = traceSink(nil)
|
||||||
|
|
||||||
|
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||||
|
t.Fatal("turn produced no reply")
|
||||||
|
}
|
||||||
|
if len(ring.Recent(5)) != 1 {
|
||||||
|
t.Error("the ring is still the first sink and must still hold the turn")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty utterance writes nothing. A blank row carries no label and no
|
||||||
|
// diagnosis, and it is his words the retention bound exists for.
|
||||||
|
func TestEmptyUtteranceIsNotPersisted(t *testing.T) {
|
||||||
|
h := traceHandler(t, decision.NewRing())
|
||||||
|
h.traces = traceSink(h.dataStore)
|
||||||
|
h.persistDecision(context.Background(), &decision.Record{Utterance: " "}, sourceText)
|
||||||
|
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Fatalf("persisted %d traces for a blank utterance", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ traceWriter = (*store.Store)(nil)
|
||||||
|
|
||||||
|
// The trace id rides back to the caller, which is what makes a correction one
|
||||||
|
// gesture: /chat already has the id, so saying "that was wrong" costs a button
|
||||||
|
// and no lookup (V-630).
|
||||||
|
func TestTurnHandsBackItsTraceID(t *testing.T) {
|
||||||
|
h := traceHandler(t, decision.NewRing())
|
||||||
|
h.traces = traceSink(h.dataStore)
|
||||||
|
|
||||||
|
ctx, sink := withTraceIDSink(context.Background())
|
||||||
|
if reply := h.handleText(ctx, "web", "сколько сейчас времени"); reply == "" {
|
||||||
|
t.Fatal("turn produced no reply")
|
||||||
|
}
|
||||||
|
id := sink.ID()
|
||||||
|
if id == 0 {
|
||||||
|
t.Fatal("no trace id came back, so /chat can offer no correction")
|
||||||
|
}
|
||||||
|
// And it names the turn that just ran, so the correction lands on the right
|
||||||
|
// utterance.
|
||||||
|
if err := h.dataStore.CorrectTurn(context.Background(), id, "query", h.now()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
labels, err := h.dataStore.RoutingLabels(context.Background(), 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(labels) != 1 || labels[0].Utterance != "сколько сейчас времени" {
|
||||||
|
t.Fatalf("labels %+v, want the turn that just ran", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A turn nobody asked the id of costs nothing, which is the mic path.
|
||||||
|
func TestTurnWithNoSinkStillPersists(t *testing.T) {
|
||||||
|
h := traceHandler(t, decision.NewRing())
|
||||||
|
h.traces = traceSink(h.dataStore)
|
||||||
|
|
||||||
|
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||||
|
t.Fatal("turn produced no reply")
|
||||||
|
}
|
||||||
|
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("persisted %d traces, want 1", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
-46
@@ -4,6 +4,10 @@
|
|||||||
// plural agreement, clock/date rendering, and the "do I actually know this
|
// plural agreement, clock/date rendering, and the "do I actually know this
|
||||||
// place/day" guards that pick an honest reply over a confidently wrong one.
|
// place/day" guards that pick an honest reply over a confidently wrong one.
|
||||||
// Extend this file rather than voice.go for anything in that shape.
|
// Extend this file rather than voice.go for anything in that shape.
|
||||||
|
//
|
||||||
|
// Count agreement is not here. It is say.CountWord, because there were four
|
||||||
|
// copies of the same three-way rule and two of the sites that needed it were
|
||||||
|
// spelling one form out (Vikunja #521).
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,17 +15,16 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/say"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ruWeekdays = []string{
|
// Weekday and month names are a closed class — the language has seven and
|
||||||
"воскресенье", "понедельник", "вторник", "среда",
|
// twelve — so they live complete in internal/lexicon, where internal/ttsnorm
|
||||||
"четверг", "пятница", "суббота",
|
// reads the same twelve month names instead of keeping a second copy
|
||||||
}
|
// (Vikunja #525).
|
||||||
|
|
||||||
var ruMonths = []string{
|
|
||||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
|
||||||
"июля", "августа", "сентября", "октября", "ноября", "декабря",
|
|
||||||
}
|
|
||||||
|
|
||||||
// onlyLocalTimeReply — the honest answer when the user asks the time somewhere
|
// onlyLocalTimeReply — the honest answer when the user asks the time somewhere
|
||||||
// other than here. She only keeps one clock, and saying so is better than
|
// other than here. She only keeps one clock, and saying so is better than
|
||||||
@@ -33,13 +36,15 @@ var ruMonths = []string{
|
|||||||
const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу."
|
const onlyLocalTimeReply = "я знаю только местное время, про другие города пока не скажу."
|
||||||
|
|
||||||
// notPlaceAfterV — words that follow "в" without naming a place, so
|
// notPlaceAfterV — words that follow "в" without naming a place, so
|
||||||
// mentionsUnknownPlace does not mistake them for a city.
|
// mentionsUnknownPlace does not mistake them for a city. Closed set, kept
|
||||||
var notPlaceAfterV = map[string]bool{
|
// complete in internal/lexicon.
|
||||||
"данный": true, "данную": true, "этот": true, "эту": true,
|
var notPlaceAfterV = func() map[string]bool {
|
||||||
"котором": true, "какое": true, "какой": true, "который": true,
|
m := map[string]bool{}
|
||||||
"общем": true, "точности": true, "курсе": true, "сутках": true,
|
for _, w := range lexicon.NotPlaceAfterV() {
|
||||||
"часах": true, "минутах": true, "секундах": true, "неделе": true,
|
m[w] = true
|
||||||
}
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase
|
// mentionsUnknownPlace reports whether the question has a "в <слово>" phrase
|
||||||
// that looks like a place we do not know ("который час в киеве"). Used only to
|
// that looks like a place we do not know ("который час в киеве"). Used only to
|
||||||
@@ -69,22 +74,26 @@ func mentionsUnknownPlace(u string) bool {
|
|||||||
// date for a day she did not understand.
|
// date for a day she did not understand.
|
||||||
const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу."
|
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
|
// mentionsUnknownDay reports whether the question names a day the calendar
|
||||||
// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to
|
// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to
|
||||||
// pick an honest reply over a confidently wrong one.
|
// pick an honest reply over a confidently wrong one.
|
||||||
//
|
//
|
||||||
// Only called after ParseCalendarDate has already failed, so "завтра" and the
|
// Only called after ParseCalendarDate has already failed, so "завтра" and the
|
||||||
// other words it does know never reach here.
|
// 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 {
|
func mentionsUnknownDay(u string) bool {
|
||||||
for _, w := range dayWords {
|
for _, tok := range quietTokens(u) {
|
||||||
if strings.Contains(u, w) {
|
if tok == "через" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, ok := router.WeekdayIndex(tok); ok {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,11 +103,11 @@ func mentionsUnknownDay(u string) bool {
|
|||||||
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
// ruClock renders the clock part of the time reply: "15 часов 4 минуты".
|
||||||
func ruClock(t time.Time) string {
|
func ruClock(t time.Time) string {
|
||||||
h, m := t.Hour(), t.Minute()
|
h, m := t.Hour(), t.Minute()
|
||||||
hourWord := ruPlural(h, "час", "часа", "часов")
|
hourWord := say.CountWord(h, "час", "часа", "часов")
|
||||||
if m == 0 {
|
if m == 0 {
|
||||||
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
return fmt.Sprintf("%d %s ровно", h, hourWord)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, ruPlural(m, "минута", "минуты", "минут"))
|
return fmt.Sprintf("%d %s %d %s", h, hourWord, m, say.CountWord(m, "минута", "минуты", "минут"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
// dayPrefix names the day relative to now ("завтра", "вчера", …) so the date
|
||||||
@@ -118,22 +127,6 @@ func dayPrefix(now, day time.Time) string {
|
|||||||
return "это"
|
return "это"
|
||||||
}
|
}
|
||||||
|
|
||||||
func ruPlural(n int, one, two, many string) string {
|
|
||||||
n = n % 100
|
|
||||||
if n > 10 && n < 20 {
|
|
||||||
return many
|
|
||||||
}
|
|
||||||
n = n % 10
|
|
||||||
switch n {
|
|
||||||
case 1:
|
|
||||||
return one
|
|
||||||
case 2, 3, 4:
|
|
||||||
return two
|
|
||||||
default:
|
|
||||||
return many
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
// hasDurationWords checks whether u is asking about elapsed/remaining time
|
||||||
// rather than the current clock — guards replySystem from replying "сейчас
|
// rather than the current clock — guards replySystem from replying "сейчас
|
||||||
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
// X часов" to "сколько времени прошло". Mirrors the stage0.go build filter.
|
||||||
@@ -161,6 +154,11 @@ func hasDurationWords(u string) bool {
|
|||||||
// Used by the query handler when answering "когда я это сделал?"-style questions.
|
// Used by the query handler when answering "когда я это сделал?"-style questions.
|
||||||
func formatTime(t time.Time) string {
|
func formatTime(t time.Time) string {
|
||||||
now := time.Now()
|
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)) {
|
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
|
||||||
return "только что"
|
return "только что"
|
||||||
}
|
}
|
||||||
@@ -169,12 +167,16 @@ func formatTime(t time.Time) string {
|
|||||||
case diff < 10*time.Minute:
|
case diff < 10*time.Minute:
|
||||||
return "несколько минут назад"
|
return "несколько минут назад"
|
||||||
case diff < 60*time.Minute:
|
case diff < 60*time.Minute:
|
||||||
return fmt.Sprintf("%d минут назад", int(diff.Minutes()))
|
n := int(diff.Minutes())
|
||||||
|
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "минуту", "минуты", "минут"))
|
||||||
case diff < 2*time.Hour:
|
case diff < 2*time.Hour:
|
||||||
return "час назад"
|
return "час назад"
|
||||||
case diff < 24*time.Hour:
|
case diff < 24*time.Hour:
|
||||||
return fmt.Sprintf("%d часа назад", int(diff.Hours()))
|
n := int(diff.Hours())
|
||||||
|
return fmt.Sprintf("%d %s назад", n, say.CountWord(n, "час", "часа", "часов"))
|
||||||
default:
|
default:
|
||||||
return t.Format("2 января 15:04")
|
// Not t.Format("2 января …"): Go reads that as a literal, so every
|
||||||
|
// fact older than a day used to read as January (Vikunja #507).
|
||||||
|
return fmt.Sprintf("%d %s %s", t.Day(), lexicon.MonthGenitive(int(t.Month())), t.Format("15:04"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// "когда я это сделал?" answers off a fact's Ts, which the store hands back as
|
||||||
|
// UTC, and the branch that names a wall clock printed it in whatever zone it
|
||||||
|
// arrived in (V-614). The instant here is built three hours off this machine's
|
||||||
|
// zone, so the assertion holds under TZ=UTC as well.
|
||||||
|
func TestFormatTimeReadsHisClock(t *testing.T) {
|
||||||
|
_, off := time.Now().Zone()
|
||||||
|
away := time.FixedZone("away", off+3*60*60)
|
||||||
|
stored := time.Now().Add(-72 * time.Hour).In(away)
|
||||||
|
|
||||||
|
got := formatTime(stored)
|
||||||
|
if want := stored.Local().Format("15:04"); !strings.Contains(got, want) {
|
||||||
|
t.Errorf("formatTime = %q, want the hour on his clock (%s)", got, want)
|
||||||
|
}
|
||||||
|
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
|
||||||
|
t.Errorf("formatTime = %q reads the zone the fact arrived in (%s)", got, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
|
||||||
|
// weekday half of this guard was a list of stems matched with strings.Contains,
|
||||||
|
// so "среди", "средство" and "средний" all read as Wednesday and the question
|
||||||
|
// was answered with onlyNearDaysReply instead of a date.
|
||||||
|
//
|
||||||
|
// The other half of the fix is coverage: a stem list stops at the forms whoever
|
||||||
|
// wrote it thought of, and "воскресеньях" was not one of them.
|
||||||
|
func TestMentionsUnknownDayReadsWordsNotStems(t *testing.T) {
|
||||||
|
for _, u := range []string{
|
||||||
|
"какое число в понедельник",
|
||||||
|
"какое число в среду",
|
||||||
|
"какое число в среде",
|
||||||
|
"что там по воскресеньям",
|
||||||
|
"what is the date on friday",
|
||||||
|
"какое число через неделю",
|
||||||
|
} {
|
||||||
|
if !mentionsUnknownDay(u) {
|
||||||
|
t.Errorf("mentionsUnknownDay(%q) = false, want true", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range []string{
|
||||||
|
"какое число в среднем",
|
||||||
|
"сколько это в среднем",
|
||||||
|
"какое сегодня средство",
|
||||||
|
"какое число",
|
||||||
|
} {
|
||||||
|
if mentionsUnknownDay(u) {
|
||||||
|
t.Errorf("mentionsUnknownDay(%q) = true; it names no day", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// mavend/seed.go — the backdated-fact seam (Vikunja #518).
|
||||||
|
//
|
||||||
|
// The pattern detector needs four events for one action+object, spread by at
|
||||||
|
// least pattern.MinIntervalDays, before it proposes a routine. Nothing could
|
||||||
|
// produce that against a running daemon in one sitting: the only writer is a
|
||||||
|
// fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
|
||||||
|
// same missing step and had been stopped there since they were filed.
|
||||||
|
//
|
||||||
|
// This is the write path that unblocks them, and it is deliberately the narrow
|
||||||
|
// one. It takes a fact, not an event, so pattern.Extract runs for real and a
|
||||||
|
// key the extractor ignores seeds nothing. It runs detectAndPropose, so what a
|
||||||
|
// seed proves is the daemon's own wiring rather than the detector in isolation
|
||||||
|
// — which is what an eval-lab fixture would have proved, and is not what those
|
||||||
|
// four tasks doubt.
|
||||||
|
//
|
||||||
|
// It is off unless mavend was started with -allow-seed, and AuthStepUp in the
|
||||||
|
// authority table besides. See ipc.SeedEventReq and auth.Requirement.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/pattern"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errSeedDisabled — what a caller gets on an ordinary box. Named rather than
|
||||||
|
// inline so the mavweb route can tell "not allowed here" apart from "the seed
|
||||||
|
// ran and the extractor declined", which look the same to a reader otherwise.
|
||||||
|
var errSeedDisabled = errors.New("mavend: seeding is off (start with -allow-seed)")
|
||||||
|
|
||||||
|
// seedSource — every seeded fact carries this, and no other writer uses it.
|
||||||
|
// The point is that seeded data stays identifiable forever: a fact that came
|
||||||
|
// from a QA sitting must never be mistaken for something he said, either by a
|
||||||
|
// person reading /history or by the wipe in V-494 when it lands.
|
||||||
|
const seedSource = "seed:qa"
|
||||||
|
|
||||||
|
// seedStoreIfAllowed returns st only when -allow-seed was passed, and logs the
|
||||||
|
// fact loudly when it does. A box that can rewrite its own past should say so
|
||||||
|
// in its boot log, so nobody reads a seeded routine months later as evidence of
|
||||||
|
// something he actually did.
|
||||||
|
func seedStoreIfAllowed(st *store.Store) *store.Store {
|
||||||
|
if !allowSeedOnStart {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
log.Printf("seed: -allow-seed is ON — backdated fact writes are permitted under source %q (Vikunja #518)", seedSource)
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedEvent writes the fact at the caller's timestamp, extracts an event from
|
||||||
|
// it, and runs the same detect-and-propose step the voice path runs.
|
||||||
|
//
|
||||||
|
// Best-effort is NOT the shape here, unlike detectPattern: a seed that half
|
||||||
|
// worked is a QA result nobody can trust, so every step reports its own
|
||||||
|
// failure. Extraction declining is not a failure — it is the extractor's
|
||||||
|
// documented answer for a value outside its lexicon, and Extracted says so.
|
||||||
|
func (d *daemonAPI) SeedEvent(ctx context.Context, req ipc.SeedEventReq) (ipc.SeedEventResp, error) {
|
||||||
|
if d.seedStore == nil {
|
||||||
|
return ipc.SeedEventResp{}, errSeedDisabled
|
||||||
|
}
|
||||||
|
if req.Key == "" || req.Value == "" {
|
||||||
|
return ipc.SeedEventResp{}, errors.New("mavend: seed needs a key and a value")
|
||||||
|
}
|
||||||
|
if req.Ts.IsZero() {
|
||||||
|
return ipc.SeedEventResp{}, errors.New("mavend: seed needs an explicit timestamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Subject, unlike the voice path: a seeded key must not queue a Nexus
|
||||||
|
// resolution. QA data has no business reaching the ecosystem.
|
||||||
|
factID, err := d.seedStore.WriteFact(ctx, req.Ts, store.KindSelf, req.Key, req.Value, seedSource, 1.0, sql.NullInt64{})
|
||||||
|
if err != nil {
|
||||||
|
return ipc.SeedEventResp{}, fmt.Errorf("seed write fact: %w", err)
|
||||||
|
}
|
||||||
|
resp := ipc.SeedEventResp{FactID: factID}
|
||||||
|
|
||||||
|
ev := pattern.Extract(factID, req.Key, req.Value, req.Ts)
|
||||||
|
if ev == nil {
|
||||||
|
// The fact is written and stays written. Saying so matters: a caller
|
||||||
|
// that assumed a seed always produces an event would otherwise read
|
||||||
|
// four silent successes and conclude the detector is broken.
|
||||||
|
log.Printf("seed: %s=%s wrote fact %d, no event (value outside the action lexicon)", req.Key, req.Value, factID)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
resp.Extracted, resp.Action, resp.Object = true, ev.Action, ev.Object
|
||||||
|
|
||||||
|
eventID, err := d.seedStore.CreateEvent(ctx, factID, ev.Action, ev.Object, req.Ts)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("seed create event: %w", err)
|
||||||
|
}
|
||||||
|
resp.EventID = eventID
|
||||||
|
|
||||||
|
r, routineID, err := detectAndPropose(ctx, d.seedStore, ev.Action, ev.Object, req.Ts)
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("seed detect: %w", err)
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return resp, nil // too few events yet, too irregular, or already decided
|
||||||
|
}
|
||||||
|
resp.Proposed, resp.RoutineID, resp.IntervalDays = true, routineID, r.IntervalDays
|
||||||
|
log.Printf("seed: proposed routine %d — %s/%s every %.1f days", routineID, r.Action, r.Object, r.IntervalDays)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Off is the default and it must mean "nothing to write with", not "permission
|
||||||
|
// to refuse later". A daemonAPI with no seedStore writes no fact at all.
|
||||||
|
func TestSeedRefusedWithoutTheFlag(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
|
||||||
|
_, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||||
|
Key: "cat_water_fountain", Value: "заправил", Ts: time.Now(),
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("seed succeeded with no seedStore")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "-allow-seed") {
|
||||||
|
t.Errorf("error does not name the flag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of the task: four seeds spread past the detector's floor
|
||||||
|
// produce a proposal against the real daemon path, which is what nobody could
|
||||||
|
// do before (Vikunja #518). Three seeds must NOT propose — MinEvents is four,
|
||||||
|
// and a test that only checked the happy end would pass on an off-by-one.
|
||||||
|
func TestSeedFourEventsProposesARoutine(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
var last ipc.SeedEventResp
|
||||||
|
// Oldest first, three hours apart — past MinIntervalDays (two hours).
|
||||||
|
for i := 3; i >= 0; i-- {
|
||||||
|
var err error
|
||||||
|
last, err = d.SeedEvent(ctx, ipc.SeedEventReq{
|
||||||
|
Key: "cat_water_fountain",
|
||||||
|
Value: "заправил",
|
||||||
|
Ts: now.Add(-time.Duration(i) * 3 * time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed %d: %v", i, err)
|
||||||
|
}
|
||||||
|
if !last.Extracted {
|
||||||
|
t.Fatalf("seed %d: no event extracted from a lexicon verb", i)
|
||||||
|
}
|
||||||
|
if i > 0 && last.Proposed {
|
||||||
|
t.Fatalf("proposed after only %d events, MinEvents is 4", 4-i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !last.Proposed {
|
||||||
|
t.Fatal("four spaced events did not propose a routine")
|
||||||
|
}
|
||||||
|
if last.Action != "refill" || last.Object != "cat_water_fountain" {
|
||||||
|
t.Errorf("wrong pair: %s/%s", last.Action, last.Object)
|
||||||
|
}
|
||||||
|
if last.IntervalDays < 0.1 {
|
||||||
|
t.Errorf("interval %v — the detector saw a burst, not a rhythm", last.IntervalDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The proposal is readable through the same list the /routines page uses,
|
||||||
|
// which is the wiring an eval-lab fixture would not have proved.
|
||||||
|
proposed, err := d.seedStore.ListProposedRoutines(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(proposed) != 1 {
|
||||||
|
t.Fatalf("expected 1 proposed routine, got %d", len(proposed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A value outside the action lexicon writes the fact and says it seeded
|
||||||
|
// nothing. Silence here would read as four working seeds and a broken
|
||||||
|
// detector.
|
||||||
|
func TestSeedReportsWhenExtractionDeclines(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||||
|
resp, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||||
|
Key: "mood", Value: "ok", Ts: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
if resp.FactID == 0 {
|
||||||
|
t.Error("fact was not written")
|
||||||
|
}
|
||||||
|
if resp.Extracted || resp.EventID != 0 || resp.Proposed {
|
||||||
|
t.Errorf("claimed an event for a non-action value: %+v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A seed with no timestamp is refused rather than defaulting to now: the only
|
||||||
|
// reason this seam exists is the caller choosing when, so a zero Ts is a bug in
|
||||||
|
// the caller and must not silently write a fact at the wrong time.
|
||||||
|
func TestSeedRequiresAnExplicitTimestamp(t *testing.T) {
|
||||||
|
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}, seedStore: newTestStore(t)}
|
||||||
|
if _, err := d.SeedEvent(context.Background(), ipc.SeedEventReq{
|
||||||
|
Key: "cat_water_fountain", Value: "заправил",
|
||||||
|
}); err == nil {
|
||||||
|
t.Fatal("seed accepted a zero timestamp")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A question about her — "что ты умеешь", "кто ты" — used to have no answer at
|
||||||
|
// all (Vikunja #555). It reached the personal boundary, which claimed it as his
|
||||||
|
// and said "не знаю — не нашла у тебя такой записи", because the boundary knows
|
||||||
|
// two sides and this is neither: her own description is not his data and it is
|
||||||
|
// not the world's either. Letting it past the boundary is no better, because
|
||||||
|
// then SearXNG answers about somebody else's assistant.
|
||||||
|
//
|
||||||
|
// The description does NOT live in the note store. Notes are his. A note about
|
||||||
|
// her sitting in his index would come back for "что я записал", would be fed to
|
||||||
|
// the digestion worker as something he said, and would be recalled by vector
|
||||||
|
// proximity for questions that are not about her at all. It is her own text, so
|
||||||
|
// it lives here, in one place, and it is the only copy.
|
||||||
|
//
|
||||||
|
// This source sits ABOVE the boundary, because a question about her never had
|
||||||
|
// an answer below it.
|
||||||
|
|
||||||
|
// selfDescription — what she is and what this box actually does. Frozen text,
|
||||||
|
// and the one rule for editing it: name only what is really wired. Anything
|
||||||
|
// that depends on config — the house, the LAN, the feeds, telegram, search — is
|
||||||
|
// named as depending on what he allowed, never claimed outright. Inventing a
|
||||||
|
// capability here is the same defect as inventing a fact, and it is worse than
|
||||||
|
// silence because he would plan around it.
|
||||||
|
//
|
||||||
|
// Written in her own voice, feminine, addressing him informally, because it is
|
||||||
|
// handed to the phraser as the evidence for the answer and the phraser will
|
||||||
|
// keep the words it is given.
|
||||||
|
const selfDescription = `Я Мэйвен, твоя помощница. Я живу на твоём сервере, ` +
|
||||||
|
`и наружу уходит только поисковый запрос — больше ничего.
|
||||||
|
|
||||||
|
Что я делаю сама: запоминаю, что ты мне говоришь, и потом отвечаю на вопросы ` +
|
||||||
|
`об этом; веду заметки; ставлю напоминания; читаю твой календарь и задачи; ` +
|
||||||
|
`отвечаю на вопросы о мире — сначала поиском, а если сети нет, то по ` +
|
||||||
|
`офлайновой энциклопедии.
|
||||||
|
|
||||||
|
Что зависит от того, что ты мне разрешил: дом, локальная сеть, ленты, ` +
|
||||||
|
`список покупок, погода, телеграм. Если что-то из этого не настроено, я ` +
|
||||||
|
`скажу об этом прямо, а не буду выдумывать ответ.
|
||||||
|
|
||||||
|
Говорю по-русски и по-английски.`
|
||||||
|
|
||||||
|
// selfSeeds — the questions this source claims. Scoring data like every other
|
||||||
|
// topic set: editing one moves the recogniser and has to be re-measured against
|
||||||
|
// TestONNXTopics.
|
||||||
|
//
|
||||||
|
// All of them are about HER — what she is, what she can do, who made her. The
|
||||||
|
// neighbouring set is topicAttend, "что требует внимания", which asks about the
|
||||||
|
// state of his things; the two share almost nothing but the second person.
|
||||||
|
var selfSeeds = []string{
|
||||||
|
"что ты умеешь",
|
||||||
|
"что ты можешь делать",
|
||||||
|
"кто ты такая",
|
||||||
|
"расскажи о себе",
|
||||||
|
"какие у тебя возможности",
|
||||||
|
// Added after measuring: it won self by 0.0002, under the margin, and the
|
||||||
|
// floor does not carry it — "способна" names no verb the floor matches.
|
||||||
|
"на что ты способна",
|
||||||
|
"чем ты можешь помочь",
|
||||||
|
"what can you do",
|
||||||
|
"who are you",
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfFloor — the offline floor, for a handler with no embedder or a turn whose
|
||||||
|
// vector never got computed. Narrow on purpose, like every other floor here: it
|
||||||
|
// answers only when the seeds cannot, and a broad guess made blind is worse
|
||||||
|
// than a narrow one.
|
||||||
|
//
|
||||||
|
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the
|
||||||
|
// Russian patterns spell the boundary out.
|
||||||
|
var selfPatterns = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])ты\s+(умеешь|можешь)([^\p{L}\p{N}]|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])кто\s+ты([^\p{L}\p{N}]|$)`),
|
||||||
|
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])(расскажи|поведай)\s+о\s+себе([^\p{L}\p{N}]|$)`),
|
||||||
|
regexp.MustCompile(`(?i)\bwhat\s+can\s+you\s+do\b`),
|
||||||
|
regexp.MustCompile(`(?i)\bwho\s+are\s+you\b`),
|
||||||
|
}
|
||||||
|
|
||||||
|
func selfFloor(utterance string) bool {
|
||||||
|
for _, re := range selfPatterns {
|
||||||
|
if re.MatchString(utterance) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// querySelf answers a question about her from selfDescription. The description
|
||||||
|
// goes through the phraser as evidence so the answer is shaped to what he
|
||||||
|
// asked — "что ты умеешь" and "кто ты" want different halves of it — and falls
|
||||||
|
// back to the text itself, which is already readable, if the model is down.
|
||||||
|
func (h *reactiveHandler) querySelf(ctx context.Context, t *queryTurn) (string, bool) {
|
||||||
|
if !h.turnIsAbout(ctx, t, topicSelf, selfFloor) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
var reply string
|
||||||
|
if h.phraser != nil {
|
||||||
|
var err error
|
||||||
|
reply, err = h.phraser.PhraseSelf(ctx, t.dec.Utterance, selfDescription)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: phrase self: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": selfDescription})
|
||||||
|
}
|
||||||
|
return reply, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSelfFloorClaimsAQuestionAboutHerAndNothingElse — the offline floor, which
|
||||||
|
// is what answers with no embedder. Narrow on purpose, so the rows that must
|
||||||
|
// NOT match are the point.
|
||||||
|
func TestSelfFloorClaimsAQuestionAboutHerAndNothingElse(t *testing.T) {
|
||||||
|
claimed := []string{
|
||||||
|
"что ты умеешь",
|
||||||
|
"что ты можешь",
|
||||||
|
"а что ты умеешь?",
|
||||||
|
"кто ты",
|
||||||
|
"кто ты такая?",
|
||||||
|
"расскажи о себе",
|
||||||
|
"what can you do",
|
||||||
|
"who are you",
|
||||||
|
}
|
||||||
|
for _, u := range claimed {
|
||||||
|
if !selfFloor(u) {
|
||||||
|
t.Errorf("%q is a question about her and the floor missed it", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
declined := []string{
|
||||||
|
"что у меня сегодня",
|
||||||
|
"расскажи про байкал",
|
||||||
|
"кто изобрёл телефон",
|
||||||
|
"что требует внимания",
|
||||||
|
"запиши что я пил воду",
|
||||||
|
// The floor spells its own word boundaries out, because Go's \b never
|
||||||
|
// fires next to a Cyrillic letter. Without that these would match.
|
||||||
|
"кто тыкал в розетку",
|
||||||
|
"расскажи о себестоимости",
|
||||||
|
}
|
||||||
|
for _, u := range declined {
|
||||||
|
if selfFloor(u) {
|
||||||
|
t.Errorf("%q is not about her and the floor claimed it", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSelfSourceAnswersFromTheDescription — with no embedder the source falls
|
||||||
|
// to the floor, and the answer has to be the description rather than silence.
|
||||||
|
func TestSelfSourceAnswersFromTheDescription(t *testing.T) {
|
||||||
|
h := personalHandler()
|
||||||
|
reply, claimed := h.querySelf(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "что ты умеешь"},
|
||||||
|
})
|
||||||
|
if !claimed {
|
||||||
|
t.Fatal("a question about her must be claimed above the boundary")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "напоминания") {
|
||||||
|
t.Errorf("the answer must come from the description: %q", reply)
|
||||||
|
}
|
||||||
|
if _, claimed := h.querySelf(context.Background(), &queryTurn{
|
||||||
|
dec: router.Decision{Utterance: "почему небо синее"},
|
||||||
|
}); claimed {
|
||||||
|
t.Error("a world question must pass this source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSelfDescriptionHoldsThePersona — it is her own text and she reads it out,
|
||||||
|
// so the same rules the phrasing eval enforces apply to it. Feminine
|
||||||
|
// self-reference, informal address, no pet names.
|
||||||
|
func TestSelfDescriptionHoldsThePersona(t *testing.T) {
|
||||||
|
lower := strings.ToLower(selfDescription)
|
||||||
|
for _, bad := range []string{"я рад ", "я готов ", "вы ", "ваш", "милый", "дорогой"} {
|
||||||
|
if strings.Contains(lower, bad) {
|
||||||
|
t.Errorf("the description breaks the persona on %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"тво", "ты"} {
|
||||||
|
if !strings.Contains(lower, want) {
|
||||||
|
t.Errorf("the description must address him directly, missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSelfDescriptionClaimsNothingUnconditionally — the constraint that makes
|
||||||
|
// this text safe to read out. Every capability that depends on config has to be
|
||||||
|
// named as depending on it, and inventing one here is the same defect as
|
||||||
|
// inventing a fact.
|
||||||
|
func TestSelfDescriptionClaimsNothingUnconditionally(t *testing.T) {
|
||||||
|
conditional := selfDescription[strings.Index(selfDescription, "Что зависит"):]
|
||||||
|
for _, cap := range []string{"дом", "локальная сеть", "ленты", "список покупок", "погода", "телеграм"} {
|
||||||
|
if !strings.Contains(conditional, cap) {
|
||||||
|
t.Errorf("%q is configured, not wired — it must sit under the conditional half", cap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -347,6 +347,55 @@ func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) {
|
|||||||
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
|
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scriptedPhraser answers the chat path from the same script the router reads.
|
||||||
|
//
|
||||||
|
// It exists because actionChat calls h.phraser.PhraseChat, and the production
|
||||||
|
// implementation posts raw HTTP to /v1/chat/completions rather than going
|
||||||
|
// through the llm client scriptedLLM stands in for. So until this, no scenario
|
||||||
|
// could script what she SAYS on a chat turn: the simulator wired phraser.NewStub()
|
||||||
|
// and every chat reply came back as a pick from fallbacks_ru_v1.json, four
|
||||||
|
// variants deep, which varied between two runs of one scenario (V-542 item 4).
|
||||||
|
//
|
||||||
|
// Everything except PhraseChat is the Stub's, by embedding. A nudge and a
|
||||||
|
// reminder are phrased by the tick loop, which has its own phraser and its own
|
||||||
|
// assertions; this seam is only about the conversation.
|
||||||
|
type scriptedPhraser struct {
|
||||||
|
*phraser.Stub
|
||||||
|
entries []scriptEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// PhraseChat returns the scripted reply for the utterance, or an error when the
|
||||||
|
// scenario scripted none. The error rather than a fallback is deliberate and
|
||||||
|
// matches scriptedLLM: actionChat logs it and falls back to ChatFallback(), so a
|
||||||
|
// scenario that never meant to assert on a chat reply behaves exactly as it did
|
||||||
|
// before, and one that DID means to is told its script has a hole.
|
||||||
|
func (p *scriptedPhraser) PhraseChat(_ context.Context, utterance string, _ []dialogue.Turn) (string, error) {
|
||||||
|
for _, e := range p.entries {
|
||||||
|
if e.Reply == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if e.Match != "" && !strings.Contains(strings.ToLower(utterance), strings.ToLower(e.Match)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return chatReplyText(e.Reply), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("simulator: no scripted chat reply for %q", truncateRunes(utterance, 60))
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatReplyText reads a scripted reply in either shape the phrasing contract
|
||||||
|
// allows: the {"response","mood"} object the model emits, or plain text.
|
||||||
|
// LLMPhraser does this parse itself, so a scenario writes one thing and both
|
||||||
|
// paths understand it.
|
||||||
|
func chatReplyText(reply string) string {
|
||||||
|
var out struct {
|
||||||
|
Response string `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(reply), &out); err == nil && out.Response != "" {
|
||||||
|
return out.Response
|
||||||
|
}
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Building the world
|
// Building the world
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -428,20 +477,22 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
|||||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
||||||
|
|
||||||
w.handler = &reactiveHandler{
|
w.handler = &reactiveHandler{
|
||||||
stt: simTranscriber{},
|
stt: simTranscriber{},
|
||||||
tts: simSynthesizer{},
|
tts: simSynthesizer{},
|
||||||
router: rtr,
|
router: rtr,
|
||||||
embedder: emb,
|
recall: recallWiring{
|
||||||
|
embedder: emb,
|
||||||
|
memStore: st.VectorMemory(),
|
||||||
|
minScore: config.DefaultQueryMinScore,
|
||||||
|
minMargin: config.DefaultQueryMinMargin,
|
||||||
|
},
|
||||||
api: api,
|
api: api,
|
||||||
matcher: matcher,
|
matcher: matcher,
|
||||||
tools: tool.NewExecutor(api, 5*time.Second),
|
tools: tool.NewExecutor(api, 5*time.Second),
|
||||||
phraser: phraser.NewStub(),
|
phraser: &scriptedPhraser{Stub: phraser.NewStub(), entries: sc.Script},
|
||||||
replier: newLLMReplier(scripted, nil),
|
replier: newLLMReplier(scripted, nil),
|
||||||
now: clock.Now,
|
now: clock.Now,
|
||||||
memStore: st.VectorMemory(),
|
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
queryMinScore: config.DefaultQueryMinScore,
|
|
||||||
queryMinMargin: config.DefaultQueryMinMargin,
|
|
||||||
timeParser: router.StubDateTimeParser{},
|
timeParser: router.StubDateTimeParser{},
|
||||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||||
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||||
@@ -1043,3 +1094,26 @@ func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
|
|||||||
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
|
t.Errorf("the clock moved to %s on a refused step, it must stay at 09:00", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSimulatorRoutesWithTheDeployedSeeds — the scenarios must replay against
|
||||||
|
// the classifier the deploy runs, not an empty one.
|
||||||
|
//
|
||||||
|
// They did not. The seed path was relative to the working directory, which is
|
||||||
|
// cmd/mavend under `go test`, so every file failed to open and the whole
|
||||||
|
// simulator scored three green scenarios with zero examples loaded (Vikunja
|
||||||
|
// #465). The count is asserted rather than logged, because a silent zero is
|
||||||
|
// exactly the failure that hid here for as long as it did.
|
||||||
|
func TestSimulatorRoutesWithTheDeployedSeeds(t *testing.T) {
|
||||||
|
cls := router.NewClassifier(router.NewHashEmbedder(1024))
|
||||||
|
seedClassifier(cls)
|
||||||
|
total := 0
|
||||||
|
for _, intent := range cls.Intents() {
|
||||||
|
total += len(cls.Examples(intent))
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
t.Fatalf("no seed examples loaded from %s — the simulator would route on nothing", seedPath())
|
||||||
|
}
|
||||||
|
if len(cls.Intents()) != 7 {
|
||||||
|
t.Fatalf("seeded %d intents, want all 7", len(cls.Intents()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user