Compare commits
133 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 229890abd7 | |||
| 0db9ca084c | |||
| 96d97e8964 | |||
| 02f6e8ad4a | |||
| 999a5ad562 | |||
| 2ea39a3d41 | |||
| 8fb6f2154d | |||
| c938148619 | |||
| 2512d686a1 | |||
| f44abcc526 | |||
| a99932b427 | |||
| 6d5801bb1f | |||
| 8aba4845bf | |||
| 2ec92ee8bf | |||
| 7c77a378c1 | |||
| 672eabc134 | |||
| a1a2fa3704 | |||
| 22a4978459 | |||
| 1456336652 | |||
| b975716759 | |||
| 4b1edb0617 | |||
| 944e553669 | |||
| cc32c2c4ab | |||
| a1e97c94ac | |||
| c7f59e48f4 | |||
| 4666057066 | |||
| 7138086c3f | |||
| 83e168f326 | |||
| a4abcdefa3 | |||
| 68a3c85186 | |||
| 88c086482e | |||
| feabf9f350 | |||
| 50c6637c1b | |||
| ee9d55ca95 | |||
| a886217223 | |||
| de9884e063 | |||
| bbefda66e2 | |||
| a37c4138a1 | |||
| d6f391430f | |||
| 68b2aa9137 | |||
| f8fa0d1b44 | |||
| 9a333b23d7 | |||
| 663b5c47b9 | |||
| 45c521e1a6 | |||
| e34669a52e | |||
| d434f83c2c | |||
| c310115fd2 | |||
| 3024f76e5f | |||
| 6bc71553ab | |||
| ed1730431c | |||
| 01e80fce4a | |||
| e69f1bd0cf | |||
| f55bedee2e | |||
| e470435cf1 | |||
| 00f9239ef9 | |||
| 3513e508b7 | |||
| c15c2b7bd2 | |||
| b6eaa704a2 | |||
| 2597a7b34a | |||
| 7203cd56fd | |||
| ab3e818bb9 | |||
| b5500a5be8 | |||
| 9095ac847d | |||
| 4b5f6adbae | |||
| ecb8ba72eb | |||
| 4fdecf9a25 | |||
| 2bbd8edbf6 | |||
| beb093aebb | |||
| b1b326018f | |||
| 08889cad88 | |||
| a4630b9314 | |||
| 39d44bb384 | |||
| 65ee0f9c61 | |||
| 76938e206d | |||
| 0b3d81ecbf | |||
| 4be6852b94 | |||
| f7b76c572f | |||
| 05ddc5c92e | |||
| b55e68f98d | |||
| beaa24754c | |||
| aed8cac439 | |||
| af4eeceb6a | |||
| 7b507dec94 | |||
| 2c0334c4fe | |||
| 92cbdbfdd3 | |||
| e78b2d8992 | |||
| 9d58922462 | |||
| b5ac48c126 | |||
| 69d0f5ee78 | |||
| 661b5c1099 | |||
| ff70637a0d | |||
| 06c1cf247e | |||
| 400653810e | |||
| b3936348f5 | |||
| c61b0b3968 | |||
| 0a5211b038 | |||
| 38be702188 | |||
| d42372e996 | |||
| 45231ba69e | |||
| 42c7b8b927 | |||
| e5a1db995d | |||
| d32eae8aac | |||
| 63b645b405 | |||
| 0e82cb442f | |||
| d94ed2e630 | |||
| c8f74c39d6 | |||
| 44b8793e2f | |||
| a4b4733767 | |||
| 8f168ab811 | |||
| eb129c2fad | |||
| 0d5bd0a9f0 | |||
| 4d97280d74 | |||
| e5ec4abe04 | |||
| 7688dfde66 | |||
| e1f84a3474 | |||
| 7852aad60f | |||
| 034d4b4359 | |||
| 799cf5587d | |||
| c1b781fac0 | |||
| 7b2b9d479a | |||
| 92de4ae496 | |||
| a0293bac85 | |||
| c1d9a4547b | |||
| 6499f6365e | |||
| 97e1a44c1a | |||
| 1b3af05d0a | |||
| e7ecce2859 | |||
| 1f8e9f21ce | |||
| e7537d032e | |||
| 2b3e34c7e8 | |||
| dde556a3d3 | |||
| b86172a98d | |||
| 22edc3cdfb |
@@ -70,3 +70,5 @@ coverage.out
|
|||||||
|
|
||||||
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
|
# root .env — MAVEN_AMBIENT_TOKEN and friends, same class as deploy/telegram.env
|
||||||
.env
|
.env
|
||||||
|
# silero-vad, downloaded (see AGENTS.md)
|
||||||
|
/models/vad/
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ model: the code puts `query: ` in front of a question and `passage: ` in front
|
|||||||
of a stored note, which is how e5 was trained. The quantized file is the one
|
of a stored note, which is how e5 was trained. The quantized file is the one
|
||||||
that is downloaded, deployed and measured.
|
that is downloaded, deployed and measured.
|
||||||
|
|
||||||
|
## Voice activity model for mavwaked
|
||||||
|
|
||||||
|
`mavwaked` decides an utterance has started with silero-vad when `-vad-model`
|
||||||
|
points at it, and with an energy threshold when it does not. The model is 2.3MB
|
||||||
|
and is not committed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p models/vad
|
||||||
|
curl -sL -o models/vad/silero_vad.onnx \
|
||||||
|
https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx
|
||||||
|
```
|
||||||
|
|
||||||
|
It needs the same `libonnxruntime.so` the embedder needs, passed as `-onnx-lib`
|
||||||
|
or read from `MAVEN_ONNX_LIB`. The measurement is
|
||||||
|
`docs/evals/2026-08-09-silero-vad.md`, and the tests skip without the file.
|
||||||
|
|
||||||
**Also need ONNX Runtime** (`libonnxruntime.so`):
|
**Also need ONNX Runtime** (`libonnxruntime.so`):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -47,27 +47,63 @@ free — `worldGap` in `cmd/mavend/worldmodel.go`, which the owner hears instead
|
|||||||
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.
|
||||||
|
|
||||||
|
**Speech-to-text moved on 2026-08-09** (V-486). `sttSeam` in `cmd/mavend/voicewire.go`
|
||||||
|
builds an `stt.Pair` beside `modelSeam`, preferring CrisperWhisper 2.0 turbo on workpc
|
||||||
|
with mavsttd as the floor. It takes only the silent half of the rule. A worse
|
||||||
|
transcript is still a turn, so `stt.Pair` has no `TranscribeRemote`. The fallback is
|
||||||
|
never spoken. CW2 turbo scores **10.4% WER in Russian against 27.5%** for the `ggml-small.bin`
|
||||||
|
mavsttd loads, over 200 Golos clips
|
||||||
|
(`docs/evals/2026-08-09-crisperwhisper2-russian-wer.md`). It runs in Intended mode, not
|
||||||
|
Verbatim, though that corpus cannot separate the two.
|
||||||
|
**whisper.cpp cannot load CW2 at all.** It reads its language count off the vocabulary
|
||||||
|
size, and CW2's 51897 tokens shift seven special token ids. So it is not a second
|
||||||
|
endpoint on mavgpud. It is its own transformers service on port 8081
|
||||||
|
(`deploy/cw2/serve.py`), which Maven reaches directly. `stt.HTTPTranscriber`
|
||||||
|
posts raw PCM to it with a bearer token, because audio is the most sensitive thing that
|
||||||
|
crosses this seam. The switch is `workstation.stt` in
|
||||||
|
`deploy/mavend.json`, and deleting the block sends every utterance to mavsttd.
|
||||||
|
**mavgpud runs that service as a second child.** That is not an optimisation. CW2 is a
|
||||||
|
ROCm process on the same card, so it registers on the KFD like any contender. Under its own
|
||||||
|
systemd unit it made mavgpud evict llama-server every few seconds. That took the
|
||||||
|
gemma-4-12b arm down for eight minutes on 2026-08-09 before anyone noticed. The card needs
|
||||||
|
one owner. Any GPU service added beside this daemon has the same defect, so add it to
|
||||||
|
`cmd/mavgpud` and not to systemd. CW2 is on the yield clock and not the idle one. At 1.6GB
|
||||||
|
it denies the card to nobody, and unloading it would only send the next voice turn to the
|
||||||
|
homesrv floor.
|
||||||
|
Text-to-speech has not moved and piper on homesrv is still the only synthesizer.
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
|
|
||||||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
||||||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
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/`)
|
||||||
|
|
||||||
@@ -82,13 +118,37 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
|||||||
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`, not this. |
|
| `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.
|
||||||
|
|
||||||
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
|
**`docker-compose.yml` runs five: `mavend`, `mavsttd`, `mavttsd`, `mavweb`, `mavpoll`.**
|
||||||
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
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
|
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.
|
daemon there listens to nobody. They belong on a client machine where the owner is standing.
|
||||||
|
|
||||||
@@ -195,11 +255,31 @@ re-run it, start a **second** llama-server on a fixed host port — the resident
|
|||||||
`--port 0` inside the container and no host process can reach it.
|
`--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 the model mavgpud holds, which is better than the resident
|
||||||
p50 329ms** — better than the resident model and about 2.5× faster (`docs/evals/2026-08-02-workstation-gemma4-12b.md`,
|
model and about 2.5× faster. gemma-4-12b scored **84.4% full / 93.5% intent-only at p50 329ms**
|
||||||
Vikunja #485). The workstation is never assumed up, so both sets of numbers are live. Judge a
|
(`docs/evals/2026-08-02-workstation-gemma4-12b.md`, Vikunja #485). The workstation is never
|
||||||
|
assumed up, so both sets of numbers are live. Judge a
|
||||||
routing change against the classifier and the resident model, since those are what always answer.
|
routing change against the classifier and the resident model, since those are what always answer.
|
||||||
|
|
||||||
|
**The workstation runs gemma-4-E4B since 2026-08-09** (owner's call), and it is a
|
||||||
|
step down measured the same day (`docs/evals/2026-08-09-e4b-vs-12b-routing.md`).
|
||||||
|
Against a same-session 12B control it scores **83.3% full / 89.6% intent-only,
|
||||||
|
destination 19/33 against 23/33, at p50 294ms against 344ms**. So it costs four
|
||||||
|
destination cases and buys 50ms. Read destination as the finding: it names nothing
|
||||||
|
where the 12B names `recall` or `calendar`, which is safe but walks the whole chain.
|
||||||
|
It also has no MTP and cannot be given any here. The only `gemma4-assistant`
|
||||||
|
draft on disk is trained against the 12B's hidden states.
|
||||||
|
**Phrasing was the unmeasured half and it is measured now**
|
||||||
|
(`docs/evals/2026-08-09-e4b-phrasing.md`). E4B scores nudges 15/15 and the
|
||||||
|
36-case talk fixture **29/36 at p50 516ms**, against the resident model's 25/36
|
||||||
|
at p50 2.97s. Persona is clean: `lang`, `feminine` and `address` are all 36/36,
|
||||||
|
where the resident model loses three on `address`. Every failure is `ontopic`
|
||||||
|
and none is a parse error. The 2026-08-05 temperature sweep put this fixture's
|
||||||
|
ceiling at 30/36, because two reply cases fail at every temperature (V-537), and
|
||||||
|
both are in E4B's failure list. So the swap costs nothing here. One defect no
|
||||||
|
check catches: in chat E4B claims "Я записала несколько идей!" when nothing was
|
||||||
|
stored, which is a wrong claim about state.
|
||||||
|
|
||||||
**The intended third engine is not a generative model** (owner's call, 05-08-2026, V-546,
|
**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
|
`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
|
classification, and the 118M multilingual-e5-small is already resident. Three heads on one
|
||||||
@@ -211,6 +291,99 @@ was a hardcode. **Fine-tune a copy of the weights.** The resident embedder backs
|
|||||||
recall. Training it in place couples routing accuracy to recall@1, with nothing in the
|
recall. Training it in place couples routing accuracy to recall@1, with nothing in the
|
||||||
suite to name the trade.
|
suite to name the trade.
|
||||||
|
|
||||||
|
**Two of those heads are trained as of 08-08-2026, and they are not the three
|
||||||
|
above** (V-661, `docs/evals/2026-08-08-routing-heads-two-head.md`). Intent and
|
||||||
|
destination share one masked mean pool. Destination scores a mean **80.8%** over
|
||||||
|
three seeds, best **29/33 (87.9%)**. The classifier cascade scores 12/33 and the
|
||||||
|
cascade with gemma-4-12b scores 24/33, so a 118M encoder beats the 12B teacher it
|
||||||
|
was distilled from. Read the best run as one seed and not a headline, because one
|
||||||
|
case is 3 points on a fixture this small.
|
||||||
|
Recall is 15/15 and world is 5/5. Intent is 93.6% mean over three seeds. That is
|
||||||
|
**not** comparable to the 76.0% and 84.4% those two arms scored: a softmax has no
|
||||||
|
clarify class, so the head's fixture is the 88 cases carrying an intent.
|
||||||
|
|
||||||
|
**A fourth head asks instead of guessing, same day** (V-661,
|
||||||
|
`docs/evals/2026-08-08-clarify-head-four-head.md`). Clarify is not a value
|
||||||
|
of intent, so a softmax cannot emit it. It is a second question over the
|
||||||
|
same pooled vector: can Maven act on this at all. That is why the head's
|
||||||
|
fixture was 88 cases and not 96. Over three seeds it catches **7.0 of the 8
|
||||||
|
`want_clarify` cases and produces 2.3 false clarifies of 88**. The cascade
|
||||||
|
today misses 1 and produces 2, so this is parity with no rules in front of
|
||||||
|
it. Accuracy is the wrong number here and a head that never asks scores
|
||||||
|
91.7%. Confidence is the other half. Max softmax over the intent head reads
|
||||||
|
**0.851 where it is right against 0.604 where it is wrong**, ranking right
|
||||||
|
above wrong in 83.4% of pairs. `Confidence: 1.0` was a hardcode, and this
|
||||||
|
replaces it with a signal. The two are not the same signal: one says which
|
||||||
|
intent is unclear, the other says the utterance carries too little to act
|
||||||
|
on. **The fourth head is not free the way the third was.** Intent,
|
||||||
|
destination and slot F1 each move down one to four points, inside the seed
|
||||||
|
spread. `поужинал` is a false clarify on every seed, which is the same
|
||||||
|
defect `thinSingleToken` was narrowed for on 2026-08-01.
|
||||||
|
|
||||||
|
The corpus for it is generated, because every existing row is answerable by
|
||||||
|
construction. **The router-prompt agreement filter cannot work here**, since
|
||||||
|
`routeGrammar` has no clarify value and a generated line always agrees with
|
||||||
|
itself. A gemma judge replaces it. The first judge called 24 of 40
|
||||||
|
answerable rows underspecified, because it judged against a generic
|
||||||
|
assistant rather than against Maven's contract.
|
||||||
|
|
||||||
|
**Mood is cut, not deferred.** The enum describes her own reply state, not the
|
||||||
|
speaker's emotion, and no dataset maps onto it.
|
||||||
|
|
||||||
|
**A third head landed the same day** (`docs/evals/2026-08-08-slot-head-three-head.md`).
|
||||||
|
BIO slot tags had no Maven-domain corpus, which was true of found corpora and
|
||||||
|
false of made ones. `label_slots.py` distils spans out of gemma-4-12b under a
|
||||||
|
GBNF closed over Maven's own five slots. A span survives only when it is a
|
||||||
|
literal substring of the utterance, so the agreement filter costs no second
|
||||||
|
call. 2178 spans over 1702 rows, 37 dropped, nothing unparsed. Three heads score
|
||||||
|
intent **92.8%**, destination **82.8%** and slot span F1 **72.4%** over three
|
||||||
|
seeds. The slot head is free: both other numbers move less than their own seed
|
||||||
|
spread. Epoch selection reads the intent dev slice alone. Slot F1 is still
|
||||||
|
climbing when it stops, which costs about 4 points.
|
||||||
|
|
||||||
|
The MASSIVE warm-start of step 2 is worth nothing here. Stock e5-small ties it on
|
||||||
|
intent and leads by a third of a case on destination. Nothing argues for keeping
|
||||||
|
that step.
|
||||||
|
|
||||||
|
The floor was a corpus defect and it is fixed. The first 120 floor rows carried
|
||||||
|
one sentence shape, so the head named a destination where the fixture says walk
|
||||||
|
the chain. Rotating six shapes took the floor 3/7 to 6/7 and destination 75.8% to
|
||||||
|
80.8%. What is left is calendar at 3/6 on every seed, which training cannot move:
|
||||||
|
the possessive agenda rules claim those cases at stage 0 and name nothing, so no
|
||||||
|
label reaches the head. That is the same trade V-660 flagged and it wants the
|
||||||
|
owner's call.
|
||||||
|
|
||||||
|
**The heads run in Go and route every turn, since 08-08-2026** (V-664,
|
||||||
|
`docs/evals/2026-08-08-routing-heads-in-go.md`). This section used to say
|
||||||
|
nothing of it ran. `RouterHeads` in `internal/router/heads.go` loads
|
||||||
|
`router_heads.onnx` and reads intent, destination and clarify off one forward
|
||||||
|
pass. It is stage 0b: after the grammars, **before** the resident model, and the
|
||||||
|
classifier is still behind both. Through the cascade it scores intent **96.9%**
|
||||||
|
and destination **75.8%** at p50 27.9ms. That beats the gemma-4-12b cascade,
|
||||||
|
84.4% and 72.7%, at a twelfth of its 329ms. The workstation stays the better
|
||||||
|
phraser and is no longer the better router.
|
||||||
|
|
||||||
|
Three rules around it. The **clarify head decides first**, before the intent
|
||||||
|
threshold. It answers a different question. A thin utterance scores low
|
||||||
|
intent by construction, so gating it cost 6 of 8 ambiguous cases. The
|
||||||
|
**destination head is read on `IntentQuery` only**, since no other intent
|
||||||
|
reaches `queryWalk`. And `headsThreshold` is 0.6, the measured knee: every value
|
||||||
|
to 0.85 drops right answers and keeps the same two wrong ones.
|
||||||
|
|
||||||
|
`voice.embedder.heads_path` is the whole switch. Empty, missing or unloadable
|
||||||
|
means the heads are nil and the cascade is byte-for-byte what shipped before
|
||||||
|
them. **It must never be pointed at `model_path`.** The resident e5-small must
|
||||||
|
not be replaced by the fine-tuned copy. Recall depends on that file scoring
|
||||||
|
what it scored.
|
||||||
|
|
||||||
|
**The hand-written tokenizer read every long word backwards** until this task
|
||||||
|
(`encodeWord`, `onnxembedder.go`). It cost recall@1 7.4 points and recall@3 11.1.
|
||||||
|
Nothing caught it because seeds and queries were mangled the same way, so cosine
|
||||||
|
survived. The heads found it. They are trained through transformers and read
|
||||||
|
through this. The embedder id now carries a tokenizer revision
|
||||||
|
(`@384/tok2`), so fixing the tokenizer triggers `ReembedAll` the way swapping the
|
||||||
|
model file does. Bump `tokenizerRev` on any change to what it emits.
|
||||||
|
|
||||||
`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
|
||||||
@@ -285,11 +458,162 @@ 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
|
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`),
|
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`.
|
read over `ipc.TurnDecisions` and rendered as the second table on `/trace`.
|
||||||
Nothing persists: a turn record is read minutes later or never, and his words do
|
**It also persists, since 06-08-2026, and that reverses what this section used to
|
||||||
not belong in a table that outlives the diagnosis. Adding a rung to the ladder
|
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
|
in `runTurn` means adding its name to `preRouteLadder` in
|
||||||
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
|
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
|
||||||
|
|
||||||
|
**A route now says where the answer lives, not only that the turn is a question**
|
||||||
|
(V-655, 07-08-2026). `query` was a shrug. The cascade sorted an utterance into one of
|
||||||
|
seven intents, with stage 0, the resident model and the classifier behind it. Then
|
||||||
|
`IntentQuery` handed the turn to `querySources` in the daemon. That is twenty-two branches
|
||||||
|
deciding by seed similarity in a fixed order. It has no fixture and no accuracy
|
||||||
|
number, no model arm and no floor. `Decision.Source` (`internal/router/source.go`) is
|
||||||
|
the second half of the route. Twelve destinations, not twenty-two. The three recall
|
||||||
|
passes plus `fact-by-key` are one destination from outside. So are search, Kiwix and
|
||||||
|
the URL reader.
|
||||||
|
|
||||||
|
**`SourceUnknown` is a real value and it is the floor.** Nothing named a destination,
|
||||||
|
so the daemon walks the whole chain. That is byte-for-byte what shipped before the
|
||||||
|
field existed. The classifier arm names nothing, so a box whose model is down routes
|
||||||
|
queries exactly as it did.
|
||||||
|
|
||||||
|
`queryWalk` in `cmd/mavend/actions_query.go` takes sources **out** and moves none.
|
||||||
|
That is the safety argument and it is not negotiable. The table's order is
|
||||||
|
load-bearing. Every comment on it argues a reason between two sources, and above all
|
||||||
|
it carries "the owner's data first, then the world". Naming `SourceWorld` does not
|
||||||
|
send the turn outside on its own. His notes and his facts still run first, because
|
||||||
|
they look rather than guess.
|
||||||
|
|
||||||
|
**The personal boundary is the one exception and it is deliberate.** It guesses,
|
||||||
|
so naming `SourceWorld` drops it. That is what stops it answering "кто такой
|
||||||
|
Линус Торвальдс?" with "не нашла у тебя такой записи", which it did on
|
||||||
|
2026-08-07. `TestNamingRecallKeepsTheBoundary` pins the other half: naming
|
||||||
|
`SourceRecall` keeps the boundary in front of the world.
|
||||||
|
|
||||||
|
**Only a stage 0 grammar may drop it** (owner's call, 09-08-2026, V-666). The
|
||||||
|
question of who is allowed to was open until then. Three deciders name a
|
||||||
|
destination and two of them infer it: the routing heads and the resident model.
|
||||||
|
An inferred `SourceWorld` on a question about him would reach SearXNG, and that
|
||||||
|
widens what is asked rather than costing a local answer. So `Decision.SourceAnchored`
|
||||||
|
carries the provenance. It is a field and not `Stage == 0`. Stage 0 also means
|
||||||
|
confidence 1.0 and an anchored claim band, and one of those could stop implying
|
||||||
|
the others. `queryWalk` reads it for the source marked `boundary: true` and for
|
||||||
|
no other. So every other guesser still comes off the turn, whoever named the
|
||||||
|
destination. `TestOnlyAGrammarMayDropTheBoundary` pins both directions.
|
||||||
|
`definitionQueryPattern` claims "кто такой X", so the 2026-08-07 case is still
|
||||||
|
anchored and still answered.
|
||||||
|
|
||||||
|
What comes out is only the sources that **guess**. Those decide a turn is theirs by
|
||||||
|
cosine against frozen seeds, then answer whatever they claimed. They hold no table
|
||||||
|
that could come back empty. Weather is the pure case and has no local data at
|
||||||
|
all. It was measured on the box on 2026-08-07
|
||||||
|
(`docs/evals/2026-08-07-week-of-usage.md` section 4). It answered both "что такое
|
||||||
|
TCP?" and "сколько будет 17 на 23?" with "для какого города?". The feed answered "какой у меня любимый язык?" with kernel headlines.
|
||||||
|
The personal boundary answered "кто такой Линус Торвальдс?" with "не нашла у тебя
|
||||||
|
такой записи". A source that guesses is marked `guesses: true` in the table. One that
|
||||||
|
looks is not, and it is always asked.
|
||||||
|
|
||||||
|
Stage 0 fills the destination where a rule already knows it. `WorldQueryGrammars()`
|
||||||
|
(`internal/router/worldquery.go`) claims "что такое X" and "сколько будет 17 на 23".
|
||||||
|
It is wired after the agenda rules and **before** the feed and list rules.
|
||||||
|
"что такое лента" is a definition question, and the feed rule would take it on the
|
||||||
|
noun alone.
|
||||||
|
`calendar-query` and `event-time-query` name the calendar. The possessive agenda rules
|
||||||
|
deliberately do not. "что у меня в списке покупок" matches `agenda-query`, and naming
|
||||||
|
the calendar there would take the list source off the turn.
|
||||||
|
|
||||||
|
Fixture unchanged at **69/91 classifier+ONNX**, measured both sides. That is the
|
||||||
|
expected result, because it scores intent and no case here changes intent.
|
||||||
|
|
||||||
|
**The destination has its own fixture and its own number as of 08-08-2026**
|
||||||
|
(V-659, `docs/evals/2026-08-08-destination-fixture.md`). This section used to say
|
||||||
|
it had neither. `want_source` on `eval.Case` is a pointer, because the destination
|
||||||
|
has three states and a bare string has two. Absent is every intent but query,
|
||||||
|
which never reaches `queryWalk`. Present and empty is the `SourceUnknown`
|
||||||
|
contract: name nothing and walk the chain. Present and named is a destination the
|
||||||
|
route must produce. Thirty-three of ninety-six cases carry one.
|
||||||
|
|
||||||
|
A destination miss does **not** fail the case. It lands in `Outcome.SourceReason`
|
||||||
|
and never in `Reasons`, so `Accuracy` and `IntentAccuracy` mean what they meant
|
||||||
|
and `SourceAccuracy` is a second number over the labelled cases only. Intent and
|
||||||
|
destination are two decisions, and one number hides which one moved. A route that
|
||||||
|
lost its intent scores no destination hit, or a clarify would satisfy an empty
|
||||||
|
label for free.
|
||||||
|
|
||||||
|
Measured classifier+ONNX: intent **73/96 (76.0%)**, destination **12/33 (36.4%)**.
|
||||||
|
The split is the finding. World is 5/5, because a stage 0 rule names it. The
|
||||||
|
`SourceUnknown` floor is 5/7. Calendar is 2/6, because the possessive agenda
|
||||||
|
rules deliberately do not name it. And **recall is 0/15, because nothing
|
||||||
|
anywhere names it**. Those turns are still answered, since the chain walks
|
||||||
|
recall early. Recall is the number the fourth head has to move.
|
||||||
|
|
||||||
|
Seven cases assert the floor and five of them are homelab operations. They
|
||||||
|
cluster because `SourceRecall`, `SourceNetwork` and `SourceAttention` overlap on
|
||||||
|
every question about the box. The other two are `ru-query-005` and
|
||||||
|
`ru-query-014`. No query source reads the reminder store, and a deadline could
|
||||||
|
sit in tasks, the calendar or Praxis. `mavpoll` writes its netdata and uptime-kuma
|
||||||
|
observations into the fact store recall reads. That is a finding about the enum,
|
||||||
|
not a gap in the labelling. The owner confirmed all seven floor labels on
|
||||||
|
08-08-2026, so they are a decision rather than an agent's guess.
|
||||||
|
|
||||||
|
`baselineGrammars` in `eval_test.go` mirrors `buildRouter` and had drifted:
|
||||||
|
`WorldQueryGrammars` was wired into the daemon by V-655 and not into the mirror,
|
||||||
|
so the fixture scored a grammar set nobody runs. Fixed by V-659, worth 3 points of
|
||||||
|
destination and nothing else. Check that function when adding a grammar.
|
||||||
|
|
||||||
|
**The model arm landed the same day** (V-660,
|
||||||
|
`docs/evals/2026-08-08-destination-model-arm.md`). `routeGrammar` carries a
|
||||||
|
`source` rule closed over `router.Sources` plus the empty floor, so the model
|
||||||
|
cannot emit a destination that does not exist. The prompt lists the twelve in
|
||||||
|
Russian and says `""` is a normal answer to give often. `LLMRouter.Route` reads it
|
||||||
|
back through `ValidSource` and on `IntentQuery` alone. Against gemma-4-12b on the
|
||||||
|
workstation the cascade scores destination **24/33 (72.7%)** with intent unmoved
|
||||||
|
at 84.4%, and **recall goes 0/15 to 14/15**. The resident Qwen3-1.7B is
|
||||||
|
unmeasured, because it binds `--port 0` inside the container.
|
||||||
|
|
||||||
|
**Stage 0 now costs four destination points.** It did not before. The four cases
|
||||||
|
the cascade loses and the model alone wins are all calendar. The possessive
|
||||||
|
agenda rules claim them first and name nothing on purpose. That caution was free
|
||||||
|
while nothing downstream could name anything either. It is not free now, and the
|
||||||
|
fix is the owner's call rather than a quiet edit.
|
||||||
|
|
||||||
|
The last arm is V-546. Intent, mood and BIO slot tags were already three heads on
|
||||||
|
one forward pass of the resident e5-small. Destination is a fourth head on the
|
||||||
|
same pass, and 72.7% from a 12B teacher is the label source for training it.
|
||||||
|
|
||||||
## LLM output contract
|
## LLM output contract
|
||||||
|
|
||||||
All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when
|
All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when
|
||||||
@@ -361,11 +685,28 @@ world questions, so she needs to read external sources. What replaces it:
|
|||||||
`wikipedia_ru_all_maxi_2026-02` verbatim** through `kiwix.book_ru`. The RU→EN rewriter
|
`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
|
is the workaround for an English book and is skipped there. Kiwix catalog names come
|
||||||
from the filename, not the `<name>` field.
|
from the filename, not the `<name>` field.
|
||||||
|
**That verbatim path sent the whole sentence to a keyword engine until 09-08-2026**
|
||||||
|
(V-668, `docs/evals/2026-08-09-kiwix-topic-retrieval.md`). Kiwix ranks by keyword
|
||||||
|
overlap, so the question words outrank the one word naming the article. "что такое TCP"
|
||||||
|
returned "Перехват TCP-соединения". "кто написал Войну и мир" returned an episode of
|
||||||
|
Doctor Who. `kiwix.Topic` drops the narrative request, the interrogative and a verb
|
||||||
|
behind one. `kiwix.TitlePath` tries the exact article first, since a ZIM is addressable
|
||||||
|
by title and a wrong title is a 404. Five of eight questions reach the right article
|
||||||
|
where they did not, one was already right, and nothing regressed. The title needs its
|
||||||
|
leading capital, so `TitleCandidates` tries the spoken form and then the capitalized
|
||||||
|
one. **"столица Франции" is answered by a title redirect to Париж**, which is the case
|
||||||
|
the 2026-08-05 measurement named as unreachable by any lexical signal. Both apply on the
|
||||||
|
verbatim path alone. The rewriter already reduces a question, and reducing twice takes
|
||||||
|
the topic off its input.
|
||||||
`Response.Empty()` is the whole gate and there is no quality threshold in front of it:
|
`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
|
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
|
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
|
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
|
`docs/evals/2026-08-05-search-quality-signals.md` (V-539). **The embedder is not a
|
||||||
|
fourth signal**, measured 2026-08-09 (V-668). Query-to-passage cosine scores 0.79 to
|
||||||
|
0.91 on answerable questions and 0.75 to 0.84 on unanswerable ones, and the sets
|
||||||
|
overlap. The wrong TCP article scored 0.8653, above five of six unanswerable rows. It
|
||||||
|
measures topic and not whether the passage answers, so no threshold splits them. **Which query source claimed
|
||||||
a turn is readable on `/chat`** as a badge beside the reply, carried on
|
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
|
`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
|
rides the context, so `handleText` keeps the one string signature the mic, telegram and
|
||||||
@@ -404,8 +745,11 @@ start of a session rather than one lookup per first use:
|
|||||||
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
|
ToolSearch("select:mcp__vikunja__list_tasks,mcp__vikunja__get_task_details,mcp__vikunja__create_task,mcp__vikunja__update_task")
|
||||||
```
|
```
|
||||||
|
|
||||||
`update_task` carrying a `description` resets `done` to false, so closing a task with a
|
**Close a finished task with `done: true` and nothing else** (owner's call, 07-08-2026).
|
||||||
write-up takes two calls: the description, then `done: true`.
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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 deps-sentinel tidy eval-router eval-reach 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
|
||||||
|
|
||||||
@@ -128,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
|
||||||
@@ -189,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
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
+35
-8
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -60,6 +60,17 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
|||||||
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 phraser.A(phraser.ActConfirm, map[string]string{"name": 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):
|
case errors.Is(err, tool.ErrNeedsAuthedSurface):
|
||||||
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
||||||
// help: everything that proposed this act — the STT, the router,
|
// help: everything that proposed this act — the STT, the router,
|
||||||
|
|||||||
+130
-27
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/kami/maven/internal/crawl"
|
"github.com/kami/maven/internal/crawl"
|
||||||
"github.com/kami/maven/internal/decision"
|
"github.com/kami/maven/internal/decision"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/kiwix"
|
||||||
"github.com/kami/maven/internal/memory"
|
"github.com/kami/maven/internal/memory"
|
||||||
"github.com/kami/maven/internal/morning"
|
"github.com/kami/maven/internal/morning"
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
@@ -59,6 +60,35 @@ type querySource struct {
|
|||||||
// sources search text with no notion of a day. When one of them grows a
|
// sources search text with no notion of a day. When one of them grows a
|
||||||
// date parameter, flip its flag here.
|
// date parameter, flip its flag here.
|
||||||
dateAware bool
|
dateAware bool
|
||||||
|
|
||||||
|
// dest — the destination this source serves, when the cascade named one
|
||||||
|
// (V-655). Several sources share a destination: the three recall passes and
|
||||||
|
// the fact-by-key lookup are all SourceRecall, because which of them lands
|
||||||
|
// the hit is an ordering detail no utterance can name. A source with no
|
||||||
|
// dest is reachable only by walking the chain.
|
||||||
|
dest router.Source
|
||||||
|
|
||||||
|
// guesses — this source decides whether the turn is its own by scoring the
|
||||||
|
// utterance against frozen seeds, rather than by looking something up and
|
||||||
|
// coming back empty.
|
||||||
|
//
|
||||||
|
// The distinction is the whole point of the field. A source that looks can
|
||||||
|
// be wrong about relevance and still harmless, because the miss shows up as
|
||||||
|
// no rows. A source that guesses answers whatever it claims: weather has no
|
||||||
|
// local table to miss against, so "что такое TCP?" became "для какого
|
||||||
|
// города?". So when the cascade names a destination, the guessers that were
|
||||||
|
// not named do not get to try. The lookups still run, because a named
|
||||||
|
// destination is evidence and not a promise.
|
||||||
|
guesses bool
|
||||||
|
|
||||||
|
// boundary — dropping this source widens what leaves the box, so only a
|
||||||
|
// literal pattern may do it (V-666, owner's call of 2026-08-09).
|
||||||
|
//
|
||||||
|
// Every other guesser costs an answer when it is wrongly taken off a turn.
|
||||||
|
// This one costs the rule that a question about him never reaches an
|
||||||
|
// upstream engine. A grammar read the words to name a destination. A model
|
||||||
|
// and a softmax both inferred one, and neither may spend that.
|
||||||
|
boundary bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// querySources is the ordered chain actionQuery walks; first source to claim
|
// querySources is the ordered chain actionQuery walks; first source to claim
|
||||||
@@ -67,85 +97,85 @@ type querySource struct {
|
|||||||
// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one
|
// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one
|
||||||
// line here plus its method; where you put the line is the whole decision.
|
// line here plus its method; where you put the line is the whole decision.
|
||||||
var querySources = []querySource{
|
var querySources = []querySource{
|
||||||
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey},
|
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey, dest: router.SourceRecall},
|
||||||
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
||||||
// the more specific ask (its matcher requires a plan word), so the calendar
|
// the more specific ask (its matcher requires a plan word), so the calendar
|
||||||
// listing would otherwise swallow it.
|
// listing would otherwise swallow it.
|
||||||
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan},
|
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan, dest: router.SourceCalendar},
|
||||||
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||||||
// and the habit question is the more specific one. Its matcher requires a
|
// and the habit question is the more specific one. Its matcher requires a
|
||||||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||||
// Wednesday still reaches the calendar.
|
// Wednesday still reaches the calendar.
|
||||||
{name: "habits", answer: (*reactiveHandler).queryHabits},
|
{name: "habits", answer: (*reactiveHandler).queryHabits, dest: router.SourceCalendar},
|
||||||
// Before "calendar" and before the recall sources: "что мне нужно
|
// Before "calendar" and before the recall sources: "что мне нужно
|
||||||
// сделать?" is a question about the task list, and the notes pass would
|
// сделать?" is a question about the task list, and the notes pass would
|
||||||
// otherwise answer it with whatever note happens to be nearest. Its
|
// otherwise answer it with whatever note happens to be nearest. Its
|
||||||
// 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, dest: router.SourceTasks},
|
||||||
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
||||||
// question about the operational state Praxis holds, and it used to fall
|
// question about the operational state Praxis holds, and it used to fall
|
||||||
// through every source to the web search (Vikunja #475). Its matcher needs
|
// through every source to the web search (Vikunja #475). Its matcher needs
|
||||||
// an attention marker, and it falls through when Praxis is not configured.
|
// an attention marker, and it falls through when Praxis is not configured.
|
||||||
{name: "attention", answer: (*reactiveHandler).queryAttention},
|
{name: "attention", answer: (*reactiveHandler).queryAttention, dest: router.SourceAttention, guesses: true},
|
||||||
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
||||||
// about the shopping list, and the recall pass would otherwise answer it
|
// 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
|
// from an old note about the shop. Its matcher needs an explicit list
|
||||||
// marker, so "надо бы съездить в магазин" is untouched.
|
// marker, so "надо бы съездить в магазин" is untouched.
|
||||||
{name: "list", answer: (*reactiveHandler).queryList},
|
{name: "list", answer: (*reactiveHandler).queryList, dest: router.SourceList, guesses: true},
|
||||||
// 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, dest: router.SourceMoney},
|
||||||
// Also above the recall sources: "что я тебе говорил?" is a question about
|
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||||||
// the facts he tapped in, and the notes pass would answer it with whatever
|
// 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
|
// note is nearest (Vikunja #456). Its matcher needs both halves of a
|
||||||
// history phrase and bails out when he names a topic, so "что я говорил
|
// history phrase and bails out when he names a topic, so "что я говорил
|
||||||
// про сервер" is still recall.
|
// про сервер" is still recall.
|
||||||
{name: "history", answer: (*reactiveHandler).queryHistory},
|
{name: "history", answer: (*reactiveHandler).queryHistory, dest: router.SourceRecall},
|
||||||
// Before the recall sources and before general knowledge: "что нового?" is
|
// 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
|
||||||
// "у меня новая лента в инстаграме" is untouched.
|
// "у меня новая лента в инстаграме" is untouched.
|
||||||
{name: "feeds", answer: (*reactiveHandler).queryFeeds},
|
{name: "feeds", answer: (*reactiveHandler).queryFeeds, dest: router.SourceFeeds, guesses: true},
|
||||||
// Before "calendar" and before the recall sources: "что включено дома?" is
|
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||||||
// a question about the house, and the notes pass would otherwise answer it
|
// a question about the house, and the notes pass would otherwise answer it
|
||||||
// from whatever he once said about the lights. Its matcher needs a house
|
// from whatever he once said about the lights. Its matcher needs a house
|
||||||
// marker plus an ask plus a device word, and it bails out on weather
|
// marker plus an ask plus a device word, and it bails out on weather
|
||||||
// wording, so "какая температура на улице?" still reaches the weather
|
// wording, so "какая температура на улице?" still reaches the weather
|
||||||
// source.
|
// source.
|
||||||
{name: "home", answer: (*reactiveHandler).queryHome},
|
{name: "home", answer: (*reactiveHandler).queryHome, dest: router.SourceHome, guesses: true},
|
||||||
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||||
// question about the LAN, and the recall pass would otherwise answer it
|
// question about the LAN, and the recall pass would otherwise answer it
|
||||||
// from an old note about the router. Its matcher needs a network word plus
|
// from an old note about the router. Its matcher needs a network word plus
|
||||||
// an ask plus a device noun, so "интернет не работает" is untouched.
|
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||||
{name: "network", answer: (*reactiveHandler).queryNetwork},
|
{name: "network", answer: (*reactiveHandler).queryNetwork, dest: router.SourceNetwork, guesses: true},
|
||||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true, dest: router.SourceCalendar},
|
||||||
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
{name: "weather", answer: (*reactiveHandler).queryWeather, dest: router.SourceWeather, guesses: true},
|
||||||
// A question about her, above the three sources that search his own data
|
// A question about her, above the three sources that search his own data
|
||||||
// (Vikunja #555). It has no answer anywhere else: below the boundary
|
// (Vikunja #555). It has no answer anywhere else: below the boundary
|
||||||
// SearXNG answers about somebody else's assistant, and above it his notes
|
// SearXNG answers about somebody else's assistant, and above it his notes
|
||||||
// answer by proximity — "кто ты" came back from a note of his, measured on
|
// 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.
|
// the box, because the recall index has no idea the subject is her.
|
||||||
{name: "self", answer: (*reactiveHandler).querySelf},
|
{name: "self", answer: (*reactiveHandler).querySelf, dest: router.SourceSelf, guesses: true},
|
||||||
{name: "embed", answer: (*reactiveHandler).queryEmbed},
|
{name: "embed", answer: (*reactiveHandler).queryEmbed, dest: router.SourceRecall},
|
||||||
{name: "memory", answer: (*reactiveHandler).queryMemory},
|
{name: "memory", answer: (*reactiveHandler).queryMemory, dest: router.SourceRecall},
|
||||||
{name: "notes", answer: (*reactiveHandler).queryNotes},
|
{name: "notes", answer: (*reactiveHandler).queryNotes, dest: router.SourceRecall},
|
||||||
// THE BOUNDARY. Everything above answers from his own data; everything
|
// THE BOUNDARY. Everything above answers from his own data; everything
|
||||||
// below answers from the world's. A question about him that got this far
|
// below answers from the world's. A question about him that got this far
|
||||||
// has no answer in his data, and no outside source can supply one, so this
|
// has no answer in his data, and no outside source can supply one, so this
|
||||||
// stops the walk rather than let the encyclopedia and the model guess.
|
// stops the walk rather than let the encyclopedia and the model guess.
|
||||||
{name: "personal", answer: (*reactiveHandler).queryPersonal},
|
{name: "personal", answer: (*reactiveHandler).queryPersonal, dest: router.SourceRecall, guesses: true, boundary: true},
|
||||||
// The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats
|
// The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats
|
||||||
// a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at
|
// a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at
|
||||||
// stake by this point — the boundary above already stopped every question
|
// stake by this point — the boundary above already stopped every question
|
||||||
// about him, and only the query string leaves the box.
|
// about him, and only the query string leaves the box.
|
||||||
{name: "search", answer: (*reactiveHandler).querySearch},
|
{name: "search", answer: (*reactiveHandler).querySearch, dest: router.SourceWorld},
|
||||||
// The offline encyclopedia, now the fallback for when the line is down or
|
// The offline encyclopedia, now the fallback for when the line is down or
|
||||||
// the search comes back empty. It reads the way it always did; what changed
|
// the search comes back empty. It reads the way it always did; what changed
|
||||||
// is that it no longer gets first refusal on a world question.
|
// is that it no longer gets first refusal on a world question.
|
||||||
{name: "kiwix", answer: (*reactiveHandler).queryKiwix},
|
{name: "kiwix", answer: (*reactiveHandler).queryKiwix, dest: router.SourceWorld},
|
||||||
// LAST before the model answers from memory, and that position is the whole
|
// LAST before the model answers from memory, and that position is the whole
|
||||||
// design (Vikunja #259): everything of his, then the search, then the ZIMs,
|
// design (Vikunja #259): everything of his, then the search, then the ZIMs,
|
||||||
// and only then a page he named. The model does NOT come first: it
|
// and only then a page he named. The model does NOT come first: it
|
||||||
@@ -153,8 +183,48 @@ var querySources = []querySource{
|
|||||||
// a 1.7B guessing at a page it cannot read is how contents get invented.
|
// a 1.7B guessing at a page it cannot read is how contents get invented.
|
||||||
// This source only claims a turn where he named a URL, so it never competes
|
// This source only claims a turn where he named a URL, so it never competes
|
||||||
// with a local answer.
|
// with a local answer.
|
||||||
{name: "web", answer: (*reactiveHandler).queryWeb},
|
{name: "web", answer: (*reactiveHandler).queryWeb, dest: router.SourceWorld},
|
||||||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral},
|
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral, dest: router.SourceWorld},
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryWalk narrows the chain for one turn against the destination the cascade
|
||||||
|
// named, and says which sources were left out (V-655).
|
||||||
|
//
|
||||||
|
// It takes sources OUT and never moves one, which is the whole safety argument.
|
||||||
|
// The table's order is load-bearing and every comment on it argues a reason
|
||||||
|
// between two sources; none of those reasons is about this. Above all, the
|
||||||
|
// order carries "his data first, then the world", and a destination named by a
|
||||||
|
// model must not be able to reverse that. Naming SourceWorld does not send the
|
||||||
|
// turn outside — it stops the guessers from claiming it on the way.
|
||||||
|
//
|
||||||
|
// What comes out is exactly the sources that guess. Those decide whether a turn
|
||||||
|
// is theirs by scoring it against frozen seeds, and then answer whatever they
|
||||||
|
// claimed, because they have no lookup that can come back empty. That is the
|
||||||
|
// whole of the 2026-08-07 defect: weather claiming "что такое TCP?", the feed
|
||||||
|
// claiming "какой у меня любимый язык?", the personal boundary claiming "кто
|
||||||
|
// такой Линус Торвальдс?". The sources that look are all still asked, so a
|
||||||
|
// wrong destination costs nothing but the guess it prevented.
|
||||||
|
//
|
||||||
|
// No destination named ⇒ the table exactly as written, which is what shipped
|
||||||
|
// before the field existed. That is the floor. The classifier arm names
|
||||||
|
// nothing, so a box whose model is down routes queries the way it always did.
|
||||||
|
// The personal boundary is the one exception, and anchored is what buys it
|
||||||
|
// (V-666). A grammar matched a literal pattern to name the destination. The
|
||||||
|
// routing heads and the resident model inferred one, and an inferred SourceWorld
|
||||||
|
// takes the boundary off a question about him. That widens what is asked
|
||||||
|
// upstream rather than costing a local answer, so those two keep it.
|
||||||
|
func queryWalk(dest router.Source, anchored bool) (walk, skipped []querySource) {
|
||||||
|
if dest == router.SourceUnknown {
|
||||||
|
return querySources, nil
|
||||||
|
}
|
||||||
|
for _, s := range querySources {
|
||||||
|
if s.guesses && s.dest != dest && (anchored || !s.boundary) {
|
||||||
|
skipped = append(skipped, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
walk = append(walk, s)
|
||||||
|
}
|
||||||
|
return walk, skipped
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||||||
@@ -164,7 +234,14 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
|||||||
// (V-564). Finish names everyone below the winner.
|
// (V-564). Finish names everyone below the winner.
|
||||||
decision.Expect(ctx, decision.StageQuery, querySourceNames())
|
decision.Expect(ctx, decision.StageQuery, querySourceNames())
|
||||||
rec := decision.From(ctx)
|
rec := decision.From(ctx)
|
||||||
for _, src := range querySources {
|
walk, skipped := queryWalk(dec.Source, dec.SourceAnchored)
|
||||||
|
for _, src := range skipped {
|
||||||
|
rec.Note(decision.Claim{
|
||||||
|
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||||
|
Reason: "it decides by similarity and the cascade named " + string(dec.Source),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, src := range walk {
|
||||||
if dec.Continued && !src.dateAware {
|
if dec.Continued && !src.dateAware {
|
||||||
rec.Note(decision.Claim{
|
rec.Note(decision.Claim{
|
||||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||||
@@ -850,6 +927,28 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The topic, not the sentence (V-668). Kiwix ranks by keyword overlap, so
|
||||||
|
// the question words outrank the one word that names the article: measured
|
||||||
|
// on 2026-08-09, "что такое TCP" returns "Перехват TCP-соединения" and
|
||||||
|
// "TCP" returns TCP. Only the verbatim path needs this. The rewriter
|
||||||
|
// already reduces a question to English keywords, and reducing twice would
|
||||||
|
// take the topic off the input it reads.
|
||||||
|
if verbatim {
|
||||||
|
if topic := kiwix.Topic(pattern); topic != "" {
|
||||||
|
// The article named exactly, before any ranking runs. A ZIM is
|
||||||
|
// addressable by title and a wrong title is a 404, so this either
|
||||||
|
// answers or costs one request that says nothing.
|
||||||
|
for _, cand := range kiwix.TitleCandidates(topic) {
|
||||||
|
page, err := h.kiwix.client.Article(ctxK, kiwix.TitlePath(book, cand), h.kiwix.runes)
|
||||||
|
if err == nil && page.Text != "" {
|
||||||
|
log.Printf("voice: kiwix: %q in %q → title hit %q", topic, book, page.Title)
|
||||||
|
return h.kiwixReply(ctx, t, page.Title, page.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pattern = topic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
hits, err := h.kiwix.client.Search(ctxK, pattern, book, h.kiwix.max)
|
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)
|
||||||
@@ -882,14 +981,18 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
|||||||
}
|
}
|
||||||
page = crawl.Page{Title: top.Title, Text: top.Snippet}
|
page = crawl.Page{Title: top.Title, Text: top.Snippet}
|
||||||
}
|
}
|
||||||
// Handed over the same way a note or a page is: context for the question he
|
return h.kiwixReply(ctx, t, top.Title, page.Text)
|
||||||
// asked, not something to recite.
|
}
|
||||||
snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes)
|
|
||||||
|
// kiwixReply hands one article over the same way a note or a page is handed
|
||||||
|
// over: context for the question he asked, not something to recite.
|
||||||
|
func (h *reactiveHandler) kiwixReply(ctx context.Context, t *queryTurn, title, text string) (string, bool) {
|
||||||
|
snippet := title + "\n" + crawl.TrimRunes(text, h.kiwix.runes)
|
||||||
reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet})
|
reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet})
|
||||||
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 readBack(top.Title + " — " + page.Text), true
|
return readBack(title + " — " + text), true
|
||||||
}
|
}
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ func TestChatAnswersWithNoLlamaServer(t *testing.T) {
|
|||||||
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
|
dead := llm.New("http://127.0.0.1:1", 500*time.Millisecond)
|
||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
h.recall.embedder = emb
|
h.recall.embedder = emb
|
||||||
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead))
|
h.router = buildRouter(emb, h.matcher, 0.55, pickLLMRouter(true, dead), nil)
|
||||||
h.replier = newLLMReplier(dead, nil)
|
h.replier = newLLMReplier(dead, nil)
|
||||||
|
|
||||||
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web"))
|
||||||
|
|||||||
+53
-23
@@ -6,7 +6,6 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/dialogue"
|
"github.com/kami/maven/internal/dialogue"
|
||||||
@@ -162,12 +161,13 @@ func withNotice(notice, reply string) string {
|
|||||||
// напоминание?" — answer first, then the open question. A question in front of
|
// напоминание?" — answer first, then the open question. A question in front of
|
||||||
// its own answer would read as ignoring what he asked.
|
// 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
|
// Two sentences, not one (V-654). This used to fold the answer's full stop into
|
||||||
// sentence — that is the owner's own punctuation, "в Риме сейчас ..., на какое
|
// a comma, on the strength of the owner having written it that way once. Spliced
|
||||||
// время поставить напоминание?". An answer that is ITSELF a question keeps its
|
// onto a real answer it reads as one run-on thought — "вот что я нашла: вайфай
|
||||||
// mark and the resume starts a new sentence: she sometimes answers a side query
|
// пароль лежит в ящике стола, на какое время поставить напоминание?" — and the
|
||||||
// by asking him to say it again, and "переформулировать?, на какое время" folds
|
// question disappears into the tail of a sentence about something else. A reply
|
||||||
// two questions into one unreadable line.
|
// with no terminator of its own is given one, so the join never depends on how
|
||||||
|
// the phraser chose to end.
|
||||||
//
|
//
|
||||||
// A resume with no answer in front of it is just the question.
|
// A resume with no answer in front of it is just the question.
|
||||||
func withResumed(reply, resumed string) string {
|
func withResumed(reply, resumed string) string {
|
||||||
@@ -178,23 +178,17 @@ func withResumed(reply, resumed string) string {
|
|||||||
if reply == "" {
|
if reply == "" {
|
||||||
return resumed
|
return resumed
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(reply, "?") {
|
if !endsSentence(reply) {
|
||||||
return reply + " " + resumed
|
reply += "."
|
||||||
}
|
}
|
||||||
if trimmed := strings.TrimRight(reply, ".!"); trimmed != "" {
|
return reply + " " + resumed
|
||||||
reply = trimmed
|
|
||||||
}
|
|
||||||
return reply + ", " + lowerFirst(resumed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// lowerFirst lowercases the opening rune, so a deck line written as a standalone
|
// endsSentence reports whether s already closes itself. The ellipsis counts: a
|
||||||
// sentence reads as the second half of one. Only the first rune: "На какое
|
// trailing "…" is a deliberate end, and a full stop after it reads as a typo.
|
||||||
// время" must become "на какое время" and nothing else in it may move.
|
func endsSentence(s string) bool {
|
||||||
func lowerFirst(s string) string {
|
r, _ := utf8.DecodeLastRuneInString(s)
|
||||||
for i, r := range s {
|
return strings.ContainsRune(".!?…", r)
|
||||||
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.
|
||||||
@@ -381,6 +375,14 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// He is answering, so the run of step-asides is over (V-654). Reset here
|
||||||
|
// rather than where a gap is FILLED: "позвонить маме" against a question
|
||||||
|
// about the time gives her nothing she asked for and still means he is in
|
||||||
|
// the exchange, and the retry it costs is bound enough on its own. The
|
||||||
|
// counter is for the case the bounds miss — he asked for other things and
|
||||||
|
// never came back.
|
||||||
|
q.Suspends = 0
|
||||||
|
|
||||||
merged := q.Answer(text, toDialogueSlots(answer))
|
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
|
||||||
@@ -467,6 +469,14 @@ func (h *reactiveHandler) noteDropped(ctx context.Context) {
|
|||||||
//
|
//
|
||||||
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
|
// 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.
|
// says nothing. She must not claim to be holding a question she cannot re-ask.
|
||||||
|
//
|
||||||
|
// Suspension is bounded, since V-654. Neither of the two things above is a
|
||||||
|
// limit: no attempt is spent, and restarting the clock means the TTL cannot
|
||||||
|
// arrive while he keeps talking. So the count is the only thing that ends it,
|
||||||
|
// and past MaxSuspends she lets the request go and says so with the same line
|
||||||
|
// every other drop uses. The rule is unchanged — a question ends by being
|
||||||
|
// answered or by being let go out loud — this only recognises three unrelated
|
||||||
|
// requests in a row as the second of those.
|
||||||
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
|
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
|
||||||
rt := turnRouteFrom(ctx)
|
rt := turnRouteFrom(ctx)
|
||||||
if rt == nil || len(q.Missing) == 0 {
|
if rt == nil || len(q.Missing) == 0 {
|
||||||
@@ -476,11 +486,22 @@ func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.Pending
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !q.CanResume() {
|
||||||
|
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||||
|
h.noteDropped(ctx)
|
||||||
|
log.Printf("voice: clarify — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q.Suspends++
|
||||||
|
// Rides is the same event counted without the reset (V-663). Incremented
|
||||||
|
// beside Suspends and never anywhere else, so the two cannot disagree about
|
||||||
|
// what happened, only about how much of it they remember.
|
||||||
|
q.Rides++
|
||||||
q.Asked = h.now()
|
q.Asked = h.now()
|
||||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||||
rt.resume = question
|
rt.resume = question
|
||||||
rt.suspended = true
|
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])
|
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d, ride %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends, q.Rides, dialogue.MaxRides)
|
||||||
}
|
}
|
||||||
|
|
||||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||||
@@ -520,6 +541,14 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
|||||||
if !ok || !q.CanAsk() {
|
if !ok || !q.CanAsk() {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
// Suspends is not carried, and by this point it is already zero: the answer
|
||||||
|
// path resets it (V-654). Left off the literal so the zero is stated where
|
||||||
|
// the struct is built, rather than inherited from a field nobody names.
|
||||||
|
//
|
||||||
|
// Rides IS carried, and that is the whole point of it (V-663). This is the
|
||||||
|
// same request under a second question, not a new one, so the turns it has
|
||||||
|
// already ridden still count against it. Dropping the field here is exactly
|
||||||
|
// the re-basing that let one question ride twenty-six replies.
|
||||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||||
Intent: q.Intent,
|
Intent: q.Intent,
|
||||||
Slots: merged,
|
Slots: merged,
|
||||||
@@ -530,6 +559,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
|||||||
TTL: clarifyTTL,
|
TTL: clarifyTTL,
|
||||||
Attempts: q.Attempts + 1,
|
Attempts: q.Attempts + 1,
|
||||||
MaxAttempts: q.MaxAttempts,
|
MaxAttempts: q.MaxAttempts,
|
||||||
|
Rides: q.Rides,
|
||||||
})
|
})
|
||||||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
|
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
|
||||||
return question, true
|
return question, true
|
||||||
@@ -571,7 +601,7 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
|||||||
}
|
}
|
||||||
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.
|
||||||
|
|||||||
@@ -317,7 +317,7 @@ func TestClarifyExpiryIsAnnouncedAndWordsStillRoute(t *testing.T) {
|
|||||||
h, _, now := newClarifyHandler(t)
|
h, _, now := newClarifyHandler(t)
|
||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
h.recall.embedder = emb
|
h.recall.embedder = emb
|
||||||
h.router = buildRouter(emb, h.matcher, 0.55, nil)
|
h.router = buildRouter(emb, h.matcher, 0.55, nil, nil)
|
||||||
|
|
||||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни"}, "напомни")); !asked {
|
||||||
t.Fatal("expected a question")
|
t.Fatal("expected a question")
|
||||||
@@ -671,7 +671,7 @@ func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) {
|
|||||||
func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
h, st, _ := newClarifyHandler(t)
|
h, st, _ := newClarifyHandler(t)
|
||||||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
|
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
|
||||||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||||||
return h, st
|
return h, st
|
||||||
}
|
}
|
||||||
@@ -740,3 +740,53 @@ func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTheResumedQuestionIsItsOwnSentence — V-654. The re-ask used to be spliced
|
||||||
|
// onto the answer with a comma, so a real answer and an unrelated open question
|
||||||
|
// read as one run-on thought and the question vanished into its tail.
|
||||||
|
func TestTheResumedQuestionIsItsOwnSentence(t *testing.T) {
|
||||||
|
const resumed = "На какое время поставить напоминание?"
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
reply string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
// The measured line, shortened. Two sentences, and the question keeps
|
||||||
|
// its capital.
|
||||||
|
name: "a statement keeps its full stop",
|
||||||
|
reply: "Вайфай пароль лежит в ящике стола.",
|
||||||
|
want: "Вайфай пароль лежит в ящике стола. " + resumed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a reply with no terminator is given one",
|
||||||
|
reply: "Вайфай пароль лежит в ящике стола",
|
||||||
|
want: "Вайфай пароль лежит в ящике стола. " + resumed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// She sometimes answers a side query by asking him to say it again.
|
||||||
|
// Two questions, and neither may swallow the other.
|
||||||
|
name: "a question keeps its mark",
|
||||||
|
reply: "Можешь переформулировать?",
|
||||||
|
want: "Можешь переформулировать? " + resumed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an ellipsis is already an ending",
|
||||||
|
reply: "Не уверена…",
|
||||||
|
want: "Не уверена… " + resumed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a resume with no answer in front of it is just the question",
|
||||||
|
reply: "",
|
||||||
|
want: resumed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := withResumed(tc.reply, resumed); got != tc.want {
|
||||||
|
t.Errorf("%s: withResumed(%q) = %q, want %q", tc.name, tc.reply, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := withResumed("Готово.", ""); got != "Готово." {
|
||||||
|
t.Errorf("nothing to resume must leave the reply alone, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ import (
|
|||||||
// and nothing should: a missing name costs one line of the record, while a
|
// 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.
|
// check that walks the ladder would have to run the ladder.
|
||||||
var preRouteLadder = []string{
|
var preRouteLadder = []string{
|
||||||
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", "ordinal",
|
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair",
|
||||||
|
"repair-negative", "ordinal",
|
||||||
}
|
}
|
||||||
|
|
||||||
// notePreRoute records one rung of that ladder and passes its verdict through
|
// notePreRoute records one rung of that ladder and passes its verdict through
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler {
|
|||||||
return &reactiveHandler{
|
return &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Tim
|
|||||||
// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
|
// 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.
|
// 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)
|
*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
|
||||||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
|
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil, nil)
|
||||||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||||||
return h, st, now
|
return h, st, now
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func TestApplyAction_FactCapture_QueuesEntityResolution(t *testing.T) {
|
|||||||
|
|
||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
matcher := tool.NewMatcher(api)
|
matcher := tool.NewMatcher(api)
|
||||||
rtr := buildRouter(emb, matcher, 0.55, nil)
|
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
|
||||||
|
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
|
|||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
|
|||||||
+30
-112
@@ -252,6 +252,23 @@ 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 = wireGatherer(st, cfg, rules)
|
gatherer = wireGatherer(st, cfg, rules)
|
||||||
@@ -284,26 +301,7 @@ func run(args []string) error {
|
|||||||
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),
|
|
||||||
getDecisions: turnDecisionsFn(voiceW),
|
|
||||||
seedStore: seedStoreIfAllowed(st),
|
|
||||||
nexus: nexusOf(voiceW),
|
|
||||||
}
|
|
||||||
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
|
||||||
@@ -364,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)
|
||||||
@@ -494,23 +495,14 @@ func run(args []string) error {
|
|||||||
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),
|
|
||||||
getDecisions: turnDecisionsFn(voiceW),
|
|
||||||
seedStore: seedStoreIfAllowed(st),
|
|
||||||
}
|
|
||||||
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
|
||||||
@@ -518,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")
|
||||||
@@ -585,33 +528,8 @@ func run(args []string) error {
|
|||||||
})
|
})
|
||||||
log.Printf("mavend: ipc listening on %s", srv.Path())
|
log.Printf("mavend: ipc listening on %s", srv.Path())
|
||||||
|
|
||||||
if !locked && voiceW != nil {
|
|
||||||
goWorker(&wg, func() {
|
|
||||||
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 {
|
||||||
goWorker(&wg, func() { tl.run(ctx) })
|
startBackground(ctx, &wg, depsNow())
|
||||||
goWorker(&wg, func() { factWorker.run(ctx) })
|
|
||||||
if evalWorker != nil {
|
|
||||||
goWorker(&wg, func() { evalWorker.run(ctx) })
|
|
||||||
}
|
|
||||||
if feedWkr != nil {
|
|
||||||
goWorker(&wg, func() { feedWkr.run(ctx) })
|
|
||||||
}
|
|
||||||
if crawlWkr != nil {
|
|
||||||
goWorker(&wg, func() { crawlWkr.run(ctx) })
|
|
||||||
}
|
|
||||||
if voiceW != nil && voiceW.mcp != nil {
|
|
||||||
goWorker(&wg, func() { voiceW.mcp.run(ctx) })
|
|
||||||
}
|
|
||||||
if voiceW != nil && voiceW.home != nil {
|
|
||||||
goWorker(&wg, func() { voiceW.home.run(ctx) })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func newNoteHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
|||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil, nil),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The floor, and it is the reason a destination is safe to add at all: a box
|
||||||
|
// whose model is down names nothing, and naming nothing has to walk the chain
|
||||||
|
// the way it walked before the field existed.
|
||||||
|
func TestNoDestinationWalksTheWholeChain(t *testing.T) {
|
||||||
|
walk, skipped := queryWalk(router.SourceUnknown, false)
|
||||||
|
if len(skipped) != 0 {
|
||||||
|
t.Errorf("skipped %d sources with no destination named, want none", len(skipped))
|
||||||
|
}
|
||||||
|
if len(walk) != len(querySources) {
|
||||||
|
t.Fatalf("walk has %d sources, want the whole table of %d", len(walk), len(querySources))
|
||||||
|
}
|
||||||
|
for i := range walk {
|
||||||
|
if walk[i].name != querySources[i].name {
|
||||||
|
t.Fatalf("position %d is %q, want %q", i, walk[i].name, querySources[i].name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 2026-08-07 defects, one per line. Each is a source that decides by seed
|
||||||
|
// similarity claiming a turn that was never its own, and then answering it
|
||||||
|
// because it has no lookup that could come back empty.
|
||||||
|
func TestANamedDestinationSilencesTheOtherGuessers(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
dest router.Source
|
||||||
|
utterance string
|
||||||
|
silenced string
|
||||||
|
anchored bool // a stage 0 grammar named the destination
|
||||||
|
}{
|
||||||
|
{router.SourceWorld, "что такое TCP?", "weather", true},
|
||||||
|
{router.SourceWorld, "сколько будет 17 на 23?", "weather", true},
|
||||||
|
{router.SourceWorld, "кто такой Линус Торвальдс?", "personal", true},
|
||||||
|
{router.SourceRecall, "какой у меня любимый язык?", "feeds", false},
|
||||||
|
{router.SourceCalendar, "что в календаре на завтра?", "weather", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
walk, skipped := queryWalk(c.dest, c.anchored)
|
||||||
|
if inWalk(walk, c.silenced) {
|
||||||
|
t.Errorf("%q named %q: %q is still asked", c.utterance, c.dest, c.silenced)
|
||||||
|
}
|
||||||
|
if !inWalk(skipped, c.silenced) {
|
||||||
|
t.Errorf("%q named %q: %q is missing from the record of who was skipped",
|
||||||
|
c.utterance, c.dest, c.silenced)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naming the world must not send the turn outside. His notes, his facts and the
|
||||||
|
// boundary in front of them are the invariant CLAUDE.md states as "the owner's
|
||||||
|
// data first, then the world", and a destination a model wrote must not be able
|
||||||
|
// to reverse it.
|
||||||
|
func TestNamingTheWorldStillReadsHisDataFirst(t *testing.T) {
|
||||||
|
walk, _ := queryWalk(router.SourceWorld, true)
|
||||||
|
for _, look := range []string{"fact-by-key", "embed", "memory", "notes"} {
|
||||||
|
if !inWalk(walk, look) {
|
||||||
|
t.Errorf("%q was dropped; only the sources that guess may be dropped", look)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if posOf(walk, "notes") > posOf(walk, "search") {
|
||||||
|
t.Error("search is asked before his notes are")
|
||||||
|
}
|
||||||
|
if posOf(walk, "search") < 0 {
|
||||||
|
t.Fatal("search is not in the walk at all")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The boundary belongs to his data, so naming recall keeps it. That is what
|
||||||
|
// makes "какой у меня любимый язык?" answer "не нашла у тебя такой записи"
|
||||||
|
// rather than reaching SearXNG once nothing local had it.
|
||||||
|
func TestNamingRecallKeepsTheBoundary(t *testing.T) {
|
||||||
|
walk, _ := queryWalk(router.SourceRecall, true)
|
||||||
|
if !inWalk(walk, "personal") {
|
||||||
|
t.Fatal("the personal boundary was skipped on a turn named for his own data")
|
||||||
|
}
|
||||||
|
if posOf(walk, "personal") > posOf(walk, "search") {
|
||||||
|
t.Error("the boundary no longer sits in front of the world")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The owner's call of 2026-08-09 (V-666): only a stage 0 grammar may take the
|
||||||
|
// personal boundary off a turn. The routing heads and the resident model both
|
||||||
|
// name a destination by inference, and an inferred SourceWorld would send a
|
||||||
|
// question about him upstream. Every other guesser still goes.
|
||||||
|
func TestOnlyAGrammarMayDropTheBoundary(t *testing.T) {
|
||||||
|
walk, skipped := queryWalk(router.SourceWorld, false)
|
||||||
|
if !inWalk(walk, "personal") {
|
||||||
|
t.Error("an inferred destination took the boundary off the turn")
|
||||||
|
}
|
||||||
|
if !inWalk(skipped, "weather") {
|
||||||
|
t.Error("weather is still asked; the rule covers the boundary alone")
|
||||||
|
}
|
||||||
|
if posOf(walk, "personal") > posOf(walk, "search") {
|
||||||
|
t.Error("the boundary no longer sits in front of the world")
|
||||||
|
}
|
||||||
|
if anchored, _ := queryWalk(router.SourceWorld, true); inWalk(anchored, "personal") {
|
||||||
|
t.Error(`a grammar named the world and the boundary stayed: ` +
|
||||||
|
`"кто такой Линус Торвальдс?" is answered "не нашла у тебя такой записи" again`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whatever the destination, the walk is a subsequence of the table. Every
|
||||||
|
// comment on that table argues an order between two sources, and none of those
|
||||||
|
// reasons is about this field.
|
||||||
|
func TestTheWalkNeverReordersTheTable(t *testing.T) {
|
||||||
|
for _, dest := range append([]router.Source{router.SourceUnknown}, router.Sources...) {
|
||||||
|
walk, skipped := queryWalk(dest, true)
|
||||||
|
if len(walk)+len(skipped) != len(querySources) {
|
||||||
|
t.Errorf("%q: %d walked + %d skipped, want %d", dest, len(walk), len(skipped), len(querySources))
|
||||||
|
}
|
||||||
|
last := -1
|
||||||
|
for _, s := range walk {
|
||||||
|
at := posOf(querySources, s.name)
|
||||||
|
if at <= last {
|
||||||
|
t.Errorf("%q: %q is out of table order", dest, s.name)
|
||||||
|
}
|
||||||
|
last = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inWalk(list []querySource, name string) bool { return posOf(list, name) >= 0 }
|
||||||
|
|
||||||
|
func posOf(list []querySource, name string) int {
|
||||||
|
for i, s := range list {
|
||||||
|
if s.name == name {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ func TestReactiveNotesReminders(t *testing.T) {
|
|||||||
|
|
||||||
emb := router.NewHashEmbedder(1024)
|
emb := router.NewHashEmbedder(1024)
|
||||||
matcher := tool.NewMatcher(api)
|
matcher := tool.NewMatcher(api)
|
||||||
rtr := buildRouter(emb, matcher, 0.55, nil)
|
rtr := buildRouter(emb, matcher, 0.55, nil, nil)
|
||||||
|
|
||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
@@ -104,7 +104,7 @@ func TestSpokenTaskCaptureFilesATask(t *testing.T) {
|
|||||||
h := &reactiveHandler{
|
h := &reactiveHandler{
|
||||||
api: api,
|
api: api,
|
||||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||||
router: buildRouter(emb, matcher, 0.55, nil),
|
router: buildRouter(emb, matcher, 0.55, nil, nil),
|
||||||
replier: voice.NewStubReplier(),
|
replier: voice.NewStubReplier(),
|
||||||
now: func() time.Time { return now },
|
now: func() time.Time { return now },
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/lexicon"
|
"github.com/kami/maven/internal/lexicon"
|
||||||
"github.com/kami/maven/internal/morph"
|
"github.com/kami/maven/internal/morph"
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ type routedTurn struct {
|
|||||||
utterance string
|
utterance string
|
||||||
intent router.Intent
|
intent router.Intent
|
||||||
at time.Time
|
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
|
// repairWindow — how long a turn stays correctable. Long enough that he can
|
||||||
@@ -54,6 +60,13 @@ const repairWindow = 5 * time.Minute
|
|||||||
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
|
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
|
||||||
var repairMarkers = lexicon.RepairMarkers()
|
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
|
// repairIntents — the words he uses for each intent, as dictionary forms. They
|
||||||
// used to be prefixes ("заметк"), which is what a prefix list costs: "команд"
|
// used to be prefixes ("заметк"), which is what a prefix list costs: "команд"
|
||||||
// also matched "командировка", and "факт" matched "фактически". morph.SameWord
|
// also matched "командировка", and "факт" matched "фактически". morph.SameWord
|
||||||
@@ -147,6 +160,18 @@ func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
|||||||
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
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 {
|
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
@@ -157,6 +182,56 @@ func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
|||||||
return last
|
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
|
// resolveRepair handles a spoken correction of the previous turn: teach the
|
||||||
// classifier, redo the request under the corrected intent, and say so.
|
// classifier, redo the request under the corrected intent, and say so.
|
||||||
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
|
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
|
||||||
@@ -182,6 +257,7 @@ func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (strin
|
|||||||
learned = false
|
learned = false
|
||||||
}
|
}
|
||||||
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
|
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{
|
dec := router.Decision{
|
||||||
Utterance: last.utterance,
|
Utterance: last.utterance,
|
||||||
@@ -207,3 +283,24 @@ func repairLine(say string, learned bool) string {
|
|||||||
}
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||||
@@ -149,3 +150,95 @@ func TestRepairIntentWordCollisions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// V-636. A spoken correction lands in the same table the /chat gesture writes,
|
||||||
|
// so the sample is not limited to the turns he happened to type.
|
||||||
|
func TestSpokenCorrectionWritesTheLabel(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
emb := router.NewHashEmbedder(256)
|
||||||
|
h.recall.embedder = emb
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{
|
||||||
|
Ts: h.now(), Utterance: "купить хлеб", Intent: "fact", Source: "tap:voice",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
h.stampLastTurn("купить хлеб", id)
|
||||||
|
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||||
|
t.Fatal("the correction was not handled")
|
||||||
|
}
|
||||||
|
labels, err := st.RoutingLabels(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(labels) != 1 || labels[0].Was != "fact" || labels[0].ShouldBe != "note" {
|
||||||
|
t.Fatalf("labels %+v: the spoken correction did not land as a pair", labels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cheap half, which voice needs more than the web does: naming an intent
|
||||||
|
// aloud means saying "заметка", which is her vocabulary and not his.
|
||||||
|
func TestUntargetedSpokenCorrection(t *testing.T) {
|
||||||
|
h, st, now := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seed := func(utterance string) int64 {
|
||||||
|
id, err := st.WriteRoutingTrace(ctx, store.RoutingTrace{
|
||||||
|
Ts: h.now(), Utterance: utterance, Intent: "query", Source: "tap:voice",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h.recordTurn(utterance, router.IntentQuery)
|
||||||
|
h.stampLastTurn(utterance, id)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
seed("поужинал")
|
||||||
|
reply, handled := h.resolveUntargetedRepair(ctx, "нет, не так")
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("«нет, не так» was not read as a correction")
|
||||||
|
}
|
||||||
|
if reply == "" {
|
||||||
|
t.Error("a correction he cannot hear reads as one that was dropped")
|
||||||
|
}
|
||||||
|
labels, err := st.RoutingLabels(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(labels) != 1 || labels[0].ShouldBe != "" || labels[0].Was != "query" {
|
||||||
|
t.Fatalf("labels %+v: want one untargeted negative naming what she chose", labels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside the window it is a fresh sentence, not a verdict.
|
||||||
|
seed("поужинал ещё раз")
|
||||||
|
*now = now.Add(repairWindow + time.Minute)
|
||||||
|
if _, handled := h.resolveUntargetedRepair(ctx, "не так"); handled {
|
||||||
|
t.Error("a correction outside the window was handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whole-utterance, never a substring. This is the difference between the
|
||||||
|
// negatives and the markers, and getting it wrong would claim any sentence with
|
||||||
|
// "не так" in it.
|
||||||
|
func TestRepairNegativeIsTheWholeUtterance(t *testing.T) {
|
||||||
|
for _, s := range []string{
|
||||||
|
"не так поняла", "нет, не так", "ты ошиблась", "неправильно", "wrong", "no, that was wrong",
|
||||||
|
} {
|
||||||
|
if !isRepairNegative(s) {
|
||||||
|
t.Errorf("%q is not read as a correction", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, s := range []string{
|
||||||
|
"это не важно", "напомни не так поздно", "а не завтра", "не так, а вот так — это заметка",
|
||||||
|
"", "нет",
|
||||||
|
} {
|
||||||
|
if isRepairNegative(s) {
|
||||||
|
t.Errorf("%q was read as a correction", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func newLLMReplier(c phraser.Completer, block func() string) *llmReplier {
|
|||||||
|
|
||||||
// Reply never fails: a clarify, a model error and an unusable generation all
|
// 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 {
|
||||||
// The deck, not the stub's single sentence: a clarify she cannot turn
|
// 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,
|
// into a question is the line he hears most often when she misses him,
|
||||||
@@ -39,14 +39,14 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
|||||||
// что ты выпел стакан воды" for "я выпил воды".
|
// что ты выпел стакан воды" for "я выпил воды".
|
||||||
return phraser.FactAck(d.Utterance)
|
return phraser.FactAck(d.Utterance)
|
||||||
}
|
}
|
||||||
out, err := r.p.PhraseReply(context.Background(), d)
|
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
|
// The persona checks, on the live path (personaguard.go). A reply that
|
||||||
// leaks reasoning or calls him "вы" is worse than a flat one.
|
// leaks reasoning or calls him "вы" is worse than a flat one.
|
||||||
if _, ok := guardSpoken("reply", out); !ok {
|
if _, ok := guardSpoken("reply", out); !ok {
|
||||||
return r.stub.Reply(d)
|
return r.stub.Reply(ctx, d)
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,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, "записала, кофе закончился")
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
|||||||
// the clarify deck rather than the stub's single sentence.
|
// the clarify deck rather than the stub's single sentence.
|
||||||
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||||
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
|
got := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "мгм"})
|
||||||
if got == "я всё поняла" {
|
if got == "я всё поняла" {
|
||||||
t.Fatal("a clarify must not be phrased by the model")
|
t.Fatal("a clarify must not be phrased by the model")
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
|||||||
t.Errorf("on clarify: got %q, want %q", got, want)
|
t.Errorf("on clarify: got %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
// Two different misses do not sound identical.
|
// Two different misses do not sound identical.
|
||||||
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
if same := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||||
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
|
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,14 +60,14 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
|||||||
// produce, which is the same claim without pinning one wording.
|
// produce, which is the same claim without pinning one wording.
|
||||||
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
|
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
|
if got := r.Reply(context.Background(), d); !phraser.IsAck(key, nil, got) {
|
||||||
t.Errorf("on %s: got %q, want a %q line", what, got, key)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -474,7 +474,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
|||||||
// used to be built on a nil API, which meant any scenario that produced an
|
// used to be built on a nil API, which meant any scenario that produced an
|
||||||
// act panicked the moment the matcher was consulted.
|
// act panicked the moment the matcher was consulted.
|
||||||
matcher := tool.NewMatcher(api)
|
matcher := tool.NewMatcher(api)
|
||||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
|
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted), nil)
|
||||||
|
|
||||||
w.handler = &reactiveHandler{
|
w.handler = &reactiveHandler{
|
||||||
stt: simTranscriber{},
|
stt: simTranscriber{},
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/stt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A box with no workstation.stt block transcribes exactly as it did before the
|
||||||
|
// seam existed: the floor is handed back untouched, and nothing probes.
|
||||||
|
func TestSttSeamWithNoBlockIsTheFloor(t *testing.T) {
|
||||||
|
floor := stt.NewStub()
|
||||||
|
got, pair := sttSeam(&config.Config{}, floor)
|
||||||
|
if pair != nil {
|
||||||
|
t.Fatal("no block must build no pair")
|
||||||
|
}
|
||||||
|
if got != stt.Transcriber(floor) {
|
||||||
|
t.Fatal("no block must hand back the floor itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSttSeamPrefersTheWorkstation(t *testing.T) {
|
||||||
|
cfg := &config.Config{Workstation: &config.WorkstationConfig{
|
||||||
|
URL: "http://127.0.0.1:1",
|
||||||
|
Stt: &config.WorkstationSttConfig{
|
||||||
|
URL: "http://127.0.0.1:2/transcribe",
|
||||||
|
Health: "http://127.0.0.1:2/health",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
got, pair := sttSeam(cfg, stt.NewStub())
|
||||||
|
if pair == nil {
|
||||||
|
t.Fatal("a configured block must build a pair")
|
||||||
|
}
|
||||||
|
defer pair.Stop()
|
||||||
|
if got != stt.Transcriber(pair) {
|
||||||
|
t.Fatal("the pair is what callers must transcribe through")
|
||||||
|
}
|
||||||
|
// Nothing answers on port 2, so the seam is the floor until it does.
|
||||||
|
if pair.Available() {
|
||||||
|
t.Fatal("an unreachable workstation must not be available")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// mavend/telegramintake.go — wiring the inbound telegram poller (V-637).
|
||||||
|
//
|
||||||
|
// The poller reaches the daemon through ipc.CoreAPI and nothing else, so a
|
||||||
|
// telegram turn takes exactly the path the web's POST /api/chat takes: Chat
|
||||||
|
// returns the reply and the persisted trace id, and CorrectTurn writes the
|
||||||
|
// label. Nothing in internal/delivery knows what a handler is.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/config"
|
||||||
|
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wireTelegramIntake starts the poller, or returns having done nothing. It is
|
||||||
|
// nil-safe in every argument, because it is called from both boot paths — the
|
||||||
|
// unlocked start and the passkey unlock — and telegram must behave the same on
|
||||||
|
// either.
|
||||||
|
//
|
||||||
|
// A sink that will not build is logged rather than fatal here. The push half
|
||||||
|
// already failed the boot in wireDispatcher for the same config, so a second
|
||||||
|
// hard failure would only lose that message.
|
||||||
|
func wireTelegramIntake(ctx context.Context, wg *sync.WaitGroup, api ipc.CoreAPI, cfg *config.Config) {
|
||||||
|
if cfg == nil || cfg.Telegram == nil || !cfg.Telegram.Intake || api == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sink, err := telegramsink.New(*cfg.Telegram)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("telegram intake: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
poller, err := telegramsink.NewPoller(sink, chatTurnFn(api), api.CorrectTurn)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("telegram intake: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
poller.Run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatTurnFn adapts ipc.Chat to the poller's Turn. The trace id comes back on
|
||||||
|
// the reply because the daemon's Chat collects it off the context (V-630), so
|
||||||
|
// the chat can offer the same correction the web does without a second op.
|
||||||
|
func chatTurnFn(api ipc.CoreAPI) telegramsink.Turn {
|
||||||
|
return func(ctx context.Context, conversation, text string) (string, int64, error) {
|
||||||
|
reply, err := api.Chat(ctx, conversation, text)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
return reply.Reply, reply.TraceID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-1
@@ -97,10 +97,19 @@ func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.Ch
|
|||||||
return ipc.ChatReply{}, errors.New("mavend: chat not available")
|
return ipc.ChatReply{}, errors.New("mavend: chat not available")
|
||||||
}
|
}
|
||||||
ctx, sink := withQuerySourceSink(ctx)
|
ctx, sink := withQuerySourceSink(ctx)
|
||||||
|
// The trace id rides back the same way (V-630), so /chat can offer a
|
||||||
|
// correction on the turn it is already showing. 0 when nothing persisted.
|
||||||
|
ctx, traces := withTraceIDSink(ctx)
|
||||||
reply := d.chatFn(ctx, conversation, text)
|
reply := d.chatFn(ctx, conversation, text)
|
||||||
return ipc.ChatReply{Reply: reply, Source: sink.Name()}, nil
|
return ipc.ChatReply{Reply: reply, Source: sink.Name(), TraceID: traces.ID()}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CorrectTurn is NOT overridden here, and that is deliberate (V-630). Every other
|
||||||
|
// diagnostic on this type exists because the daemon holds something the store
|
||||||
|
// cannot answer from a table. A correction is a table, so the embedded store
|
||||||
|
// adapter is already the right answer and a second implementation here would be
|
||||||
|
// a second place for it to drift.
|
||||||
|
|
||||||
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
// MCPServers — the configured MCP servers and their health (Vikunja #251).
|
||||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||||
// default state and the web surface renders it as such.
|
// default state and the web surface renders it as such.
|
||||||
|
|||||||
@@ -186,6 +186,28 @@ func carriesReminderVerb(text string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isPleasantry matches the WHOLE utterance against lexicon.Pleasantries, after
|
||||||
|
// lowercasing and dropping the punctuation a greeting carries.
|
||||||
|
//
|
||||||
|
// Whole utterance and not tokens. Every token rule tried here was wrong on
|
||||||
|
// something: "вечер" answers "это утра или вечера?", "нет" answers a confirm,
|
||||||
|
// and "спокойной" alone is not an utterance at all. A greeting is a fixed
|
||||||
|
// phrase, so matching it as one costs nothing and claims nothing else.
|
||||||
|
func isPleasantry(text string) bool {
|
||||||
|
t := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
t = strings.Trim(t, " .,!?…")
|
||||||
|
t = strings.Join(strings.Fields(t), " ")
|
||||||
|
if t == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range lexicon.Pleasantries() {
|
||||||
|
if t == p {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// offlineOwnRequest is the shape half of the evidence: the offline token tests,
|
// offlineOwnRequest is the shape half of the evidence: the offline token tests,
|
||||||
// which cost nothing and never depend on the model that produced the routing.
|
// which cost nothing and never depend on the model that produced the routing.
|
||||||
// It is also the whole answer when there is no route to read — the classifier
|
// It is also the whole answer when there is no route to read — the classifier
|
||||||
@@ -225,6 +247,16 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.
|
|||||||
// hour, and no route saying "question" changes that. It works because the
|
// hour, and no route saying "question" changes that. It works because the
|
||||||
// extractor no longer reads a day word as the current clock, so a sentence
|
// extractor no longer reads a day word as the current clock, so a sentence
|
||||||
// that names no hour now fills nothing to weigh.
|
// that names no hour now fills nothing to weigh.
|
||||||
|
// A pleasantry is neither (V-663). "спасибо" and "привет" fell through to
|
||||||
|
// roleAnswer, so a question about a reminder's DAY was re-asked at a man
|
||||||
|
// saying thank you, and the retry it spent was one of the three bounds
|
||||||
|
// meant to end the ride. It is an aside: answered as itself, the question
|
||||||
|
// resumed on the tail, no attempt spent, one ride counted. Placed above the
|
||||||
|
// content gate because "доброе утро" has content and states nothing, so
|
||||||
|
// neither half of the evidence below can reach it.
|
||||||
|
if q != nil && isPleasantry(text) {
|
||||||
|
return roleAside
|
||||||
|
}
|
||||||
own := false
|
own := false
|
||||||
if len(ownContent(text)) > 0 {
|
if len(ownContent(text)) > 0 {
|
||||||
own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text))
|
own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text))
|
||||||
|
|||||||
@@ -279,3 +279,162 @@ func TestTheTurnIsRoutedOnce(t *testing.T) {
|
|||||||
t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first)
|
t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestASuspendedQuestionDoesNotRideForever — V-654, the measured failure of
|
||||||
|
// 2026-08-07 (docs/evals/2026-08-07-week-of-usage-transcript.md, t=51 to t=58).
|
||||||
|
//
|
||||||
|
// A side query suspends the parked question, spends no attempt and restarts the
|
||||||
|
// TTL. Nothing else bounded it, so one unfilled time slot came back on the end
|
||||||
|
// of six consecutive unrelated replies and stopped only when a seventh turn
|
||||||
|
// happened to read as a failed answer. Three step-asides, then she lets it go
|
||||||
|
// and says so.
|
||||||
|
func TestASuspendedQuestionDoesNotRideForever(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, st := newRoutingClarifyHandler(t)
|
||||||
|
id := dialogueIDFor(sourceText, "web")
|
||||||
|
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||||
|
|
||||||
|
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||||||
|
t.Fatalf("expected the time question, got %q", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three questions of his own. Each one is answered as itself and each one
|
||||||
|
// brings the open question back, exactly as V-561 asks.
|
||||||
|
asides := []string{
|
||||||
|
"о чём мы вчера говорили?",
|
||||||
|
"какие у меня напоминания?",
|
||||||
|
"сколько времени?",
|
||||||
|
}
|
||||||
|
for i, text := range asides {
|
||||||
|
reply := h.handleText(ctx, "web", text)
|
||||||
|
if !strings.HasSuffix(reply, resumed) {
|
||||||
|
t.Fatalf("side query %d: the question must come back, got %q", i+1, reply)
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, clarifyDropped) {
|
||||||
|
t.Fatalf("side query %d: nothing was let go yet, so nothing may say so: %q", i+1, reply)
|
||||||
|
}
|
||||||
|
q := h.clarifyStore.Get(id, h.now())
|
||||||
|
if q == nil {
|
||||||
|
t.Fatalf("side query %d: the question was dropped early", i+1)
|
||||||
|
}
|
||||||
|
if q.Attempts != 1 {
|
||||||
|
t.Fatalf("side query %d: a step-aside spent an attempt: %d", i+1, q.Attempts)
|
||||||
|
}
|
||||||
|
if q.Suspends != i+1 {
|
||||||
|
t.Fatalf("side query %d: suspends = %d, want %d", i+1, q.Suspends, i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The fourth. She has stepped aside as often as she is willing to, so the
|
||||||
|
// request goes — out loud, and without the question on the tail.
|
||||||
|
reply := h.handleText(ctx, "web", "что у меня сегодня?")
|
||||||
|
if !strings.Contains(reply, clarifyDropped) {
|
||||||
|
t.Fatalf("the request was let go in silence: %q", reply)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(reply, resumed) {
|
||||||
|
t.Fatalf("a question she has let go must not be asked again: %q", reply)
|
||||||
|
}
|
||||||
|
if h.clarifyStore.Get(id, h.now()) != nil {
|
||||||
|
t.Fatal("the question must be gone once she has said she let it go")
|
||||||
|
}
|
||||||
|
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||||
|
t.Fatalf("a reminder was invented for a time nobody gave: %v err=%v", reminders, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAnAnsweredGapResetsTheSuspendBudget — the counter measures CONSECUTIVE
|
||||||
|
// step-asides. He filled a gap, so the run is broken and the next question
|
||||||
|
// starts with its full allowance: a long exchange he is engaged with must not
|
||||||
|
// run out of patience on his behalf.
|
||||||
|
func TestAnAnsweredGapResetsTheSuspendBudget(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, _ := newRoutingClarifyHandler(t)
|
||||||
|
id := dialogueIDFor(sourceText, "web")
|
||||||
|
|
||||||
|
// A bare "напомни" is missing both halves, so answering the subject re-parks
|
||||||
|
// the request with a question about the time.
|
||||||
|
if reply := h.handleText(ctx, "web", "напомни"); !strings.Contains(reply, "?") {
|
||||||
|
t.Fatalf("expected a question, got %q", reply)
|
||||||
|
}
|
||||||
|
if reply := h.handleText(ctx, "web", "какие у меня напоминания?"); reply == "" {
|
||||||
|
t.Fatal("the side query must be answered as itself")
|
||||||
|
}
|
||||||
|
if q := h.clarifyStore.Get(id, h.now()); q == nil || q.Suspends != 1 {
|
||||||
|
t.Fatalf("the side query was not counted: %+v", q)
|
||||||
|
}
|
||||||
|
if reply := h.handleText(ctx, "web", "позвонить маме"); reply == "" {
|
||||||
|
t.Fatal("the answer must be consumed")
|
||||||
|
}
|
||||||
|
q := h.clarifyStore.Get(id, h.now())
|
||||||
|
if q == nil {
|
||||||
|
t.Fatal("a reminder still needs its time, so a question must be parked")
|
||||||
|
}
|
||||||
|
if q.Suspends != 0 {
|
||||||
|
t.Fatalf("answering a gap must reset the suspend budget: suspends = %d", q.Suspends)
|
||||||
|
}
|
||||||
|
// The ride it already took is carried across the re-park (V-663). Resetting
|
||||||
|
// both counters here is what let one question ride twenty-six replies.
|
||||||
|
if q.Rides != 1 {
|
||||||
|
t.Fatalf("the aside it already took was forgotten: rides = %d", q.Rides)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTwoBoundsCannotRearmEachOther — V-663.
|
||||||
|
//
|
||||||
|
// MaxSuspends landed and the measurement did not move: twenty-six of 140 turns
|
||||||
|
// carried a tail before it and twenty-six after. This is the shape it misses,
|
||||||
|
// taken from the 2026-08-08 run, where one question rode turns 7 to 13.
|
||||||
|
//
|
||||||
|
// An aside spends no attempt, so MaxAttempts never reaches it. A turn that
|
||||||
|
// reads as a failed answer zeroes Suspends, so MaxSuspends never reaches the
|
||||||
|
// asides either. Alternating the two rearms each bound with the other's
|
||||||
|
// traffic. Rides counts both kinds and is never reset, so it is what ends this.
|
||||||
|
func TestTwoBoundsCannotRearmEachOther(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
h, _ := newRoutingClarifyHandler(t)
|
||||||
|
id := dialogueIDFor(sourceText, "web")
|
||||||
|
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||||
|
|
||||||
|
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||||||
|
t.Fatalf("expected the time question, got %q", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two asides. Each one rides and neither spends an attempt.
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
reply := h.handleText(ctx, "web", "какие у меня напоминания?")
|
||||||
|
if !strings.HasSuffix(reply, resumed) {
|
||||||
|
t.Fatalf("aside %d: the question must come back, got %q", i+1, reply)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
q := h.clarifyStore.Get(id, h.now())
|
||||||
|
if q == nil || q.Rides != 2 || q.Suspends != 2 {
|
||||||
|
t.Fatalf("after two asides: %+v", q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pleasantry. It used to read as a failed answer, so she re-asked the
|
||||||
|
// question at a man saying thank you and spent an attempt doing it. Now it
|
||||||
|
// is an aside: answered as itself, question on the tail, one more ride.
|
||||||
|
reply := h.handleText(ctx, "web", "спасибо")
|
||||||
|
if !strings.HasSuffix(reply, resumed) {
|
||||||
|
t.Fatalf("a pleasantry lost the parked question: %q", reply)
|
||||||
|
}
|
||||||
|
q = h.clarifyStore.Get(id, h.now())
|
||||||
|
if q == nil || q.Attempts != 1 {
|
||||||
|
t.Fatalf("a pleasantry spent an attempt: %+v", q)
|
||||||
|
}
|
||||||
|
if q.Rides != 3 {
|
||||||
|
t.Fatalf("a pleasantry rode free: %+v", q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// One more ride of any kind and the request goes, out loud.
|
||||||
|
reply = h.handleText(ctx, "web", "какие у меня напоминания?")
|
||||||
|
if !strings.Contains(reply, clarifyDropped) {
|
||||||
|
t.Fatalf("the question rode four asides and was let go in silence: %q", reply)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(reply, resumed) {
|
||||||
|
t.Fatalf("a question she has let go must not be asked again: %q", reply)
|
||||||
|
}
|
||||||
|
if h.clarifyStore.Get(id, h.now()) != nil {
|
||||||
|
t.Fatal("the question must be gone once she has said she let it go")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+25
-2
@@ -145,6 +145,17 @@ type reactiveHandler struct {
|
|||||||
// is recorded, which is what a test that did not ask for one gets.
|
// is recorded, which is what a test that did not ask for one gets.
|
||||||
decisions *decision.Ring
|
decisions *decision.Ring
|
||||||
|
|
||||||
|
// traces persists those same records (V-629, routingtrace.go). The ring is
|
||||||
|
// still what /trace reads; this is the second sink, and it exists because the
|
||||||
|
// routing heads cannot be fitted without real utterances. nil ⇒ the ring
|
||||||
|
// alone, which is the behaviour every box had before 06-08-2026.
|
||||||
|
traces traceWriter
|
||||||
|
|
||||||
|
// encoderID names the encoder body live on this box, stored beside each
|
||||||
|
// trace: a fitted distance means nothing under another body. Empty ⇒ no
|
||||||
|
// embedder, so the classifier was the keyword floor.
|
||||||
|
encoderID string
|
||||||
|
|
||||||
// clarifyStore parks the request behind an open question she asked (see
|
// clarifyStore parks the request behind an open question she asked (see
|
||||||
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
|
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
|
||||||
clarifyStore *dialogue.ClarifyStore
|
clarifyStore *dialogue.ClarifyStore
|
||||||
@@ -265,7 +276,11 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
var rec *decision.Record
|
var rec *decision.Record
|
||||||
ctx, rec = decision.With(ctx, text)
|
ctx, rec = decision.With(ctx, text)
|
||||||
decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
|
decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
|
||||||
defer func() { h.decisions.Push(rec.Finish(h.now())) }()
|
defer func() {
|
||||||
|
done := rec.Finish(h.now())
|
||||||
|
h.decisions.Push(done)
|
||||||
|
h.persistDecision(ctx, done, src)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0b. the turn's routing, computed at most once and shared (Vikunja #560).
|
// 0b. the turn's routing, computed at most once and shared (Vikunja #560).
|
||||||
@@ -350,6 +365,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
return withNotice(expiredNotice, reply)
|
return withNotice(expiredNotice, reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4d-ii. and the same correction without a target — "нет, не так" (V-636).
|
||||||
|
// After the targeted one, which is the narrower claim: an utterance that
|
||||||
|
// names an intent is answered by redoing the request, and this rung only
|
||||||
|
// gets the ones that name nothing.
|
||||||
|
if reply, handled := h.resolveUntargetedRepair(ctx, text); notePreRoute(ctx, "repair-negative", handled) {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
|
|
||||||
// 4e. ordinal selection — "второй", "первую сделал" pick from the list she
|
// 4e. ordinal selection — "второй", "первую сделал" pick from the list she
|
||||||
// just read (ordinal.go). Before routing, and only when a list is actually
|
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||||
// bound to the session: with nothing offered, "второй" is an ordinary word
|
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||||||
@@ -435,7 +458,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
|
|
||||||
// 9. replier — phrase the reply across the router decision.
|
// 9. replier — phrase the reply across the router decision.
|
||||||
if replyText == "" {
|
if replyText == "" {
|
||||||
replyText = h.replier.Reply(dec)
|
replyText = h.replier.Reply(ctx, dec)
|
||||||
}
|
}
|
||||||
return withNotice(expiredNotice, replyText)
|
return withNotice(expiredNotice, replyText)
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-6
@@ -36,7 +36,9 @@ type voiceWiring struct {
|
|||||||
sessions *voice.Sessions
|
sessions *voice.Sessions
|
||||||
voiceSink delivery.Sink
|
voiceSink delivery.Sink
|
||||||
embedder router.Embedder
|
embedder router.Embedder
|
||||||
handler *reactiveHandler // the reactive handler for IPC Chat
|
// heads — the routing heads, nil unless embedder.heads_path is set.
|
||||||
|
heads *router.RouterHeads
|
||||||
|
handler *reactiveHandler // the reactive handler for IPC Chat
|
||||||
// worker clients (set when configured as Remote): closed on shutdown so
|
// worker clients (set when configured as Remote): closed on shutdown so
|
||||||
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
|
||||||
sttClient *worker.Client
|
sttClient *worker.Client
|
||||||
@@ -53,7 +55,11 @@ type voiceWiring struct {
|
|||||||
// unless a `workstation` block names an address. Held here only so the
|
// unless a `workstation` block names an address. Held here only so the
|
||||||
// prober is stopped on shutdown; callers were handed it at build time.
|
// prober is stopped on shutdown; callers were handed it at build time.
|
||||||
pair *llm.Pair
|
pair *llm.Pair
|
||||||
mcp *mcpWiring
|
// sttPair — CrisperWhisper 2.0 on the workstation with mavsttd as the
|
||||||
|
// floor, nil unless the `workstation.stt` block names an address. Held for
|
||||||
|
// the same reason as pair: to stop its prober on shutdown.
|
||||||
|
sttPair *stt.Pair
|
||||||
|
mcp *mcpWiring
|
||||||
// home — the Home Assistant client, nil unless the `smarthome` block is
|
// home — the Home Assistant client, nil unless the `smarthome` block is
|
||||||
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
// enabled (Vikunja #256). Its devices land in the same allowlist as every
|
||||||
// other act, so nothing else here has to know about it.
|
// other act, so nothing else here has to know about it.
|
||||||
@@ -72,6 +78,9 @@ func (w *voiceWiring) close() {
|
|||||||
if w.embedder != nil {
|
if w.embedder != nil {
|
||||||
_ = w.embedder.Close()
|
_ = w.embedder.Close()
|
||||||
}
|
}
|
||||||
|
if w.heads != nil {
|
||||||
|
_ = w.heads.Close()
|
||||||
|
}
|
||||||
if w.server != nil {
|
if w.server != nil {
|
||||||
_ = w.server.Close()
|
_ = w.server.Close()
|
||||||
}
|
}
|
||||||
@@ -84,6 +93,9 @@ func (w *voiceWiring) close() {
|
|||||||
if w.pair != nil {
|
if w.pair != nil {
|
||||||
w.pair.Stop()
|
w.pair.Stop()
|
||||||
}
|
}
|
||||||
|
if w.sttPair != nil {
|
||||||
|
w.sttPair.Stop()
|
||||||
|
}
|
||||||
w.mcp.close()
|
w.mcp.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +124,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
} else {
|
} else {
|
||||||
transcriber = stt.NewStub()
|
transcriber = stt.NewStub()
|
||||||
}
|
}
|
||||||
|
transcriber, w.sttPair = sttSeam(cfg, transcriber)
|
||||||
w.transcriber = transcriber
|
w.transcriber = transcriber
|
||||||
|
|
||||||
// ----- tts (Stub in-process OR Remote) -----
|
// ----- tts (Stub in-process OR Remote) -----
|
||||||
@@ -147,8 +160,29 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
emb = router.NewHashEmbedder(1024)
|
emb = router.NewHashEmbedder(1024)
|
||||||
}
|
}
|
||||||
w.embedder = emb
|
w.embedder = emb
|
||||||
|
|
||||||
|
// ----- router: routing heads (only when configured, and never fatal) -----
|
||||||
|
// A missing or broken weights file logs and leaves w.heads nil, which is
|
||||||
|
// byte-for-byte the cascade that shipped before V-664. Refusing to start
|
||||||
|
// over a routing accelerator would trade a working box for a better one.
|
||||||
|
if cfg.Voice.Embedder != nil && cfg.Voice.Embedder.HeadsPath != "" {
|
||||||
|
h, err := router.NewRouterHeads(
|
||||||
|
cfg.Voice.Embedder.HeadsPath,
|
||||||
|
cfg.Voice.Embedder.TokenizerPath,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("voice: routing heads unavailable, cascade unchanged: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("voice: routing heads loaded from %s", cfg.Voice.Embedder.HeadsPath)
|
||||||
|
w.heads = h
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
repairFactVectors(dataStore, emb)
|
repairFactVectors(dataStore, emb)
|
||||||
checkStoredEmbedder(dataStore, emb)
|
checkStoredEmbedder(dataStore, emb)
|
||||||
|
// Retention is enforced on write, which is not enough on its own: a box that
|
||||||
|
// goes quiet keeps every trace until the next sixty-fourth turn (V-629).
|
||||||
|
pruneTracesOnStart(dataStore, time.Now())
|
||||||
|
|
||||||
// ----- tool executor (the enabled act allowlist, store-backed) -----
|
// ----- tool executor (the enabled act allowlist, store-backed) -----
|
||||||
// Config tools are the declarative bootstrap: seed them into the store as
|
// Config tools are the declarative bootstrap: seed them into the store as
|
||||||
@@ -176,7 +210,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
||||||
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
||||||
w.netscan = wireNetScan(cfg, coreAPI)
|
w.netscan = wireNetScan(cfg, coreAPI)
|
||||||
matcher := tool.NewMatcher(coreAPI)
|
matcher := tool.NewMatcher(coreAPI).WithAliases(toolAliases(cfg.Voice.Tools))
|
||||||
|
|
||||||
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
|
||||||
var weatherProvider weather.Provider
|
var weatherProvider weather.Provider
|
||||||
@@ -220,7 +254,8 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// against the classifier's 50.0%, at about 1s a turn instead of 30ms (see
|
// against the classifier's 50.0%, at about 1s a turn instead of 30ms (see
|
||||||
// config.VoiceConfig.LLMRouter). The classifier always stays wired as the
|
// config.VoiceConfig.LLMRouter). The classifier always stays wired as the
|
||||||
// fallback, so a model error never breaks a turn.
|
// fallback, so a model error never breaks a turn.
|
||||||
rtr := buildRouter(emb, matcher, threshold, pickLLMRouter(cfg.Voice.UseLLMRouter(), hot))
|
rtr := buildRouter(emb, matcher, threshold,
|
||||||
|
pickLLMRouter(cfg.Voice.UseLLMRouter(), hot), w.heads)
|
||||||
|
|
||||||
// ----- sessions registry (shared with voicesink) -----
|
// ----- sessions registry (shared with voicesink) -----
|
||||||
sessions := voice.NewSessions()
|
sessions := voice.NewSessions()
|
||||||
@@ -298,7 +333,13 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
|||||||
// Always on (V-564). The record is the instrument the rest of V-558 is
|
// Always on (V-564). The record is the instrument the rest of V-558 is
|
||||||
// measured with, and one that only runs when a flag is set is not there
|
// measured with, and one that only runs when a flag is set is not there
|
||||||
// on the night the misroute happens.
|
// on the night the misroute happens.
|
||||||
decisions: decision.NewRing(),
|
decisions: decision.NewRing(),
|
||||||
|
// The second sink (V-629). Same records, persisted, because the routing
|
||||||
|
// heads cannot be fitted from a 25-turn ring. Nil store ⇒ ring only, and
|
||||||
|
// EmbedderID is the same string the vector marker uses, so a trace and a
|
||||||
|
// stored vector name their body the same way.
|
||||||
|
traces: traceSink(dataStore),
|
||||||
|
encoderID: router.EmbedderID(emb),
|
||||||
clarifyStore: clarifyStore,
|
clarifyStore: clarifyStore,
|
||||||
// 0 here (unset config) ⇒ the dialogue default.
|
// 0 here (unset config) ⇒ the dialogue default.
|
||||||
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
||||||
@@ -357,6 +398,44 @@ func modelSeam(cfg *config.Config, resident *llm.Client) (router.Completer, *llm
|
|||||||
return pair, pair
|
return pair, pair
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sttSeam builds the transcription seam the voice path and the meeting
|
||||||
|
// recorder share. It is modelSeam for audio and follows the same rule.
|
||||||
|
//
|
||||||
|
// With no `workstation.stt` block it hands back the floor untouched, which is
|
||||||
|
// today's deploy exactly. With one, it is an stt.Pair preferring CrisperWhisper
|
||||||
|
// 2.0 on workpc, which scores 10.4% WER in Russian against the floor's 27.5%
|
||||||
|
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
|
||||||
|
//
|
||||||
|
// Only the silent half of the degradation rule applies here. A worse transcript
|
||||||
|
// is still a turn, so there is nothing to name a gap about and the fallback is
|
||||||
|
// never spoken. That is why stt.Pair has no TranscribeRemote.
|
||||||
|
func sttSeam(cfg *config.Config, floor stt.Transcriber) (stt.Transcriber, *stt.Pair) {
|
||||||
|
if cfg.Workstation == nil || cfg.Workstation.Stt == nil {
|
||||||
|
return floor, nil
|
||||||
|
}
|
||||||
|
s := cfg.Workstation.Stt
|
||||||
|
lang := ""
|
||||||
|
if cfg.Voice != nil {
|
||||||
|
lang = cfg.Voice.Lang
|
||||||
|
if cfg.Voice.Stt != nil && cfg.Voice.Stt.Lang != "" {
|
||||||
|
lang = cfg.Voice.Stt.Lang
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pair := stt.NewPair(
|
||||||
|
stt.NewHTTPTranscriber(s.URL, s.Token, lang, time.Duration(s.Timeout)),
|
||||||
|
floor,
|
||||||
|
s.Health,
|
||||||
|
time.Duration(s.Probe),
|
||||||
|
)
|
||||||
|
pair.Start(context.Background())
|
||||||
|
if s.Token == "" {
|
||||||
|
log.Print("voice: the workstation transcriber has no token, so anything on the LAN can post audio to it")
|
||||||
|
}
|
||||||
|
log.Printf("voice: workstation transcriber at %s, probed every %s, mavsttd as the floor",
|
||||||
|
s.URL, time.Duration(s.Probe))
|
||||||
|
return pair, pair
|
||||||
|
}
|
||||||
|
|
||||||
func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
|
func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return nil
|
return nil
|
||||||
@@ -381,7 +460,8 @@ func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
|
|||||||
// intent from seedDir (models/seeds/<intent>.txt) — see seedClassifier
|
// intent from seedDir (models/seeds/<intent>.txt) — see seedClassifier
|
||||||
// below for the current intent list and file names.
|
// below for the current intent list and file names.
|
||||||
// - Threshold is from voice.router_threshold config (default 0.55).
|
// - Threshold is from voice.router_threshold config (default 0.55).
|
||||||
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, llmR *router.LLMRouter) *router.Router {
|
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||||
|
llmR *router.LLMRouter, heads *router.RouterHeads) *router.Router {
|
||||||
cls := router.NewClassifier(emb)
|
cls := router.NewClassifier(emb)
|
||||||
seedClassifier(cls)
|
seedClassifier(cls)
|
||||||
grammars := router.DefaultGrammars(acts)
|
grammars := router.DefaultGrammars(acts)
|
||||||
@@ -392,6 +472,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
||||||
// routed system and answered "пока не умею" (Vikunja #474).
|
// routed system and answered "пока не умею" (Vikunja #474).
|
||||||
|
// After the agenda rules, which are the narrower claim, and BEFORE the feed
|
||||||
|
// and list rules, which are not: "что такое лента" is a definition question
|
||||||
|
// and the feed rule would take it on the noun alone (V-655).
|
||||||
|
grammars = append(grammars, router.WorldQueryGrammars()...)
|
||||||
grammars = append(grammars, router.FeedQueryGrammar())
|
grammars = append(grammars, router.FeedQueryGrammar())
|
||||||
// The list side of the same exposure: a phrasing with no possessive in it
|
// The list side of the same exposure: a phrasing with no possessive in it
|
||||||
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||||
@@ -429,6 +513,7 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
},
|
},
|
||||||
Threshold: threshold,
|
Threshold: threshold,
|
||||||
LLM: llmR,
|
LLM: llmR,
|
||||||
|
Heads: heads,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +600,20 @@ func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
|
|||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toolAliases collects the spoken phrases per tool name. Without them the act
|
||||||
|
// matcher only ever matched the English tool name, so no Russian utterance could
|
||||||
|
// reach a tool and every homelab act fell to proposeGap (V-633).
|
||||||
|
func toolAliases(tools []config.ToolConfig) map[string][]string {
|
||||||
|
out := make(map[string][]string, len(tools))
|
||||||
|
for _, tc := range tools {
|
||||||
|
if tc.Name == "" || len(tc.Aliases) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[tc.Name] = tc.Aliases
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// seedTools upserts the config-declared tools into the store as enabled. Editing
|
// seedTools upserts the config-declared tools into the store as enabled. Editing
|
||||||
// mavend.json is a human act, so a config tool is enabled by definition; this
|
// mavend.json is a human act, so a config tool is enabled by definition; this
|
||||||
// makes the declarative config the reproducible bootstrap while the store stays
|
// makes the declarative config the reproducible bootstrap while the store stays
|
||||||
|
|||||||
+13
-4
@@ -34,22 +34,31 @@ type probe struct {
|
|||||||
drmDev string
|
drmDev string
|
||||||
}
|
}
|
||||||
|
|
||||||
// foreign lists every ROCm process that is not ours. selfPID is the supervisor's
|
// foreign lists every ROCm process that is not ours. self holds the pids of the
|
||||||
// llama-server child, or 0 when it is not running.
|
// supervisor's own children, and a child that is not running contributes 0.
|
||||||
|
//
|
||||||
|
// There is more than one child since 09-08-2026. CW2 registers on the KFD like
|
||||||
|
// any ROCm job, so a supervisor that excluded only llama-server would read its
|
||||||
|
// own transcriber as a contender, yield the card to it, and never keep a model
|
||||||
|
// loaded again.
|
||||||
//
|
//
|
||||||
// An unreadable kfd tree returns no processes and no error. That is deliberate
|
// An unreadable kfd tree returns no processes and no error. That is deliberate
|
||||||
// and it is the safe direction only because startVRAM also has to agree before
|
// and it is the safe direction only because startVRAM also has to agree before
|
||||||
// anything launches: a supervisor that cannot see the KFD never sees free VRAM
|
// anything launches: a supervisor that cannot see the KFD never sees free VRAM
|
||||||
// either, because the CPT run holding the card shows up in the drm totals.
|
// either, because the CPT run holding the card shows up in the drm totals.
|
||||||
func (p probe) foreign(selfPID int) []gpuProc {
|
func (p probe) foreign(self ...int) []gpuProc {
|
||||||
entries, err := os.ReadDir(p.kfdRoot)
|
entries, err := os.ReadDir(p.kfdRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
mine := make(map[int]bool, len(self))
|
||||||
|
for _, pid := range self {
|
||||||
|
mine[pid] = true
|
||||||
|
}
|
||||||
var out []gpuProc
|
var out []gpuProc
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
pid, err := strconv.Atoi(e.Name())
|
pid, err := strconv.Atoi(e.Name())
|
||||||
if err != nil || pid == selfPID {
|
if err != nil || mine[pid] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, gpuProc{
|
out = append(out, gpuProc{
|
||||||
|
|||||||
+46
-1
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeKFD builds the sysfs shape the workstation actually has: one directory
|
// fakeKFD builds the sysfs shape the workstation actually has: one directory
|
||||||
@@ -47,6 +49,24 @@ func TestForeignExcludesOurChild(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The transcriber is a ROCm process on the same card, so it registers on the
|
||||||
|
// KFD exactly like a contender does. Reading it as one is what happened on
|
||||||
|
// 2026-08-09 while CW2 ran under its own systemd unit: mavgpud yielded, waited
|
||||||
|
// five polls, loaded the model, yielded again, and never held it for a whole
|
||||||
|
// minute. Excluding every child is the fix and this is the test of it.
|
||||||
|
func TestForeignExcludesEveryChild(t *testing.T) {
|
||||||
|
p := probe{kfdRoot: fakeKFD(t, map[int]int64{478104: 12791693312, 999: 4096, 1001: 1717986918})}
|
||||||
|
|
||||||
|
ours := p.foreign(999, 1001)
|
||||||
|
if len(ours) != 1 || ours[0].PID != 478104 {
|
||||||
|
t.Fatalf("only the CPT run is a contender, got %+v", ours)
|
||||||
|
}
|
||||||
|
// A child that is not running reports pid 0, which must exclude nothing.
|
||||||
|
if got := p.foreign(999, 0); len(got) != 2 {
|
||||||
|
t.Errorf("a stopped child excludes nobody: got %d contenders, want 2", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// An empty KFD tree is the state that permits a start, so it must read as empty
|
// An empty KFD tree is the state that permits a start, so it must read as empty
|
||||||
// rather than as an error the caller has to interpret.
|
// rather than as an error the caller has to interpret.
|
||||||
func TestForeignEmptyAndMissing(t *testing.T) {
|
func TestForeignEmptyAndMissing(t *testing.T) {
|
||||||
@@ -81,7 +101,7 @@ func TestFreeVRAM(t *testing.T) {
|
|||||||
// rather than hanging or proxying into a closed port. Maven reads this endpoint
|
// rather than hanging or proxying into a closed port. Maven reads this endpoint
|
||||||
// on a timer forever, including while the workstation is busy.
|
// on a timer forever, including while the workstation is busy.
|
||||||
func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) {
|
func TestHealthAndProxyRefuseWhenNotReady(t *testing.T) {
|
||||||
s := &supervisor{run: newRunner("/bin/true", nil, "")}
|
s := &supervisor{run: newRunner("fake", "/bin/true", nil, "")}
|
||||||
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
|
h := s.handler(mustURL(t, "http://127.0.0.1:1"))
|
||||||
|
|
||||||
for _, path := range []string{"/health", "/v1/chat/completions"} {
|
for _, path := range []string{"/health", "/v1/chat/completions"} {
|
||||||
@@ -101,3 +121,28 @@ func mustURL(t *testing.T, s string) *url.URL {
|
|||||||
}
|
}
|
||||||
return u
|
return u
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Yielding is all or nothing. A CPT run wants the whole card, so handing back
|
||||||
|
// the language model while the transcriber keeps 1.6GB mapped would leave the
|
||||||
|
// other job failing its allocation, which is the outcome yielding exists to
|
||||||
|
// prevent.
|
||||||
|
func TestYieldStopsEveryChild(t *testing.T) {
|
||||||
|
idle := "while : ; do sleep 1 ; done"
|
||||||
|
s := &supervisor{
|
||||||
|
cfg: config{EvictAfter: 1, StopGrace: duration(2 * time.Second)},
|
||||||
|
probe: probe{kfdRoot: fakeKFD(t, map[int]int64{478104: 12791693312})},
|
||||||
|
run: newRunner("llama-server", fakeServer(t, idle), nil, ""),
|
||||||
|
stt: newRunner("cw2", fakeServer(t, idle), nil, ""),
|
||||||
|
}
|
||||||
|
for _, r := range s.children() {
|
||||||
|
if err := r.start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.tick(context.Background())
|
||||||
|
for _, r := range s.children() {
|
||||||
|
if r.running() {
|
||||||
|
t.Errorf("%s outlived the yield", r.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+90
-17
@@ -10,6 +10,11 @@
|
|||||||
// the card. Not on demand, because a 7-14B takes tens of seconds to load and a
|
// the card. Not on demand, because a 7-14B takes tens of seconds to load and a
|
||||||
// world question would be answered by a gap every time the card had been quiet.
|
// world question would be answered by a gap every time the card had been quiet.
|
||||||
// Not always on, because that holds 16GB against the owner's own jobs.
|
// Not always on, because that holds 16GB against the owner's own jobs.
|
||||||
|
//
|
||||||
|
// It supervises a second child since 09-08-2026, the CW2 transcriber, and for
|
||||||
|
// one reason only: it is a ROCm process on the same card. Any GPU service the
|
||||||
|
// owner leaves running beside this daemon reads as a contender and evicts the
|
||||||
|
// model, so the card needs one owner rather than two neighbours.
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -36,6 +41,10 @@ type config struct {
|
|||||||
// owner's business and not this daemon's schema.
|
// owner's business and not this daemon's schema.
|
||||||
LlamaArgs []string `json:"llama_args"`
|
LlamaArgs []string `json:"llama_args"`
|
||||||
|
|
||||||
|
// Stt is optional. Without it mavgpud supervises llama-server alone, which
|
||||||
|
// is everything it did before 09-08-2026.
|
||||||
|
Stt *sttConfig `json:"stt,omitempty"`
|
||||||
|
|
||||||
KFDRoot string `json:"kfd_root"`
|
KFDRoot string `json:"kfd_root"`
|
||||||
DRMDevice string `json:"drm_device"`
|
DRMDevice string `json:"drm_device"`
|
||||||
|
|
||||||
@@ -51,6 +60,22 @@ type config struct {
|
|||||||
StartAfter int `json:"start_after_polls"`
|
StartAfter int `json:"start_after_polls"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sttConfig is the CW2 transcriber, which mavgpud runs for one reason: it is a
|
||||||
|
// ROCm process on this card. Left to its own systemd unit it registers on the
|
||||||
|
// KFD, the supervisor reads it as a contender, and llama-server is evicted
|
||||||
|
// within two polls and restarted five polls later, forever. That thrash was
|
||||||
|
// observed on 2026-08-09 and it is what folded the service in here.
|
||||||
|
//
|
||||||
|
// Maven talks to it directly, not through this daemon. There is no proxy and no
|
||||||
|
// idle timer: at 1.6GB it denies the card to nobody, and unloading it would only
|
||||||
|
// send the next voice turn to the homesrv floor for no gain.
|
||||||
|
type sttConfig struct {
|
||||||
|
// Addr is where the service binds, and it is read only to probe /health.
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
Bin string `json:"bin"`
|
||||||
|
Args []string `json:"args"`
|
||||||
|
}
|
||||||
|
|
||||||
func defaults() config {
|
func defaults() config {
|
||||||
return config{
|
return config{
|
||||||
Listen: ":8080",
|
Listen: ":8080",
|
||||||
@@ -99,12 +124,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
base := "http://" + cfg.LlamaAddr
|
base := "http://" + cfg.LlamaAddr
|
||||||
run := newRunner(cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
|
run := newRunner("llama-server", cfg.LlamaBin, cfg.LlamaArgs, base+"/health")
|
||||||
sup := &supervisor{
|
sup := &supervisor{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
probe: probe{kfdRoot: cfg.KFDRoot, drmDev: cfg.DRMDevice},
|
probe: probe{kfdRoot: cfg.KFDRoot, drmDev: cfg.DRMDevice},
|
||||||
run: run,
|
run: run,
|
||||||
}
|
}
|
||||||
|
if s := cfg.Stt; s != nil {
|
||||||
|
if s.Bin == "" || s.Addr == "" {
|
||||||
|
log.Fatal("mavgpud: stt needs both bin and addr")
|
||||||
|
}
|
||||||
|
sup.stt = newRunner("cw2", s.Bin, s.Args, "http://"+s.Addr+"/health")
|
||||||
|
}
|
||||||
sup.touch()
|
sup.touch()
|
||||||
|
|
||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
@@ -129,13 +160,17 @@ func main() {
|
|||||||
shut, done := context.WithTimeout(context.Background(), 5*time.Second)
|
shut, done := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer done()
|
defer done()
|
||||||
_ = srv.Shutdown(shut)
|
_ = srv.Shutdown(shut)
|
||||||
run.stop(time.Duration(cfg.StopGrace))
|
for _, r := range sup.children() {
|
||||||
|
r.stop(time.Duration(cfg.StopGrace))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type supervisor struct {
|
type supervisor struct {
|
||||||
cfg config
|
cfg config
|
||||||
probe probe
|
probe probe
|
||||||
run *runner
|
run *runner
|
||||||
|
// stt is the CW2 transcriber, or nil when the config names none.
|
||||||
|
stt *runner
|
||||||
|
|
||||||
lastReq atomic.Int64 // unix nanos of the last request Maven sent
|
lastReq atomic.Int64 // unix nanos of the last request Maven sent
|
||||||
|
|
||||||
@@ -198,7 +233,11 @@ func (s *supervisor) loop(ctx context.Context) {
|
|||||||
// allocates, so we see a contender during its startup rather than after it has
|
// allocates, so we see a contender during its startup rather than after it has
|
||||||
// already failed to get the memory it wanted.
|
// already failed to get the memory it wanted.
|
||||||
func (s *supervisor) tick(ctx context.Context) {
|
func (s *supervisor) tick(ctx context.Context) {
|
||||||
others := s.probe.foreign(s.run.pid())
|
var pids []int
|
||||||
|
for _, r := range s.children() {
|
||||||
|
pids = append(pids, r.pid())
|
||||||
|
}
|
||||||
|
others := s.probe.foreign(pids...)
|
||||||
if len(others) > 0 {
|
if len(others) > 0 {
|
||||||
s.foreignStreak++
|
s.foreignStreak++
|
||||||
s.clearStreak = 0
|
s.clearStreak = 0
|
||||||
@@ -207,31 +246,65 @@ func (s *supervisor) tick(ctx context.Context) {
|
|||||||
s.clearStreak++
|
s.clearStreak++
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.run.running() {
|
// Yielding is all or nothing. A CPT run wants the whole card, and handing
|
||||||
s.run.refreshReady(ctx)
|
// back 8GB while holding 1.6GB is the shape of a failed allocation.
|
||||||
switch {
|
if s.foreignStreak >= s.cfg.EvictAfter && s.anyRunning() {
|
||||||
case s.foreignStreak >= s.cfg.EvictAfter:
|
log.Printf("mavgpud: yielding the card to %s", describe(others))
|
||||||
log.Printf("mavgpud: yielding the card to %s", describe(others))
|
for _, r := range s.children() {
|
||||||
s.run.stop(time.Duration(s.cfg.StopGrace))
|
r.stop(time.Duration(s.cfg.StopGrace))
|
||||||
case s.idle() > time.Duration(s.cfg.IdleTimeout):
|
|
||||||
log.Printf("mavgpud: idle for %s, unloading", s.idle().Round(time.Second))
|
|
||||||
s.run.stop(time.Duration(s.cfg.StopGrace))
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.clearStreak < s.cfg.StartAfter {
|
clear := s.clearStreak >= s.cfg.StartAfter
|
||||||
|
|
||||||
|
if s.run.running() {
|
||||||
|
s.run.refreshReady(ctx)
|
||||||
|
if s.idle() > time.Duration(s.cfg.IdleTimeout) {
|
||||||
|
log.Printf("mavgpud: idle for %s, unloading", s.idle().Round(time.Second))
|
||||||
|
s.run.stop(time.Duration(s.cfg.StopGrace))
|
||||||
|
}
|
||||||
|
} else if clear && s.probe.freeVRAM() >= s.cfg.MinFreeVRAM {
|
||||||
|
s.touch() // the idle clock starts at load, not at the last request before it
|
||||||
|
if err := s.run.start(); err != nil {
|
||||||
|
log.Printf("mavgpud: start llama-server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.stt == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if free := s.probe.freeVRAM(); free < s.cfg.MinFreeVRAM {
|
if s.stt.running() {
|
||||||
|
s.stt.refreshReady(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.touch() // the idle clock starts at load, not at the last request before it
|
// No VRAM precondition here, unlike llama-server. That check exists because
|
||||||
if err := s.run.start(); err != nil {
|
// a 12B refuses to load when the card is short, and 1.6GB fits wherever the
|
||||||
log.Printf("mavgpud: start llama-server: %v", err)
|
// KFD is clear. Reading free VRAM would also block the transcriber for good
|
||||||
|
// once the language model was resident, since it holds more than the floor.
|
||||||
|
if clear {
|
||||||
|
if err := s.stt.start(); err != nil {
|
||||||
|
log.Printf("mavgpud: start cw2: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *supervisor) children() []*runner {
|
||||||
|
if s.stt == nil {
|
||||||
|
return []*runner{s.run}
|
||||||
|
}
|
||||||
|
return []*runner{s.run, s.stt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *supervisor) anyRunning() bool {
|
||||||
|
for _, r := range s.children() {
|
||||||
|
if r.running() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// describe names the contenders in the log. This log is the instrument for the
|
// describe names the contenders in the log. This log is the instrument for the
|
||||||
// open question in #488: whether polling the KFD misses a job that wants the
|
// open question in #488: whether polling the KFD misses a job that wants the
|
||||||
// card without registering there.
|
// card without registering there.
|
||||||
|
|||||||
+17
-13
@@ -10,14 +10,18 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// runner owns one llama-server process. Owning it is the point of the daemon:
|
// runner owns one GPU process. Owning it is the point of the daemon: the
|
||||||
// the workstation cannot keep a 7-14B resident, because that holds 16GB against
|
// workstation cannot keep a 7-14B resident, because that holds 16GB against
|
||||||
// the owner's CPT runs, Correx and the manga-recap pipeline. So the thing that
|
// the owner's CPT runs, Correx and the manga-recap pipeline. So the thing that
|
||||||
// stays up is this, which costs no VRAM, and the model comes and goes under it.
|
// stays up is this, which costs no VRAM, and the model comes and goes under it.
|
||||||
|
//
|
||||||
|
// There are two of them since 09-08-2026: llama-server and the CW2 transcriber.
|
||||||
|
// name is what the log calls this one.
|
||||||
type runner struct {
|
type runner struct {
|
||||||
|
name string
|
||||||
bin string
|
bin string
|
||||||
args []string
|
args []string
|
||||||
// ready is llama-server's own /health, which answers "is a model loaded".
|
// ready is the child's own /health, which answers "is a model loaded".
|
||||||
// Loading a 7-14B takes tens of seconds, so started is not ready.
|
// Loading a 7-14B takes tens of seconds, so started is not ready.
|
||||||
readyURL string
|
readyURL string
|
||||||
|
|
||||||
@@ -32,9 +36,9 @@ type runner struct {
|
|||||||
http *http.Client
|
http *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRunner(bin string, args []string, readyURL string) *runner {
|
func newRunner(name, bin string, args []string, readyURL string) *runner {
|
||||||
return &runner{
|
return &runner{
|
||||||
bin: bin, args: args, readyURL: readyURL,
|
name: name, bin: bin, args: args, readyURL: readyURL,
|
||||||
http: &http.Client{Timeout: 2 * time.Second},
|
http: &http.Client{Timeout: 2 * time.Second},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +64,7 @@ func (r *runner) isReady() bool {
|
|||||||
return r.ready
|
return r.ready
|
||||||
}
|
}
|
||||||
|
|
||||||
// start launches llama-server. It returns as soon as the process exists, not
|
// start launches the child. It returns as soon as the process exists, not
|
||||||
// when the model is loaded.
|
// when the model is loaded.
|
||||||
func (r *runner) start() error {
|
func (r *runner) start() error {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -76,7 +80,7 @@ func (r *runner) start() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.cmd, r.ready, r.yielding = cmd, false, false
|
r.cmd, r.ready, r.yielding = cmd, false, false
|
||||||
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
|
log.Printf("mavgpud: started %s pid=%d", r.name, cmd.Process.Pid)
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
@@ -84,15 +88,15 @@ func (r *runner) start() error {
|
|||||||
r.cmd, r.ready, r.yielding = nil, false, false
|
r.cmd, r.ready, r.yielding = nil, false, false
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if yielded {
|
if yielded {
|
||||||
log.Printf("mavgpud: llama-server stopped, card yielded (%v)", err)
|
log.Printf("mavgpud: %s stopped, card yielded (%v)", r.name, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("mavgpud: llama-server exited: %v", err)
|
log.Printf("mavgpud: %s exited: %v", r.name, err)
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop ends llama-server and waits for the VRAM to come back. SIGTERM first so
|
// stop ends the child and waits for the VRAM to come back. SIGTERM first so
|
||||||
// it unmaps cleanly, SIGKILL after the grace window. Returning before the
|
// it unmaps cleanly, SIGKILL after the grace window. Returning before the
|
||||||
// process is gone would let the supervisor report a free card while 14GB is
|
// process is gone would let the supervisor report a free card while 14GB is
|
||||||
// still mapped, which is the one lie that would make yielding useless.
|
// still mapped, which is the one lie that would make yielding useless.
|
||||||
@@ -117,11 +121,11 @@ func (r *runner) stop(grace time.Duration) {
|
|||||||
}
|
}
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
}
|
}
|
||||||
log.Printf("mavgpud: llama-server did not exit in %s, killing", grace)
|
log.Printf("mavgpud: %s did not exit in %s, killing", r.name, grace)
|
||||||
_ = syscall.Kill(pgid, syscall.SIGKILL)
|
_ = syscall.Kill(pgid, syscall.SIGKILL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// refreshReady asks llama-server whether the model is loaded. Called once per
|
// refreshReady asks the child whether the model is loaded. Called once per
|
||||||
// supervisor tick, never per request.
|
// supervisor tick, never per request.
|
||||||
func (r *runner) refreshReady(ctx context.Context) {
|
func (r *runner) refreshReady(ctx context.Context) {
|
||||||
if !r.running() {
|
if !r.running() {
|
||||||
@@ -141,6 +145,6 @@ func (r *runner) refreshReady(ctx context.Context) {
|
|||||||
r.ready = ok
|
r.ready = ok
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if ok && !was {
|
if ok && !was {
|
||||||
log.Printf("mavgpud: model ready")
|
log.Printf("mavgpud: %s ready", r.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func fakeServer(t *testing.T, body string) string {
|
|||||||
// status of a routine yield is identical to that of a real crash. Reading the
|
// status of a routine yield is identical to that of a real crash. Reading the
|
||||||
// mavgpud log, the two were indistinguishable (Vikunja #491).
|
// mavgpud log, the two were indistinguishable (Vikunja #491).
|
||||||
func TestStopMarksTheExitAsAYield(t *testing.T) {
|
func TestStopMarksTheExitAsAYield(t *testing.T) {
|
||||||
r := newRunner(fakeServer(t, "while : ; do sleep 1 ; done"), nil, "")
|
r := newRunner("fake", fakeServer(t, "while : ; do sleep 1 ; done"), nil, "")
|
||||||
if err := r.start(); err != nil {
|
if err := r.start(); err != nil {
|
||||||
t.Fatalf("start: %v", err)
|
t.Fatalf("start: %v", err)
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ func TestStopMarksTheExitAsAYield(t *testing.T) {
|
|||||||
// Stopping when nothing is running must not arm the flag for the next child.
|
// Stopping when nothing is running must not arm the flag for the next child.
|
||||||
// The next exit after that would be a real crash logged as a yield.
|
// The next exit after that would be a real crash logged as a yield.
|
||||||
func TestStopWithNoChildDoesNotArmTheFlag(t *testing.T) {
|
func TestStopWithNoChildDoesNotArmTheFlag(t *testing.T) {
|
||||||
r := newRunner("/nonexistent", nil, "")
|
r := newRunner("fake", "/nonexistent", nil, "")
|
||||||
r.stop(10 * time.Millisecond)
|
r.stop(10 * time.Millisecond)
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|||||||
+27
-7
@@ -5,12 +5,17 @@
|
|||||||
// is detected sends it as a PushToTalk frame to the voice server. The reply
|
// is detected sends it as a PushToTalk frame to the voice server. The reply
|
||||||
// audio is played back through aplay(1).
|
// audio is played back through aplay(1).
|
||||||
//
|
//
|
||||||
// No wake-word model yet (MVP uses voice-activity-only trigger). The
|
// Voice activity is silero-vad when -vad-model points at the graph, and an
|
||||||
// SurfaceVoice auth layer caps all commands at L0 (no destructive acts),
|
// energy threshold when it does not. Silero declines noise the threshold
|
||||||
// making accidental triggers safe by design. A proper wake-word engine
|
// accepts: 0 frames against 68 to 99 on the four fixtures, measured in
|
||||||
// (openWakeWord / Silero VAD ONNX) is the planned upgrade — the VAD shape
|
// docs/evals/2026-08-09-silero-vad.md. Note that the model window is 512
|
||||||
// (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so
|
// samples and the capture frame is 480, so silero.go re-chunks. This comment
|
||||||
// swapping energy-threshold for ONNX-inference is a local change in vad.go.
|
// used to say the two matched, which was true of silero v4.
|
||||||
|
//
|
||||||
|
// There is still no wake-word model, so anything spoken near the microphone
|
||||||
|
// becomes a turn (V-487 stage two). The SurfaceVoice auth layer caps all
|
||||||
|
// commands at L0 (no destructive acts), which is what makes an accidental
|
||||||
|
// trigger safe rather than expensive.
|
||||||
//
|
//
|
||||||
// While a reply is playing the capture side is muted (half-duplex): without
|
// While a reply is playing the capture side is muted (half-duplex): without
|
||||||
// it, Maven's own voice comes back in through the mic and she answers
|
// it, Maven's own voice comes back in through the mic and she answers
|
||||||
@@ -73,6 +78,9 @@ func run(args []string) error {
|
|||||||
bargeIn := flag.Bool("barge-in", false, "cut Maven off when he talks over her (needs a room-tuned -barge-in-rms)")
|
bargeIn := flag.Bool("barge-in", false, "cut Maven off when he talks over her (needs a room-tuned -barge-in-rms)")
|
||||||
bargeRMS := flag.Int("barge-in-rms", defaultBargeRMS, "RMS x10000 a frame must clear to count as barge-in")
|
bargeRMS := flag.Int("barge-in-rms", defaultBargeRMS, "RMS x10000 a frame must clear to count as barge-in")
|
||||||
bargeFrames := flag.Int("barge-in-frames", defaultBargeFrames, "consecutive frames over -barge-in-rms before playback is cut")
|
bargeFrames := flag.Int("barge-in-frames", defaultBargeFrames, "consecutive frames over -barge-in-rms before playback is cut")
|
||||||
|
vadModel := flag.String("vad-model", "", "silero-vad onnx file; empty runs the energy threshold instead")
|
||||||
|
vadThreshold := flag.Float64("vad-threshold", defaultSileroThreshold, "speech probability a frame must clear")
|
||||||
|
onnxLib := flag.String("onnx-lib", os.Getenv("MAVEN_ONNX_LIB"), "libonnxruntime.so, needed with -vad-model")
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
@@ -82,8 +90,20 @@ func run(args []string) error {
|
|||||||
vc := voice.Dial(*addr)
|
vc := voice.Dial(*addr)
|
||||||
defer vc.Close()
|
defer vc.Close()
|
||||||
|
|
||||||
// VAD engine.
|
// VAD engine. A model that will not load is logged and not fatal: the
|
||||||
|
// energy threshold is worse, and it is a great deal better than a
|
||||||
|
// listening client that refuses to start.
|
||||||
vad := NewVAD(*minRMS, *speechMs, *silenceMs, *maxMs)
|
vad := NewVAD(*minRMS, *speechMs, *silenceMs, *maxMs)
|
||||||
|
if *vadModel != "" {
|
||||||
|
s, err := newSileroVAD(*vadModel, *onnxLib)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("mavwaked: silero unavailable, energy threshold unchanged: %v", err)
|
||||||
|
} else {
|
||||||
|
defer s.Close()
|
||||||
|
vad.UseSilero(s, *vadThreshold)
|
||||||
|
log.Printf("mavwaked: silero-vad from %s, threshold %.2f", *vadModel, *vadThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Audio source.
|
// Audio source.
|
||||||
var src io.ReadCloser
|
var src io.ReadCloser
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// silero-vad, the speech detector that replaces the energy threshold (V-487).
|
||||||
|
//
|
||||||
|
// Why an energy threshold is not a voice activity detector. It answers "is
|
||||||
|
// this frame loud", and a fan, a door and a television are all loud. mavwaked
|
||||||
|
// sends every utterance it accepts to speech-to-text and then to the daemon,
|
||||||
|
// so a false trigger is a turn Maven takes on something nobody said to her.
|
||||||
|
// Silero answers "is this frame speech", which is the question.
|
||||||
|
//
|
||||||
|
// It is 2.3MB of ONNX and runs on one CPU core in real time. That is not an
|
||||||
|
// aside: this is the one model in the system that may never be offloaded or
|
||||||
|
// gated on GPU admission, because a wake path that waits on a card is not a
|
||||||
|
// wake path.
|
||||||
|
//
|
||||||
|
// Nil is a working value. Without -vad-model the daemon runs the energy VAD
|
||||||
|
// exactly as it did before this file existed.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
ort "github.com/yalue/onnxruntime_go"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// sileroWindow — samples per inference at 16kHz. The model is fixed at
|
||||||
|
// 512 and does not accept another size, which is why this file
|
||||||
|
// re-chunks rather than reusing the 480-sample capture frame. main.go
|
||||||
|
// used to claim the two matched; that was true of silero v4.
|
||||||
|
sileroWindow = 512
|
||||||
|
|
||||||
|
// sileroContext — samples of the previous window prepended to each
|
||||||
|
// inference, as the reference implementation does. Without it the first
|
||||||
|
// milliseconds of every window are judged with no history and speech
|
||||||
|
// onsets score low.
|
||||||
|
sileroContext = 64
|
||||||
|
|
||||||
|
// sileroState — the LSTM state carried between windows, [2][1][128].
|
||||||
|
sileroStateDim = 128
|
||||||
|
|
||||||
|
// defaultSileroThreshold — probability above which a window is speech.
|
||||||
|
// 0.5 is the reference default. Raising it costs speech onsets, which
|
||||||
|
// are the quietest part of an utterance.
|
||||||
|
defaultSileroThreshold = 0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
// sileroVAD holds one ONNX session and the streaming state around it. It is
|
||||||
|
// fed 30ms capture frames and answers per frame, buffering across calls
|
||||||
|
// because 480 samples never line up with a 512-sample window.
|
||||||
|
type sileroVAD struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
session *ort.DynamicAdvancedSession
|
||||||
|
|
||||||
|
pending []float32 // samples not yet part of a full window
|
||||||
|
context [sileroContext]float32 // tail of the previous window
|
||||||
|
state []float32 // [2][1][128], carried between windows
|
||||||
|
last float64 // most recent probability, held between windows
|
||||||
|
sr []int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSileroVAD loads the graph. The ONNX environment is initialised here when
|
||||||
|
// nothing else has done it, because mavwaked has no embedder to do it first.
|
||||||
|
func newSileroVAD(modelPath, libPath string) (*sileroVAD, error) {
|
||||||
|
if !ort.IsInitialized() {
|
||||||
|
if libPath != "" {
|
||||||
|
ort.SetSharedLibraryPath(libPath)
|
||||||
|
}
|
||||||
|
if err := ort.InitializeEnvironment(); err != nil {
|
||||||
|
return nil, fmt.Errorf("silero: onnx runtime: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s, err := ort.NewDynamicAdvancedSession(modelPath,
|
||||||
|
[]string{"input", "state", "sr"}, []string{"output", "stateN"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("silero: load %s: %w", modelPath, err)
|
||||||
|
}
|
||||||
|
return &sileroVAD{
|
||||||
|
session: s,
|
||||||
|
state: make([]float32, 2*sileroStateDim),
|
||||||
|
sr: []int64{16000},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Speech reports whether the frame carries speech, and the probability behind
|
||||||
|
// that answer. A frame that completes no window inherits the previous
|
||||||
|
// probability, so the caller sees one answer per frame either way.
|
||||||
|
func (s *sileroVAD) Speech(frame []int16, threshold float64) (bool, float64) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for _, v := range frame {
|
||||||
|
s.pending = append(s.pending, float32(v)/32768.0)
|
||||||
|
}
|
||||||
|
for len(s.pending) >= sileroWindow {
|
||||||
|
p, err := s.infer(s.pending[:sileroWindow])
|
||||||
|
if err != nil {
|
||||||
|
// A failed inference must not silence the microphone. Hold the
|
||||||
|
// last answer and let the next window try again.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.last = p
|
||||||
|
s.pending = s.pending[sileroWindow:]
|
||||||
|
}
|
||||||
|
return s.last >= threshold, s.last
|
||||||
|
}
|
||||||
|
|
||||||
|
// infer runs one window and rolls the state and the context forward.
|
||||||
|
func (s *sileroVAD) infer(window []float32) (float64, error) {
|
||||||
|
in := make([]float32, sileroContext+sileroWindow)
|
||||||
|
copy(in, s.context[:])
|
||||||
|
copy(in[sileroContext:], window)
|
||||||
|
|
||||||
|
inT, err := ort.NewTensor(ort.NewShape(1, int64(len(in))), in)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer inT.Destroy()
|
||||||
|
stT, err := ort.NewTensor(ort.NewShape(2, 1, sileroStateDim), s.state)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer stT.Destroy()
|
||||||
|
srT, err := ort.NewTensor(ort.NewShape(1), s.sr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer srT.Destroy()
|
||||||
|
|
||||||
|
out, err := ort.NewEmptyTensor[float32](ort.NewShape(1, 1))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer out.Destroy()
|
||||||
|
next, err := ort.NewEmptyTensor[float32](ort.NewShape(2, 1, sileroStateDim))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer next.Destroy()
|
||||||
|
|
||||||
|
if err := s.session.Run(
|
||||||
|
[]ort.Value{inT, stT, srT},
|
||||||
|
[]ort.Value{out, next},
|
||||||
|
); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
copy(s.state, next.GetData())
|
||||||
|
copy(s.context[:], in[len(in)-sileroContext:])
|
||||||
|
return float64(out.GetData()[0]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset drops the streaming state. Called at every utterance boundary and
|
||||||
|
// after barge-in, so echo-era history never scores the next sentence.
|
||||||
|
func (s *sileroVAD) Reset() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.pending = s.pending[:0]
|
||||||
|
s.context = [sileroContext]float32{}
|
||||||
|
for i := range s.state {
|
||||||
|
s.state[i] = 0
|
||||||
|
}
|
||||||
|
s.last = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the session.
|
||||||
|
func (s *sileroVAD) Close() error {
|
||||||
|
if s == nil || s.session == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.session.Destroy()
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// What this measures. The energy threshold cannot tell a voice from a
|
||||||
|
// television, and every utterance it accepts becomes a turn. So the test that
|
||||||
|
// matters is not "does silero find speech" — it is "does it decline what the
|
||||||
|
// energy threshold accepts".
|
||||||
|
//
|
||||||
|
// Speech is the four piper fixtures mavsttd already scores against. They are
|
||||||
|
// synthesised, so nothing of the owner's voice is committed. Non-speech is
|
||||||
|
// white noise at the same loudness, which is the cheapest thing that fools an
|
||||||
|
// energy floor and the honest floor for this claim.
|
||||||
|
//
|
||||||
|
// Both halves skip without models/vad/silero_vad.onnx and MAVEN_ONNX_LIB,
|
||||||
|
// like the TestONNX measurements in internal/router/eval.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const wavHeader = 44 // 16kHz mono s16le, written by piper
|
||||||
|
|
||||||
|
func loadSilero(t *testing.T) *sileroVAD {
|
||||||
|
t.Helper()
|
||||||
|
model := filepath.Join("..", "..", "models", "vad", "silero_vad.onnx")
|
||||||
|
lib := os.Getenv("MAVEN_ONNX_LIB")
|
||||||
|
if _, err := os.Stat(model); err != nil {
|
||||||
|
t.Skipf("missing %s: %v", model, err)
|
||||||
|
}
|
||||||
|
if lib == "" {
|
||||||
|
t.Skip("MAVEN_ONNX_LIB unset")
|
||||||
|
}
|
||||||
|
s, err := newSileroVAD(model, lib)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("silero unavailable: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// feedAll runs a whole clip through a VAD and reports how many utterances it
|
||||||
|
// produced and how many frames it called speech.
|
||||||
|
func feedAll(v *VAD, pcm []int16) (utterances, speechFrames int) {
|
||||||
|
for i := 0; i+frameSamples <= len(pcm); i += frameSamples {
|
||||||
|
frame := pcm[i : i+frameSamples]
|
||||||
|
utt, state := v.Feed(frame)
|
||||||
|
if state == StateSpeech {
|
||||||
|
speechFrames++
|
||||||
|
}
|
||||||
|
if utt.Bytes != nil {
|
||||||
|
utterances++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return utterances, speechFrames
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFixture(t *testing.T, name string) []int16 {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "mavsttd", "testdata", name))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("missing fixture %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if len(raw) <= wavHeader {
|
||||||
|
t.Fatalf("%s: %d bytes, no audio", name, len(raw))
|
||||||
|
}
|
||||||
|
return PCMToI16(raw[wavHeader:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// noise returns white noise scaled to the same RMS as ref. Same loudness,
|
||||||
|
// nothing said.
|
||||||
|
func noise(ref []int16, seed int64) []int16 {
|
||||||
|
target := frameRMS(ref)
|
||||||
|
r := rand.New(rand.NewSource(seed))
|
||||||
|
out := make([]int16, len(ref))
|
||||||
|
for i := range out {
|
||||||
|
out[i] = int16(r.NormFloat64() * target * 32768.0)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSileroHearsSpeechAndDeclinesNoise(t *testing.T) {
|
||||||
|
s := loadSilero(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
for _, name := range []string{"ru_fact.wav", "ru_query.wav", "ru_reminder.wav", "en_act.wav"} {
|
||||||
|
pcm := readFixture(t, name)
|
||||||
|
|
||||||
|
v := NewVAD(0, 0, 0, 0)
|
||||||
|
v.UseSilero(s, defaultSileroThreshold)
|
||||||
|
_, spoke := feedAll(v, pcm)
|
||||||
|
if spoke == 0 {
|
||||||
|
t.Errorf("%s: silero heard no speech in a spoken clip", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Reset()
|
||||||
|
v2 := NewVAD(0, 0, 0, 0)
|
||||||
|
v2.UseSilero(s, defaultSileroThreshold)
|
||||||
|
_, heard := feedAll(v2, noise(pcm, 7))
|
||||||
|
|
||||||
|
energy := NewVAD(0, 0, 0, 0)
|
||||||
|
_, energyHeard := feedAll(energy, noise(pcm, 7))
|
||||||
|
|
||||||
|
t.Logf("%s: speech frames — silero on speech %d, silero on noise %d, energy on noise %d",
|
||||||
|
name, spoke, heard, energyHeard)
|
||||||
|
if heard >= energyHeard {
|
||||||
|
t.Errorf("%s: silero called %d noise frames speech, energy called %d — no improvement",
|
||||||
|
name, heard, energyHeard)
|
||||||
|
}
|
||||||
|
s.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkSileroFrame answers the only performance question that matters
|
||||||
|
// here: one 30ms frame must cost far less than 30ms on one core, or the
|
||||||
|
// detector cannot run always-on beside everything else on that machine.
|
||||||
|
func BenchmarkSileroFrame(b *testing.B) {
|
||||||
|
s := loadSilero(&testing.T{})
|
||||||
|
if s == nil {
|
||||||
|
b.Skip("silero unavailable")
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
frame := make([]int16, frameSamples)
|
||||||
|
for i := range frame {
|
||||||
|
frame[i] = int16(i%400 - 200)
|
||||||
|
}
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
s.Speech(frame, defaultSileroThreshold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSileroRechunksAcrossFrames pins the reason this file exists. The capture
|
||||||
|
// frame is 480 samples and the model window is 512, so a detector that ran one
|
||||||
|
// inference per frame would be feeding the model a shape it does not accept.
|
||||||
|
func TestSileroRechunksAcrossFrames(t *testing.T) {
|
||||||
|
s := loadSilero(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
silence := make([]int16, frameSamples)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if _, p := s.Speech(silence, defaultSileroThreshold); math.IsNaN(p) {
|
||||||
|
t.Fatalf("frame %d: probability is NaN", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(s.pending) >= sileroWindow {
|
||||||
|
t.Errorf("pending grew to %d samples, so windows are not being consumed", len(s.pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-1
@@ -68,6 +68,37 @@ type VAD struct {
|
|||||||
// follows the room's ambient level. Initialised to minRMS; updated
|
// follows the room's ambient level. Initialised to minRMS; updated
|
||||||
// on each silence frame.
|
// on each silence frame.
|
||||||
floorRMS float64
|
floorRMS float64
|
||||||
|
|
||||||
|
// speech is silero-vad, or nil. When it is set the energy floor decides
|
||||||
|
// nothing: the question becomes "is this speech" rather than "is this
|
||||||
|
// loud", and the noise floor is not even tracked. Everything after that
|
||||||
|
// answer — the speech hold, the silence hold, the length cap, the
|
||||||
|
// buffer — is the same state machine either way, which is why the
|
||||||
|
// detector goes here and not around this type.
|
||||||
|
speech *sileroVAD
|
||||||
|
speechMin float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// UseSilero swaps the energy threshold for the model. Passing nil is a
|
||||||
|
// no-op, so a caller that could not load the graph keeps a working VAD.
|
||||||
|
func (v *VAD) UseSilero(s *sileroVAD, threshold float64) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if threshold <= 0 {
|
||||||
|
threshold = defaultSileroThreshold
|
||||||
|
}
|
||||||
|
v.speech = s
|
||||||
|
v.speechMin = threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSpeech answers the one question the state machine asks of a frame.
|
||||||
|
func (v *VAD) isSpeech(frame []int16, rms float64) bool {
|
||||||
|
if v.speech != nil {
|
||||||
|
ok, _ := v.speech.Speech(frame, v.speechMin)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
return rms >= v.floorRMS
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVAD creates a VAD with the given thresholds. Zero values use defaults.
|
// NewVAD creates a VAD with the given thresholds. Zero values use defaults.
|
||||||
@@ -110,7 +141,7 @@ func (v *VAD) State() SpeechState { return v.state }
|
|||||||
// should send the audio to the voice server before feeding more frames.
|
// should send the audio to the voice server before feeding more frames.
|
||||||
func (v *VAD) Feed(frame []int16) (_ audio.Audio, state SpeechState) {
|
func (v *VAD) Feed(frame []int16) (_ audio.Audio, state SpeechState) {
|
||||||
rms := frameRMS(frame)
|
rms := frameRMS(frame)
|
||||||
isSpeech := rms >= v.floorRMS
|
isSpeech := v.isSpeech(frame, rms)
|
||||||
|
|
||||||
switch v.state {
|
switch v.state {
|
||||||
case StateSilence:
|
case StateSilence:
|
||||||
@@ -175,6 +206,9 @@ func (v *VAD) Feed(frame []int16) (_ audio.Audio, state SpeechState) {
|
|||||||
func (v *VAD) Reset() { v.reset() }
|
func (v *VAD) Reset() { v.reset() }
|
||||||
|
|
||||||
func (v *VAD) reset() {
|
func (v *VAD) reset() {
|
||||||
|
if v.speech != nil {
|
||||||
|
v.speech.Reset()
|
||||||
|
}
|
||||||
v.state = StateSilence
|
v.state = StateSilence
|
||||||
v.speechFrames = 0
|
v.speechFrames = 0
|
||||||
v.silenceFrames = 0
|
v.silenceFrames = 0
|
||||||
|
|||||||
+105
-2
@@ -2,12 +2,15 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/webauthn"
|
"github.com/kami/maven/internal/webauthn"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,6 +28,21 @@ type chatMsg struct {
|
|||||||
// Source — the query source that claimed the turn, shown as a badge beside
|
// Source — the query source that claimed the turn, shown as a badge beside
|
||||||
// the reply. Empty for a turn no source claimed (V-539).
|
// the reply. Empty for a turn no source claimed (V-539).
|
||||||
Source string
|
Source string
|
||||||
|
// TraceID anchors the correction gesture (V-630). Non-zero ⇒ the turn was
|
||||||
|
// persisted and can be corrected in one click. 0 ⇒ no correction is offered,
|
||||||
|
// which is honest: a box with no database has no turn to correct.
|
||||||
|
TraceID int64
|
||||||
|
// Corrected — the owner already corrected this turn, so the page says thank
|
||||||
|
// you instead of offering the buttons again.
|
||||||
|
Corrected string
|
||||||
|
}
|
||||||
|
|
||||||
|
// correctionTargets — the seven public intents, in the order the buttons are
|
||||||
|
// shown. Read from internal/router rather than typed out, so a new intent cannot
|
||||||
|
// exist without a way to correct a turn into it.
|
||||||
|
var correctionTargets = []router.Intent{
|
||||||
|
router.IntentFact, router.IntentNote, router.IntentReminder,
|
||||||
|
router.IntentQuery, router.IntentAct, router.IntentChat, router.IntentSystem,
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleChatPage renders the chat conversation page.
|
// handleChatPage renders the chat conversation page.
|
||||||
@@ -38,12 +56,21 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|||||||
msgs = append(msgs, chatMsg{Role: "user", Text: q})
|
msgs = append(msgs, chatMsg{Role: "user", Text: q})
|
||||||
}
|
}
|
||||||
if reply := r.URL.Query().Get("r"); reply != "" {
|
if reply := r.URL.Query().Get("r"); reply != "" {
|
||||||
msgs = append(msgs, chatMsg{Role: "assistant", Text: reply, Source: r.URL.Query().Get("s")})
|
id, _ := strconv.ParseInt(r.URL.Query().Get("t"), 10, 64)
|
||||||
|
msgs = append(msgs, chatMsg{
|
||||||
|
Role: "assistant", Text: reply, Source: r.URL.Query().Get("s"),
|
||||||
|
TraceID: id, Corrected: r.URL.Query().Get("c"),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
// UserText rides beside the messages so the correction form can hand the
|
||||||
|
// conversation back on the redirect: this page has no session and no JS, so
|
||||||
|
// what is on screen is what the query params carry.
|
||||||
renderPage(w, chatTmpl, struct {
|
renderPage(w, chatTmpl, struct {
|
||||||
Error string
|
Error string
|
||||||
Messages []chatMsg
|
Messages []chatMsg
|
||||||
}{Messages: msgs})
|
Targets []router.Intent
|
||||||
|
UserText string
|
||||||
|
}{Messages: msgs, Targets: correctionTargets, UserText: r.URL.Query().Get("q")})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
// handleChatAPI processes a chat message POST and redirects back to /chat.
|
||||||
@@ -88,5 +115,81 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
|||||||
if reply.Source != "" {
|
if reply.Source != "" {
|
||||||
dest += "&s=" + url.QueryEscape(reply.Source)
|
dest += "&s=" + url.QueryEscape(reply.Source)
|
||||||
}
|
}
|
||||||
|
// The trace id rides along so the reply can carry a correction gesture
|
||||||
|
// (V-630). Absent when nothing persisted, and the page then offers none.
|
||||||
|
if reply.TraceID != 0 {
|
||||||
|
dest += "&t=" + strconv.FormatInt(reply.TraceID, 10)
|
||||||
|
}
|
||||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleCorrectAPI records that the last turn was routed wrongly (V-630).
|
||||||
|
//
|
||||||
|
// A correction is the only supervised signal this box gets, and everything else
|
||||||
|
// in the trace accumulates on its own. So the gesture has to cost nothing: one
|
||||||
|
// POST from the reply he is already looking at, carrying the trace id and
|
||||||
|
// optionally the intent it should have been. An unstated target is accepted,
|
||||||
|
// because a turn marked wrong with no target is still a usable negative.
|
||||||
|
//
|
||||||
|
// Step-up gated like POST /api/chat, and that costs the gesture nothing: he
|
||||||
|
// tapped to send the turn he is now correcting, so the session is already up.
|
||||||
|
// It is gated because trace ids are sequential integers and this writes the one
|
||||||
|
// table the routing heads (V-546) will be fitted on. A caller who can guess an
|
||||||
|
// id could otherwise mislabel turns he never corrected.
|
||||||
|
func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !requireCore(w, core, "correct") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !stepUpGate(w, session, requireStepUp) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("trace_id")), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
http.Error(w, "trace_id required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shouldBe := strings.TrimSpace(r.FormValue("should_be"))
|
||||||
|
// Only one of the seven, or nothing. Free text here would put an unroutable
|
||||||
|
// label in the one table V-632 fits prototypes from.
|
||||||
|
if shouldBe != "" && !isCorrectionTarget(shouldBe) {
|
||||||
|
http.Error(w, "should_be must be one of the seven intents", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := core.CorrectTurn(r.Context(), id, shouldBe); err != nil {
|
||||||
|
log.Printf("correct turn %d: %v", id, err)
|
||||||
|
// A turn past the retention bound is gone, and saying so is different
|
||||||
|
// from saying the write broke.
|
||||||
|
if errors.Is(err, ipc.ErrNoSuchTrace) {
|
||||||
|
http.Error(w, "that turn is no longer stored", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, "correction failed", http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stamp := shouldBe
|
||||||
|
if stamp == "" {
|
||||||
|
stamp = "wrong"
|
||||||
|
}
|
||||||
|
// Back to the conversation he was in, with the turn still on screen. The
|
||||||
|
// query params carry it, so the correction is preserved by re-sending them.
|
||||||
|
dest := "/chat?q=" + url.QueryEscape(r.FormValue("q")) +
|
||||||
|
"&r=" + url.QueryEscape(r.FormValue("rep")) + "&c=" + url.QueryEscape(stamp)
|
||||||
|
if s := r.FormValue("s"); s != "" {
|
||||||
|
dest += "&s=" + url.QueryEscape(s)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, dest, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCorrectionTarget — one of the seven, and nothing else.
|
||||||
|
func isCorrectionTarget(s string) bool {
|
||||||
|
for _, t := range correctionTargets {
|
||||||
|
if string(t) == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,21 @@
|
|||||||
<div class="scroll chat-scroll" id=chatHistory>
|
<div class="scroll chat-scroll" id=chatHistory>
|
||||||
{{range .Messages}}
|
{{range .Messages}}
|
||||||
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}{{if .Source}} <span class="badge badge-accent" title="the query source that claimed this turn">{{.Source}}</span>{{end}}</div>
|
<div class="chat-msg {{.Role}}"><strong>{{if eq .Role "user"}}you{{else}}maven{{end}}:</strong> {{.Text}}{{if .Source}} <span class="badge badge-accent" title="the query source that claimed this turn">{{.Source}}</span>{{end}}</div>
|
||||||
|
{{if and (eq .Role "assistant") .TraceID}}
|
||||||
|
{{if .Corrected}}
|
||||||
|
<div class=chat-correct><span class="badge badge-ok" title="the label is kept; the transcript still expires in 14 days">corrected: {{.Corrected}}</span></div>
|
||||||
|
{{else}}
|
||||||
|
<form method=post action=/api/correct class=chat-correct>
|
||||||
|
<input type=hidden name=trace_id value="{{.TraceID}}">
|
||||||
|
<input type=hidden name=q value="{{$.UserText}}">
|
||||||
|
<input type=hidden name=rep value="{{.Text}}">
|
||||||
|
<input type=hidden name=s value="{{.Source}}">
|
||||||
|
<button class="btn btn-sm" title="wrong, and I am not saying what it was">wrong</button>
|
||||||
|
<span class=chat-correct-label>should have been:</span>
|
||||||
|
{{range $.Targets}}<button class="btn btn-sm btn-muted" name=should_be value="{{.}}">{{.}}</button>{{end}}
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class=empty>
|
<div class=empty>
|
||||||
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-message"/></svg>
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// correctCore records the correction the handler sends.
|
||||||
|
type correctCore struct {
|
||||||
|
ipc.UnimplementedCoreAPI
|
||||||
|
traceID int64
|
||||||
|
shouldBe string
|
||||||
|
called bool
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *correctCore) CorrectTurn(_ context.Context, traceID int64, shouldBe string) error {
|
||||||
|
c.called, c.traceID, c.shouldBe = true, traceID, shouldBe
|
||||||
|
return c.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func postCorrect(form url.Values) *http.Request {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/correct", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full gesture: wrong, and it should have been a fact.
|
||||||
|
func TestCorrectAPIWithTarget(t *testing.T) {
|
||||||
|
core := &correctCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleCorrectAPI(rr, postCorrect(url.Values{
|
||||||
|
"trace_id": {"42"}, "should_be": {"fact"}, "q": {"поужинал"}, "rep": {"поняла"},
|
||||||
|
}), core, stepUpSession(), false)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status %d, want 303; body=%s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if core.traceID != 42 || core.shouldBe != "fact" {
|
||||||
|
t.Errorf("corrected trace %d to %q", core.traceID, core.shouldBe)
|
||||||
|
}
|
||||||
|
// The turn stays on screen, and the page says it was corrected.
|
||||||
|
loc := rr.Header().Get("Location")
|
||||||
|
if !strings.Contains(loc, "c=fact") || !strings.Contains(loc, "q=") {
|
||||||
|
t.Errorf("redirect %q loses the turn or the correction", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cheap half. A turn marked wrong with no target is still a usable negative,
|
||||||
|
// and it must not cost more to give than the full answer.
|
||||||
|
func TestCorrectAPIWithNoTarget(t *testing.T) {
|
||||||
|
core := &correctCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}}), core, stepUpSession(), false)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("status %d, want 303", rr.Code)
|
||||||
|
}
|
||||||
|
if !core.called || core.shouldBe != "" {
|
||||||
|
t.Errorf("called=%v shouldBe=%q, want an untargeted negative recorded", core.called, core.shouldBe)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Header().Get("Location"), "c=wrong") {
|
||||||
|
t.Errorf("redirect %q does not say the turn was marked wrong", rr.Header().Get("Location"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free text here would put an unroutable label in the one table V-632 fits
|
||||||
|
// prototypes from.
|
||||||
|
func TestCorrectAPIRejectsUnknownTarget(t *testing.T) {
|
||||||
|
core := &correctCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}, "should_be": {"погода"}}), core, stepUpSession(), false)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status %d, want 400", rr.Code)
|
||||||
|
}
|
||||||
|
if core.called {
|
||||||
|
t.Error("wrote a label for a target that is not one of the seven")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorrectAPINeedsTraceID(t *testing.T) {
|
||||||
|
for _, form := range []url.Values{{}, {"trace_id": {"0"}}, {"trace_id": {"nope"}}} {
|
||||||
|
core := &correctCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleCorrectAPI(rr, postCorrect(form), core, stepUpSession(), false)
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("form %v: status %d, want 400", form, rr.Code)
|
||||||
|
}
|
||||||
|
if core.called {
|
||||||
|
t.Errorf("form %v: reached the core", form)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A write that broke is not a turn that expired, and the two must not read the
|
||||||
|
// same to the owner deciding whether to correct again.
|
||||||
|
func TestCorrectAPIReportsFailure(t *testing.T) {
|
||||||
|
core := &correctCore{err: errors.New("disk is full")}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false)
|
||||||
|
if rr.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("status %d, want 502", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A trace past the retention bound is gone, and the surface says that.
|
||||||
|
func TestCorrectAPIExpiredTurn(t *testing.T) {
|
||||||
|
core := &correctCore{err: ipc.ErrNoSuchTrace}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false)
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status %d, want 404", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCorrectAPIPostOnly(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleCorrectAPI(rr, httptest.NewRequest(http.MethodGet, "/api/correct", nil), &correctCore{}, stepUpSession(), false)
|
||||||
|
if rr.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("status %d, want 405", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every one of the seven intents has a button, so a new intent cannot exist with
|
||||||
|
// no way to correct a turn into it.
|
||||||
|
func TestCorrectionTargetsAreTheSeven(t *testing.T) {
|
||||||
|
if len(correctionTargets) != 7 {
|
||||||
|
t.Fatalf("%d targets, want the seven public intents", len(correctionTargets))
|
||||||
|
}
|
||||||
|
for _, want := range []string{"fact", "note", "reminder", "query", "act", "chat", "system"} {
|
||||||
|
if !isCorrectionTarget(want) {
|
||||||
|
t.Errorf("%s is not offered", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isCorrectionTarget("") {
|
||||||
|
t.Error("empty is not a target: it is the absence of one, handled separately")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trace ids are sequential, so a caller who cannot assert step-up must not be
|
||||||
|
// able to label a turn the owner never corrected.
|
||||||
|
func TestCorrectAPINeedsStepUp(t *testing.T) {
|
||||||
|
core := &correctCore{}
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, nil, true)
|
||||||
|
if rr.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status %d, want 403", rr.Code)
|
||||||
|
}
|
||||||
|
if core.called {
|
||||||
|
t.Error("wrote a label with no step-up")
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-1
@@ -58,6 +58,12 @@ func main() {
|
|||||||
// mutex, so sharing the connection would freeze every other page for the
|
// mutex, so sharing the connection would freeze every other page for the
|
||||||
// length of the load. See handleModels.
|
// length of the load. See handleModels.
|
||||||
var swapConn modelController
|
var swapConn modelController
|
||||||
|
// turnConn — a third connection, for POST /api/chat and nothing else, for
|
||||||
|
// the same reason /models has one (V-638). A chat turn routes, phrases and
|
||||||
|
// may act, bounded only by phraser.timeout at 60s, and every other handler
|
||||||
|
// on this server queues behind it on the shared client's one mutex. Nil ⇒
|
||||||
|
// chat shares the main connection, which is how it behaved before.
|
||||||
|
var turnConn ipc.CoreAPI
|
||||||
if *coreSock != "" {
|
if *coreSock != "" {
|
||||||
c, err := ipc.DialWait(*coreSock, 60*time.Second)
|
c, err := ipc.DialWait(*coreSock, 60*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,6 +77,12 @@ func main() {
|
|||||||
defer sc.Close()
|
defer sc.Close()
|
||||||
swapConn = sc
|
swapConn = sc
|
||||||
}
|
}
|
||||||
|
if tc, err := ipc.Dial(*coreSock); err != nil {
|
||||||
|
log.Printf("chat: third core connection failed (%v) — /api/chat will share the main one and a turn will block the other pages", err)
|
||||||
|
} else {
|
||||||
|
defer tc.Close()
|
||||||
|
turnConn = tc
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stepUpSession stays nil unless the passkey endpoints are wired below — it
|
// stepUpSession stays nil unless the passkey endpoints are wired below — it
|
||||||
@@ -208,8 +220,15 @@ func main() {
|
|||||||
// decides how every utterance is routed and how every reply is worded.
|
// decides how every utterance is routed and how every reply is worded.
|
||||||
mux.HandleFunc("/tools", gatedPage(handleTools))
|
mux.HandleFunc("/tools", gatedPage(handleTools))
|
||||||
mux.HandleFunc("/routines", gatedPage(handleRoutines))
|
mux.HandleFunc("/routines", gatedPage(handleRoutines))
|
||||||
mux.HandleFunc("/api/chat", gatedPage(handleChatAPI))
|
mux.HandleFunc("/api/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
c := turnConn
|
||||||
|
if c == nil {
|
||||||
|
c = core
|
||||||
|
}
|
||||||
|
handleChatAPI(w, r, c, stepUpSession, *requireStepUp)
|
||||||
|
})
|
||||||
mux.HandleFunc("/api/revert", gatedPage(handleRevert))
|
mux.HandleFunc("/api/revert", gatedPage(handleRevert))
|
||||||
|
mux.HandleFunc("/api/correct", gatedPage(handleCorrectAPI))
|
||||||
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
|
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -702,6 +702,11 @@ details[open] > summary { margin-bottom: var(--space-1); }
|
|||||||
.chat-form { display: flex; gap: var(--space-2); }
|
.chat-form { display: flex; gap: var(--space-2); }
|
||||||
.chat-form input { flex: 1; }
|
.chat-form input { flex: 1; }
|
||||||
.chat-scroll { max-height: 60vh; overflow-y: auto; margin-bottom: var(--space-4); }
|
.chat-scroll { max-height: 60vh; overflow-y: auto; margin-bottom: var(--space-4); }
|
||||||
|
/* The correction gesture (V-630). Wraps on a phone rather than scrolling: it is
|
||||||
|
one row of small buttons, and a gesture that has to be panned to is not one. */
|
||||||
|
.chat-correct { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1);
|
||||||
|
padding: 0 var(--space-3) var(--space-2); margin-top: calc(-1 * var(--space-1)); margin-bottom: var(--space-2); }
|
||||||
|
.chat-correct-label { font-size: var(--fs-xs); color: var(--text-machine); margin-left: var(--space-2); }
|
||||||
|
|
||||||
/* ── Key-value grid ── */
|
/* ── Key-value grid ── */
|
||||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); font-size: var(--fs-sm); }
|
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); font-size: var(--fs-sm); }
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""CrisperWhisper 2.0 turbo as an HTTP service, for Maven's stt.Pair.
|
||||||
|
|
||||||
|
Two endpoints and no framework.
|
||||||
|
|
||||||
|
GET /health 200 once the model is loaded, 503 while it is loading.
|
||||||
|
POST /transcribe raw 16kHz mono PCM in, {"text","confidence"} out.
|
||||||
|
|
||||||
|
The body is the PCM itself rather than JSON. A minute of 16kHz mono is under
|
||||||
|
2MB raw and about 2.6MB base64, and the format is fixed at the Maven seam, so
|
||||||
|
headers carry it more cheaply than an envelope.
|
||||||
|
|
||||||
|
Why this exists at all: whisper.cpp cannot load CW2. It derives its language
|
||||||
|
count from the vocabulary size, and CW2's 51897 tokens shift seven special
|
||||||
|
token ids. So mavsttd stays whisper.cpp on homesrv and this runs beside the
|
||||||
|
model on workpc, where it scores 10.4% WER in Russian against the floor's 27.5%
|
||||||
|
(docs/evals/2026-08-09-crisperwhisper2-russian-wer.md in the Maven repo).
|
||||||
|
|
||||||
|
Intended mode, not verbatim. The owner asked for what he meant to say, not
|
||||||
|
every stutter on the way there.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
HOST = os.environ.get("CW2_HOST", "0.0.0.0")
|
||||||
|
PORT = int(os.environ.get("CW2_PORT", "8081"))
|
||||||
|
SIZE = os.environ.get("CW2_SIZE", "turbo")
|
||||||
|
MODE = os.environ.get("CW2_MODE", "intended")
|
||||||
|
TOKEN = os.environ.get("CW2_TOKEN", "")
|
||||||
|
# 25MB is about thirteen minutes of 16kHz mono. Longer than any utterance and
|
||||||
|
# short enough that a wrong caller cannot exhaust memory.
|
||||||
|
MAX_BODY = int(os.environ.get("CW2_MAX_BODY", str(25 * 1024 * 1024)))
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(asctime)s cw2: %(message)s", stream=sys.stderr
|
||||||
|
)
|
||||||
|
log = logging.getLogger("cw2")
|
||||||
|
|
||||||
|
_model = None
|
||||||
|
# The card holds one model and transcribes one utterance at a time. The lock is
|
||||||
|
# what makes a second caller wait rather than corrupt the first.
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def load_model():
|
||||||
|
global _model
|
||||||
|
from crisperwhisper import CrisperWhisperModel
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
# backend is forced. With ctranslate2 importable, "auto" picks ct2, which is
|
||||||
|
# CUDA-only and this card is AMD.
|
||||||
|
m = CrisperWhisperModel(
|
||||||
|
SIZE, backend="transformers", compute_type="float16", device="cuda"
|
||||||
|
)
|
||||||
|
_model = m
|
||||||
|
log.info("loaded %s in %.1fs, mode=%s", SIZE, time.perf_counter() - t0, MODE)
|
||||||
|
|
||||||
|
|
||||||
|
def authorised(headers):
|
||||||
|
if not TOKEN:
|
||||||
|
return True
|
||||||
|
got = headers.get("Authorization", "")
|
||||||
|
return hmac.compare_digest(got, "Bearer " + TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
log.info(fmt, *args)
|
||||||
|
|
||||||
|
def _send(self, code, payload):
|
||||||
|
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
if self.path.rstrip("/") != "/health":
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
return
|
||||||
|
if _model is None:
|
||||||
|
self._send(503, {"status": "loading"})
|
||||||
|
return
|
||||||
|
self._send(200, {"status": "ok", "model": SIZE, "mode": MODE})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path.rstrip("/") != "/transcribe":
|
||||||
|
self._send(404, {"error": "not found"})
|
||||||
|
return
|
||||||
|
if not authorised(self.headers):
|
||||||
|
self._send(401, {"error": "unauthorised"})
|
||||||
|
return
|
||||||
|
if _model is None:
|
||||||
|
self._send(503, {"error": "loading"})
|
||||||
|
return
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if length <= 0 or length > MAX_BODY:
|
||||||
|
self._send(413, {"error": "bad body length"})
|
||||||
|
return
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
|
||||||
|
rate = int(self.headers.get("X-Sample-Rate", "16000"))
|
||||||
|
channels = int(self.headers.get("X-Channels", "1"))
|
||||||
|
bits = int(self.headers.get("X-Sample-Bits", "16"))
|
||||||
|
lang = self.headers.get("X-Language", "ru") or "ru"
|
||||||
|
if channels != 1 or bits != 16:
|
||||||
|
self._send(400, {"error": "want 16-bit mono pcm"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# int16 little-endian to the float32 the encoder wants.
|
||||||
|
wav = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
|
||||||
|
if wav.size == 0:
|
||||||
|
self._send(200, {"text": "", "confidence": 0.0})
|
||||||
|
return
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with _lock:
|
||||||
|
res = _model.transcribe(wav, sr=rate, language=lang, mode=MODE)
|
||||||
|
except Exception as exc: # noqa: BLE001 - the caller falls back to mavsttd
|
||||||
|
log.exception("transcribe failed")
|
||||||
|
self._send(500, {"error": str(exc)})
|
||||||
|
return
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
text = (res.text or "").strip()
|
||||||
|
log.info("%.2fs audio in %.2fs: %r", wav.size / rate, elapsed, text[:60])
|
||||||
|
# The model reports no calibrated score. 1.0 would be a claim, and the
|
||||||
|
# Maven side reads confidence only to log it.
|
||||||
|
self._send(200, {"text": text, "confidence": 0.0})
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if not TOKEN:
|
||||||
|
log.warning("no CW2_TOKEN set: anything on the LAN can post audio here")
|
||||||
|
# Bind before loading, so a restart answers 503 rather than refusing the
|
||||||
|
# connection. Both make Maven fall back, but only one of them says why.
|
||||||
|
srv = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||||
|
threading.Thread(target=load_model, daemon=True).start()
|
||||||
|
log.info("listening on %s:%d", HOST, PORT)
|
||||||
|
srv.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+75
-15
@@ -25,6 +25,25 @@
|
|||||||
"llm_nudges": false
|
"llm_nudges": false
|
||||||
},
|
},
|
||||||
|
|
||||||
|
"//ntfy": [
|
||||||
|
"The second reach (V-649). Until 07-08-2026 telegram was the only one, and",
|
||||||
|
"telegram needs api.telegram.org, the socks relay below and a matching ufw",
|
||||||
|
"rule — three things in series that have each failed once, and when they do",
|
||||||
|
"a sev4 nudge has nowhere to go. ntfy shares none of them: it is reached",
|
||||||
|
"directly, no relay.",
|
||||||
|
"It is not only a spare. The routing table sends sev3-away and away",
|
||||||
|
"reminders here and NOWHERE else, so with this block absent those two",
|
||||||
|
"routes hit a nil sink and vanish without a log or an outbox row.",
|
||||||
|
"The credential is an ntfy access token, scoped write-only to this one",
|
||||||
|
"topic, so a popped sink can push to it and cannot read it back. Set it in",
|
||||||
|
"deploy/telegram.env beside the telegram secrets; that file is gitignored."
|
||||||
|
],
|
||||||
|
"ntfy": {
|
||||||
|
"base_url": "https://ntfy.kvmx.ru",
|
||||||
|
"topic": "maven",
|
||||||
|
"token": "${NTFY_TOKEN}"
|
||||||
|
},
|
||||||
|
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"bot_token": "${TELEGRAM_BOT_TOKEN}",
|
"bot_token": "${TELEGRAM_BOT_TOKEN}",
|
||||||
"chat_id": "${TELEGRAM_CHAT_ID}",
|
"chat_id": "${TELEGRAM_CHAT_ID}",
|
||||||
@@ -37,7 +56,15 @@
|
|||||||
"This needs a matching ufw rule or the container's SYN is dropped:",
|
"This needs a matching ufw rule or the container's SYN is dropped:",
|
||||||
" ufw allow from 192.168.240.0/20 to any port 10808 proto tcp"
|
" ufw allow from 192.168.240.0/20 to any port 10808 proto tcp"
|
||||||
],
|
],
|
||||||
"proxy": "socks5://192.168.240.1:10808"
|
"proxy": "socks5://192.168.240.1:10808",
|
||||||
|
"//intake": [
|
||||||
|
"Read the chat as well as write to it (V-637). The poller long-polls",
|
||||||
|
"getUpdates through the same relay and accepts chat_id as the only",
|
||||||
|
"sender. Deleting this key turns inbound off again.",
|
||||||
|
"chat_id must be numeric here or the daemon refuses to start: an inbound",
|
||||||
|
"update names its chat by number, so an @-name would match nothing."
|
||||||
|
],
|
||||||
|
"intake": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"//workstation": [
|
"//workstation": [
|
||||||
@@ -51,10 +78,30 @@
|
|||||||
"Addressed by LAN address, not container name: mavgpud runs on another",
|
"Addressed by LAN address, not container name: mavgpud runs on another",
|
||||||
"machine and there is no shared docker network to name it on."
|
"machine and there is no shared docker network to name it on."
|
||||||
],
|
],
|
||||||
|
"//workstation.stt": [
|
||||||
|
"CrisperWhisper 2.0 turbo on the same machine, a second service on port",
|
||||||
|
"8081 and not a second endpoint on mavgpud. whisper.cpp cannot load CW2 at",
|
||||||
|
"all: it derives its language count from the vocabulary size, and CW2's",
|
||||||
|
"51897 tokens shift seven special token ids. So it runs under transformers",
|
||||||
|
"there and mavsttd stays whisper.cpp here.",
|
||||||
|
"Worth the second service: CW2 turbo scores 10.4% WER in Russian against",
|
||||||
|
"27.5% for the ggml-small.bin mavsttd loads, measured on 200 Golos clips",
|
||||||
|
"in docs/evals/2026-08-09-crisperwhisper2-russian-wer.md.",
|
||||||
|
"Deleting this block sends every utterance to mavsttd, which is what the",
|
||||||
|
"box did before it existed. A worse transcript is still a turn, so the",
|
||||||
|
"fallback is silent and Kami is never told which machine heard him.",
|
||||||
|
"The token is what stops anything on the LAN posting audio to that port."
|
||||||
|
],
|
||||||
"workstation": {
|
"workstation": {
|
||||||
"url": "http://192.168.1.105:8080",
|
"url": "http://192.168.1.105:8080",
|
||||||
"probe": "15s",
|
"probe": "15s",
|
||||||
"timeout": "90s"
|
"timeout": "90s",
|
||||||
|
"stt": {
|
||||||
|
"url": "http://192.168.1.105:8081/transcribe",
|
||||||
|
"token": "${MAVEN_STT_TOKEN}",
|
||||||
|
"probe": "15s",
|
||||||
|
"timeout": "10s"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"//search": [
|
"//search": [
|
||||||
@@ -204,7 +251,8 @@
|
|||||||
"embedder": {
|
"embedder": {
|
||||||
"model_path": "/opt/maven/models/embedder/multilingual-e5-small/model_quantized.onnx",
|
"model_path": "/opt/maven/models/embedder/multilingual-e5-small/model_quantized.onnx",
|
||||||
"tokenizer_path": "/opt/maven/models/embedder/multilingual-e5-small/tokenizer.json",
|
"tokenizer_path": "/opt/maven/models/embedder/multilingual-e5-small/tokenizer.json",
|
||||||
"lib_path": "/opt/maven/lib/libonnxruntime.so"
|
"lib_path": "/opt/maven/lib/libonnxruntime.so",
|
||||||
|
"heads_path": "/opt/maven/models/embedder/router-heads/router_heads.onnx"
|
||||||
},
|
},
|
||||||
"llm_router": true,
|
"llm_router": true,
|
||||||
"query_min_score": 0.55,
|
"query_min_score": 0.55,
|
||||||
@@ -212,18 +260,30 @@
|
|||||||
"clarify_max_attempts": 3,
|
"clarify_max_attempts": 3,
|
||||||
"tool_timeout": "30s",
|
"tool_timeout": "30s",
|
||||||
"tools": [
|
"tools": [
|
||||||
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false },
|
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false,
|
||||||
{ "name": "ps", "cmd": ["docker", "ps"], "scope": "homelab", "destructive": false },
|
"aliases": ["статус", "покажи статус", "проверь статус"] },
|
||||||
{ "name": "uptime", "cmd": ["uptime"], "scope": "homelab", "destructive": false },
|
{ "name": "ps", "cmd": ["docker", "ps"], "scope": "homelab", "destructive": false,
|
||||||
{ "name": "disk", "cmd": ["df", "-h"], "scope": "homelab", "destructive": false },
|
"aliases": ["статус докера", "лог докера", "покажи запущенные контейнеры", "покажи контейнеры", "список контейнеров", "что запущено"] },
|
||||||
{ "name": "memory", "cmd": ["free", "-h"], "scope": "homelab", "destructive": false },
|
{ "name": "uptime", "cmd": ["uptime"], "scope": "homelab", "destructive": false,
|
||||||
{ "name": "logs", "cmd": ["journalctl", "-n", "50", "-u"], "scope": "homelab", "destructive": false },
|
"aliases": ["покажи uptime", "аптайм", "как работает сервер", "сколько работает сервер"] },
|
||||||
{ "name": "restart", "cmd": ["systemctl", "restart"], "scope": "homelab", "destructive": true },
|
{ "name": "disk", "cmd": ["df", "-h"], "scope": "homelab", "destructive": false,
|
||||||
{ "name": "stop", "cmd": ["systemctl", "stop"], "scope": "homelab", "destructive": true },
|
"aliases": ["сколько места на диске", "сколько свободного места на диске", "место на диске", "покажи диск"] },
|
||||||
{ "name": "start", "cmd": ["systemctl", "start"], "scope": "homelab", "destructive": true },
|
{ "name": "memory", "cmd": ["free", "-h"], "scope": "homelab", "destructive": false,
|
||||||
{ "name": "docker-restart", "cmd": ["docker", "restart"], "scope": "homelab", "destructive": true },
|
"aliases": ["свободная память", "сколько оперативной памяти свободно", "покажи память"] },
|
||||||
{ "name": "docker-stop", "cmd": ["docker", "stop"], "scope": "homelab", "destructive": true },
|
{ "name": "logs", "cmd": ["journalctl", "-n", "50", "-u"], "scope": "homelab", "destructive": false,
|
||||||
{ "name": "reboot", "cmd": ["systemctl", "reboot"], "scope": "homelab", "destructive": true }
|
"aliases": ["покажи логи", "логи", "лог"] },
|
||||||
|
{ "name": "restart", "cmd": ["systemctl", "restart"], "scope": "homelab", "destructive": true,
|
||||||
|
"aliases": ["перезапусти", "перезагрузи", "рестарт"] },
|
||||||
|
{ "name": "stop", "cmd": ["systemctl", "stop"], "scope": "homelab", "destructive": true,
|
||||||
|
"aliases": ["останови", "останови сервис"] },
|
||||||
|
{ "name": "start", "cmd": ["systemctl", "start"], "scope": "homelab", "destructive": true,
|
||||||
|
"aliases": ["запусти", "запусти сервис"] },
|
||||||
|
{ "name": "docker-restart", "cmd": ["docker", "restart"], "scope": "homelab", "destructive": true,
|
||||||
|
"aliases": ["перезапусти контейнер", "перезагрузи контейнер"] },
|
||||||
|
{ "name": "docker-stop", "cmd": ["docker", "stop"], "scope": "homelab", "destructive": true,
|
||||||
|
"aliases": ["останови контейнер"] },
|
||||||
|
{ "name": "reboot", "cmd": ["systemctl", "reboot"], "scope": "homelab", "destructive": true,
|
||||||
|
"aliases": ["перезагрузи сервер", "перезагрузи хост"] }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-5
@@ -2,9 +2,14 @@
|
|||||||
"listen": ":8080",
|
"listen": ":8080",
|
||||||
"llama_addr": "127.0.0.1:10000",
|
"llama_addr": "127.0.0.1:10000",
|
||||||
"llama_bin": "llama-server",
|
"llama_bin": "llama-server",
|
||||||
|
"//llama_args": [
|
||||||
|
"E4B carries no MTP tensors, so the speculative flags are gone with the 12B.",
|
||||||
|
"MTP on this box is a separate gguf of architecture gemma4-assistant with",
|
||||||
|
"nextn_predict_layers=4, and mtp-gemma-4-12B-it-BF16 is the only one there is.",
|
||||||
|
"Its head is trained against the 12B's hidden states, so it cannot drive E4B."
|
||||||
|
],
|
||||||
"llama_args": [
|
"llama_args": [
|
||||||
"-m", "/mnt/D/AI/gemma4/gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
|
"-m", "/mnt/D/AI/gemma4/gemma-4-E4B-it-qat-UD-Q4_K_XL.gguf",
|
||||||
"-md", "/mnt/D/AI/gemma4/mtp-gemma-4-12B-it-BF16.gguf",
|
|
||||||
"-ngl", "99",
|
"-ngl", "99",
|
||||||
"-fa", "on",
|
"-fa", "on",
|
||||||
"-np", "1",
|
"-np", "1",
|
||||||
@@ -15,11 +20,22 @@
|
|||||||
"--batch-size", "2048",
|
"--batch-size", "2048",
|
||||||
"--ubatch-size", "512",
|
"--ubatch-size", "512",
|
||||||
"--jinja",
|
"--jinja",
|
||||||
"--chat-template-kwargs", "{\"enable_thinking\":false}",
|
"--chat-template-kwargs", "{\"enable_thinking\":false}"
|
||||||
"--spec-type", "draft-mtp",
|
|
||||||
"--spec-draft-n-max", "2"
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
"//stt": [
|
||||||
|
"CrisperWhisper 2.0 turbo, which Maven reaches directly on port 8081.",
|
||||||
|
"mavgpud runs it because it is a ROCm process on this card: under its own",
|
||||||
|
"systemd unit it registered on the KFD and the supervisor evicted",
|
||||||
|
"llama-server every few seconds. CW2_TOKEN comes from the unit's",
|
||||||
|
"EnvironmentFile and is never a flag value."
|
||||||
|
],
|
||||||
|
"stt": {
|
||||||
|
"addr": "127.0.0.1:8081",
|
||||||
|
"bin": "/home/kami/Programs/cw2-eval/.venv/bin/python",
|
||||||
|
"args": ["/home/kami/Programs/cw2-service/serve.py"]
|
||||||
|
},
|
||||||
|
|
||||||
"kfd_root": "/sys/class/kfd/kfd/proc",
|
"kfd_root": "/sys/class/kfd/kfd/proc",
|
||||||
"drm_device": "/sys/class/drm/card1/device",
|
"drm_device": "/sys/class/drm/card1/device",
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
# Runs on the workstation (bugmachine), not on homesrv. Install as a systemd
|
# Runs on the workstation (workpc), not on homesrv. Install as a systemd
|
||||||
# user unit and turn on lingering, so the card is supervised after a reboot
|
# user unit and turn on lingering, so the card is supervised after a reboot
|
||||||
# with nobody logged in:
|
# with nobody logged in:
|
||||||
#
|
#
|
||||||
@@ -8,10 +8,15 @@
|
|||||||
# scp deploy/mavgpud.service workpc:~/.config/systemd/user/mavgpud.service
|
# scp deploy/mavgpud.service workpc:~/.config/systemd/user/mavgpud.service
|
||||||
# ssh workpc 'systemctl --user daemon-reload && systemctl --user enable --now mavgpud'
|
# ssh workpc 'systemctl --user daemon-reload && systemctl --user enable --now mavgpud'
|
||||||
# sudo loginctl enable-linger kami
|
# sudo loginctl enable-linger kami
|
||||||
Description=Maven GPU supervisor (holds llama-server while the card is free)
|
Description=Maven GPU supervisor (holds llama-server and CW2 while the card is free)
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
|
# CW2_TOKEN for the transcriber child, which inherits this environment. The
|
||||||
|
# token is read from a file and never appears as a flag value, the rule
|
||||||
|
# mavpoll and mavmaild follow. Missing file, no transcriber auth, so keep the
|
||||||
|
# dash off: a mavgpud that cannot read it must fail loudly.
|
||||||
|
EnvironmentFile=%h/Programs/cw2-service/cw2.env
|
||||||
ExecStart=%h/.local/bin/mavgpud -config %h/.config/mavgpud.json
|
ExecStart=%h/.local/bin/mavgpud -config %h/.config/mavgpud.json
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Telegram bot token and chat ID for mavend's away-channel reach.
|
# Secrets for mavend's away-channel reaches. The file is still called
|
||||||
|
# telegram.env because compose names it that; it holds both reaches now.
|
||||||
# Copy this file to deploy/telegram.env and fill in real values.
|
# Copy this file to deploy/telegram.env and fill in real values.
|
||||||
# deploy/telegram.env is gitignored — never commit the real secrets.
|
# deploy/telegram.env is gitignored — never commit the real secrets.
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# ntfy access token for the `maven` topic, the second reach (V-649). Mint it on
|
||||||
|
# the ntfy server with write access to that topic and nothing else:
|
||||||
|
# ntfy token add --expires=never maven
|
||||||
|
# Read access is not needed — mavend publishes and never subscribes.
|
||||||
|
NTFY_TOKEN=
|
||||||
|
|||||||
@@ -157,6 +157,44 @@ services:
|
|||||||
# - maildata:/var/lib/mavmaild
|
# - maildata:/var/lib/mavmaild
|
||||||
# - ./deploy/imap.password:/run/secrets/imap.password:ro
|
# - ./deploy/imap.password:/run/secrets/imap.password:ro
|
||||||
|
|
||||||
|
# The calendar reader (Vikunja #644) is OFF and commented out: it needs a
|
||||||
|
# CalDAV account, and there is none on this box. It was built, listed in
|
||||||
|
# `make build`, and deployed nowhere, which is the worst of the three states —
|
||||||
|
# this block records the decision instead.
|
||||||
|
#
|
||||||
|
# What its absence costs, so the cost is visible from here:
|
||||||
|
# - Agenda questions route correctly and answer from nothing. Stage 0 sends
|
||||||
|
# "что у меня сегодня" to IntentQuery (V-498) and the `calendar` query
|
||||||
|
# source reads facts(kind=env, source=caldav:*) that nobody writes.
|
||||||
|
# - The nudge gate loses a suppressor. loop.State.CalendarBusy is fed by
|
||||||
|
# those same facts, so "do not nag mid-meeting" is permanently false.
|
||||||
|
#
|
||||||
|
# Core never sees the CalDAV password: the reader polls the collection itself
|
||||||
|
# and hands core one fact per event over WriteFact. Nothing here can create a
|
||||||
|
# reminder, so a misread event cannot fire.
|
||||||
|
#
|
||||||
|
# The password is read from a FILE, so it never appears in `ps`, in this file,
|
||||||
|
# or in shell history — the same rule mavpoll and mavmaild follow.
|
||||||
|
#
|
||||||
|
# To enable: write the password to deploy/caldav.password (0600, gitignored),
|
||||||
|
# point -url at the collection, and uncomment this service. No mavend.json
|
||||||
|
# block is needed — events arrive over IPC as facts. -render-url is optional
|
||||||
|
# and OFF here: it publishes Maven's own reminders back as events, and it must
|
||||||
|
# not name the collection -url reads, or the poller reads its own writes back
|
||||||
|
# in (checkRenderTarget refuses that). It takes -render-pass-file, and falls
|
||||||
|
# back to this password when that is not given.
|
||||||
|
# mavcaldav:
|
||||||
|
# <<: *image
|
||||||
|
# command: ["mavcaldav", "-socket", "/run/maven/mavend.sock",
|
||||||
|
# "-url", "http://localhost:5232/kami/personal",
|
||||||
|
# "-user", "kami",
|
||||||
|
# "-pass-file", "/run/secrets/caldav.password",
|
||||||
|
# "-interval", "5m"]
|
||||||
|
# depends_on: [mavend]
|
||||||
|
# volumes:
|
||||||
|
# - sockets:/run/maven
|
||||||
|
# - ./deploy/caldav.password:/run/secrets/caldav.password:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
sockets:
|
sockets:
|
||||||
|
|||||||
+57
-1
@@ -1,6 +1,6 @@
|
|||||||
# Maven — Design
|
# Maven — Design
|
||||||
|
|
||||||
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
*Last verified: 2026-08-07 @ beb093a. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
> Folded 2026-07-30 from `SPEC.md` (north star, 2026-07-03), `maven.md`
|
> Folded 2026-07-30 from `SPEC.md` (north star, 2026-07-03), `maven.md`
|
||||||
> (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan,
|
> (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan,
|
||||||
@@ -282,6 +282,62 @@ Three reasons, in the order they settle it:
|
|||||||
So the notice stays what it is: the in-process TTL case, where she really did
|
So the notice stays what it is: the in-process TTL case, where she really did
|
||||||
wait and really did let go.
|
wait and really did let go.
|
||||||
|
|
||||||
|
#### A parked question may step aside three times
|
||||||
|
|
||||||
|
Decided 2026-08-07 (V-654). A side query or an aside suspends the parked
|
||||||
|
question instead of dropping it. The words are answered as themselves, and the
|
||||||
|
question comes back on the end of the same reply.
|
||||||
|
|
||||||
|
Neither bound on a question reaches that path. No attempt is spent, because a
|
||||||
|
side query is not a failed answer, so `MaxAttempts` never applies.
|
||||||
|
`noteSuspended` also restarts the 90s clock, since she is about to speak the
|
||||||
|
question again. So the TTL cannot arrive while he keeps talking.
|
||||||
|
|
||||||
|
Measured on 2026-08-07: one unfilled time slot rode the tail of six consecutive
|
||||||
|
unrelated replies. It stopped only when a seventh turn happened to read as a
|
||||||
|
failed answer. See `docs/evals/2026-08-07-week-of-usage.md`.
|
||||||
|
|
||||||
|
`PendingQuestion.Suspends` counts the step-asides. `MaxSuspends` is 3, matching
|
||||||
|
`DefaultMaxAttempts`. Past it she lets the request go, with the same
|
||||||
|
`clarifyDropped` line every other drop uses. The owner's rule is unchanged. A
|
||||||
|
question still ends by being answered or by being let go out loud. This only
|
||||||
|
recognises three unrelated requests in a row as the second of those.
|
||||||
|
|
||||||
|
The count is of CONSECUTIVE step-asides. It resets the moment he answers, in
|
||||||
|
`resolveClarifyAnswer`. An answer that gives her nothing she asked for resets it
|
||||||
|
too. "Позвонить маме" against a question about the time is still him in the
|
||||||
|
exchange. The retry it costs is bound enough on its own.
|
||||||
|
|
||||||
|
#### And it may ride four turns in all
|
||||||
|
|
||||||
|
Decided 2026-08-08 (V-663), because the bound above did not move the number it
|
||||||
|
was written for. Twenty-six of 140 turns carried a tail before it landed and
|
||||||
|
twenty-six carried one after.
|
||||||
|
|
||||||
|
Two bounds rearm each other. An aside spends no attempt, so `MaxAttempts` never
|
||||||
|
reaches it. A turn that reads as a failed answer zeroes `Suspends`, so
|
||||||
|
`MaxSuspends` never reaches the asides. Alternating them, each bound is restored
|
||||||
|
by the other's traffic. Measured on 2026-08-08: one question about a reminder's
|
||||||
|
day rode turns 7 to 13. It ended only because turn 14 was a new request.
|
||||||
|
|
||||||
|
`PendingQuestion.Rides` counts the same event as `Suspends` with the resets
|
||||||
|
taken out. It is set once, incremented only in `noteSuspended`, carried across
|
||||||
|
the re-park in `askRemainingGap`, and read by nothing that could lower it.
|
||||||
|
`MaxRides` is 4, one looser than `MaxSuspends` so that the tighter statement
|
||||||
|
about a run stays reachable.
|
||||||
|
|
||||||
|
This is a bound, not a cure. It ends the measured ride one turn early. Most of
|
||||||
|
that ride's length is attempts, spent because `classifyTurnRole` reads "спасибо"
|
||||||
|
and "привет" as failed answers to a question about a day. That is the next
|
||||||
|
thing to fix and it is not a bound.
|
||||||
|
|
||||||
|
The re-ask is also two sentences rather than one. It used to be spliced onto the
|
||||||
|
answer with a comma. On a real answer that buries the question in the tail of
|
||||||
|
one run-on thought:
|
||||||
|
|
||||||
|
> вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить
|
||||||
|
> напоминание?
|
||||||
|
|
||||||
### save-where — the two-memory routing axis
|
### save-where — the two-memory routing axis
|
||||||
|
|
||||||
One discriminator: **does the loop evaluate a predicate against it?**
|
One discriminator: **does the loop evaluate a predicate against it?**
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Alarm verbs reach stage 0
|
||||||
|
|
||||||
|
**06-08-2026. V-627.** Measured with `TestONNXBaseline`, 91-case RU routing fixture,
|
||||||
|
classifier plus the ONNX embedder. No LLM arm in this run.
|
||||||
|
|
||||||
|
## What was wrong
|
||||||
|
|
||||||
|
`lexicon.ReminderVerbs` held five words and none of them named an alarm. `ReminderGrammar`
|
||||||
|
in `internal/router/stage0.go` did not read the set at all: it carried the literal
|
||||||
|
`напомни|remind me`. So no part of the cascade recognised `разбуди`.
|
||||||
|
|
||||||
|
Three fixture cases ride on that. Under the classifier they went to fact and act at high
|
||||||
|
confidence, so the failure was never a near miss:
|
||||||
|
|
||||||
|
- `ru-rem-005` "разбуди меня в 6:30" to fact at 0.918
|
||||||
|
- `ru-rem-009` "разбуди меня полвосьмого" to act at 0.941
|
||||||
|
- `en-rem-002` "wake me at 6:15" to fact at 0.899
|
||||||
|
|
||||||
|
Found while training the V-546 intent head, where the same three cases went to system. The
|
||||||
|
head reads a spoken time with no known verb in front of it as a clock question. The
|
||||||
|
classifier was making the same mistake in its own way.
|
||||||
|
|
||||||
|
## The change
|
||||||
|
|
||||||
|
Four alarm imperatives and bare `wake` join `reminder_verbs`. `ReminderGrammar` builds its
|
||||||
|
alternation from the set, longest alternative first, and eats an optional `мне`, `меня` or
|
||||||
|
`me` before the body.
|
||||||
|
|
||||||
|
Longest-first is load-bearing. Go's alternation is leftmost-first rather than longest-match,
|
||||||
|
so `напомнить` listed after `напомни` would never match.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**66/91 to 69/91, 72.5% to 75.8% full.** Three cases gained, none lost.
|
||||||
|
|
||||||
|
All three are the alarms above, and each now carries its time slot, which it did not before.
|
||||||
|
Clarify counts unchanged at 0 false and 8 missed. The two remaining system failures,
|
||||||
|
`какое число завтра` and `какой день недели послезавтра`, failed at baseline too.
|
||||||
|
|
||||||
|
## What this does not fix
|
||||||
|
|
||||||
|
The lexicon addition on its own moved nothing. Measured before touching the grammar:
|
||||||
|
**66/91**, exactly the baseline. Every consumer of `reminder_verbs` reads it after a reminder
|
||||||
|
route already exists. A verb that cannot win the route is a verb nobody asks about. The
|
||||||
|
grammar was the whole change.
|
||||||
|
|
||||||
|
Lemma matching in `isReminderVerb` now covers `разбудил` as well as `разбуди`, because one
|
||||||
|
lemma holds both. That is the trap `cmd/mavend/quiet_toggle.go` documents for `говори`. It
|
||||||
|
is tolerable here and not in the quiet toggle. `isReminderVerb` runs only on an utterance
|
||||||
|
already routed to reminder, and it decides where the subject starts. A quiet match flips a
|
||||||
|
daemon-wide setting from any channel.
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# The fact parser: closed classes against the substring stems they replaced
|
||||||
|
|
||||||
|
Measured 2026-08-06 at 22edc3c and its parent 0445693, on the corpus in
|
||||||
|
`internal/router/factparser_corpus_test.go`. Dated file: it is not edited after
|
||||||
|
today, and a newer number is a new file.
|
||||||
|
|
||||||
|
V-586 rewrote `DefaultFactParser` off hand-written Russian stems onto six closed
|
||||||
|
classes in `internal/lexicon`. Its commit message reported 64/91 on the RU
|
||||||
|
routing fixture, unchanged. That number does not bear on the change: the fixture
|
||||||
|
holds three fact cases and all three miss on intent, so the parser is never
|
||||||
|
reached. This file measures the parser directly, and runs the LLM arm the
|
||||||
|
original commit skipped.
|
||||||
|
|
||||||
|
**The rewrite is better on the utterances it was designed for and no worse on
|
||||||
|
the ones it was not.** True positives go 35/40 to 39/40, misfires rejected go
|
||||||
|
8/15 to 14/15. What neither version has is coverage: of 36 plausible utterances
|
||||||
|
whose word is in no lexicon set, the substring parser caught 3 by accident and
|
||||||
|
the closed-class parser catches 0. That is the honest headline. The word list
|
||||||
|
did not shrink the vocabulary — it never had one — it made the boundary visible.
|
||||||
|
|
||||||
|
## The corpus
|
||||||
|
|
||||||
|
91 cases, three classes. **True positives** are sentences the owner would say,
|
||||||
|
with the key that must be written. **Misfires** are sentences the substring
|
||||||
|
parser wrote a fact for and should not have, including the three hard negatives
|
||||||
|
the rewrite was argued on. **False negatives** are sentences a reasonable person
|
||||||
|
would say whose word is in no set at all; `want` is the key a human would
|
||||||
|
assign, and the ship parser is expected to miss them. They are the measurement
|
||||||
|
of what a closed class costs, not a bug list.
|
||||||
|
|
||||||
|
The old parser is carried in the test file as `legacyFactParse`, copied verbatim
|
||||||
|
from 0445693, so the comparison reruns:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deps/go/go/bin/go test -run TestFactParserCorpus -v ./internal/router/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Score
|
||||||
|
|
||||||
|
| | true positives | misfires rejected | false-negative cases recovered |
|
||||||
|
|---|---|---|---|
|
||||||
|
| old (substring stems, 0445693) | 35/40 | 8/15 | 3/36 |
|
||||||
|
| **new (closed classes, 22edc3c)** | **39/40** | **14/15** | **0/36** |
|
||||||
|
|
||||||
|
Sixteen cases disagree. Eleven of them the rewrite wins, three it loses, and two
|
||||||
|
are cases neither gets.
|
||||||
|
|
||||||
|
**Won.** Every misfire the commit message named — `душа болит`, `в комнате
|
||||||
|
душно`, `это была беда`, `наша победа`, `на душе легко` — plus `водитель пилота
|
||||||
|
ждёт`, where two stems in one sentence made the old water arm fire. And five true
|
||||||
|
positives the stems simply did not list: `перекусил`, `передохнул`, `отдыхаю`,
|
||||||
|
`пойду спать`, `i showered`. Morphology buys those; a stem list would need a new
|
||||||
|
entry for each.
|
||||||
|
|
||||||
|
**Lost.** `допил воду` is a real regression and the only failing true positive.
|
||||||
|
The vendored dictionary lemmatises `допил` to `допилить`, to finish sawing —
|
||||||
|
exactly the collision `drink_verbs` already carries `пил` and `пили` as surface
|
||||||
|
forms to dodge, left unhandled for the prefixed form. `допить` is in the set and
|
||||||
|
the sentence still misses. It is flagged `broken` in the corpus rather than
|
||||||
|
fixed, because this branch measures.
|
||||||
|
|
||||||
|
`был в душе` and `после душа полегчало` are the price of matching the shower set
|
||||||
|
exactly. The dictionary makes `душ` and `душа` one word, so a lemma test cannot
|
||||||
|
tell a shower from a soul; exact matching keeps `на душе легко` out and loses
|
||||||
|
the oblique cases of the real noun with it. The old parser got both by accident,
|
||||||
|
along with the soul. That trade is right — writing a shower fact when he said
|
||||||
|
his soul feels light is worse than missing one — but it is a trade and the two
|
||||||
|
rows are what it costs.
|
||||||
|
|
||||||
|
`недоспал` is the third loss and the least defensible: the old substring `спал`
|
||||||
|
caught it, and `недоспать` is in no set.
|
||||||
|
|
||||||
|
**Neither.** `обеденный перерыв отменили` — a cancelled lunch break — is a fact
|
||||||
|
for both parsers, `meal` for the old one off the adjective and `break` for the
|
||||||
|
new one off `перерыв`. Nothing in either design reads the cancellation.
|
||||||
|
`дрых до обеда` is scored `meal` by both, because the meal arm runs first and
|
||||||
|
`обеда` is in it, which is not wrong so much as beside the point.
|
||||||
|
|
||||||
|
## The false-negative surface
|
||||||
|
|
||||||
|
This is the half the routing fixture cannot see and the half that decides
|
||||||
|
whether the design holds. 36 cases, 0 recovered:
|
||||||
|
|
||||||
|
- **water** — `выпил чаю`, `глотнул воды`, `хлебнул воды`, `выпил стакан`,
|
||||||
|
`i hydrated`, `finished my bottle of water`. The water arm needs a noun AND a
|
||||||
|
verb, so an elided noun or an unlisted verb drops the whole capture.
|
||||||
|
- **meal** — `ем суп`, `съел бутерброд`, `наелся`, `пожрал`, `полдник был`,
|
||||||
|
`snack`, `supper`, `brunch`, `i eat now`. `есть` is deliberately absent for
|
||||||
|
`есть новости по бэкапу`, and `ем`, its most ordinary spoken form, goes with it.
|
||||||
|
- **shower** — `помылся`, `сходил в ванную`, `искупался`, `i am showering`,
|
||||||
|
plus the two oblique cases above.
|
||||||
|
- **break** — `сделал передышку`, `перекур`, `полежал немного`, `сделал паузу`,
|
||||||
|
`i took five`, `resting now`.
|
||||||
|
- **sleep** — `вздремнул`, `прикорнул`, `дрых`, `недоспал`, `лёг в двенадцать`,
|
||||||
|
`сон был короткий`, `i napped`, `took a nap`.
|
||||||
|
|
||||||
|
None of these are exotic. They are the second and third word a person reaches
|
||||||
|
for, and every one of them is a fact the owner stated and Maven silently did not
|
||||||
|
record. A silent miss is the worst failure mode this parser has: he said it, she
|
||||||
|
heard it, nothing was written, and nothing told him.
|
||||||
|
|
||||||
|
## The routing fixture, LLM arm
|
||||||
|
|
||||||
|
The arm 22edc3c skipped. `MAVEN_LLM_URL` points the harness at any llama-server;
|
||||||
|
the previous run reported none reachable, which was the shell's `HTTP_PROXY` and
|
||||||
|
not the network. Run against **gemma-4-12B-it-qat-UD-Q4_K_XL on the workstation
|
||||||
|
at `192.168.1.105:8080`**, the same box as the 02-08 measurement, with
|
||||||
|
`NO_PROXY=192.168.1.105`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
NO_PROXY=192.168.1.105 no_proxy=192.168.1.105 \
|
||||||
|
make eval-models MAVEN_LLM_URL=http://192.168.1.105:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
| | full | intent-only | p50 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| llm-only, 0445693 | 51.6% (47/91) | 82.4% | — |
|
||||||
|
| llm-only, 22edc3c | 52.7% (48/91) | 83.5% | 341ms |
|
||||||
|
| cascade+llm, 0445693 | 85.7% (78/91) | 93.4% | — |
|
||||||
|
| **cascade+llm, 22edc3c** | **86.8% (79/91)** | **94.5%** | 334ms |
|
||||||
|
|
||||||
|
One case either way, both directions, and the failing set is identical between
|
||||||
|
the two commits. That is run-to-run variance on a sampling model, not a signal.
|
||||||
|
The parser change is invisible to the routing fixture on the LLM arm for the
|
||||||
|
same reason it is invisible on the classifier arm: the three fact cases miss on
|
||||||
|
intent and the parser is never called. Do not read these rows as evidence about
|
||||||
|
the parser. They are evidence that the fixture cannot answer the question, which
|
||||||
|
is why the corpus above exists.
|
||||||
|
|
||||||
|
## Verdict
|
||||||
|
|
||||||
|
The closed-class rewrite holds up as a rewrite. It is strictly better than what
|
||||||
|
it replaced on both classes anyone argued about, and the one regression
|
||||||
|
(`допил`) and one bad trade (the oblique `душ`) are both dictionary collisions
|
||||||
|
rather than design faults.
|
||||||
|
|
||||||
|
It does not hold up as an answer. A closed class is the right mechanism for a
|
||||||
|
set that is actually closed — interrogatives, weekdays, cardinals — and
|
||||||
|
"the words a person uses to say he ate" is not that set. The corpus puts a
|
||||||
|
number on it: 36 ordinary sentences, 0 recovered, and every new one costs a
|
||||||
|
lexicon edit by whoever notices. The three mechanisms CLAUDE.md names do not
|
||||||
|
contain the right one for this job. The embedder-topic mechanism is the closest
|
||||||
|
fit and is wrong too, because this is slot extraction rather than aboutness.
|
||||||
|
|
||||||
|
This is a case for the V-546 slot-tagging head. Self-care facts are a bounded
|
||||||
|
key space (five keys) over unbounded surface forms, which is exactly what a BIO
|
||||||
|
tagger on e5-small is for: it generalises to `вздремнул` without anyone adding
|
||||||
|
`вздремнуть` to a list, and max softmax gives the confidence the parser's
|
||||||
|
hardcoded `true` does not have. Until it lands, the closed classes are the
|
||||||
|
correct floor and the 36 rows above are the size of the gap they leave.
|
||||||
|
|
||||||
|
## The corpus, case by case
|
||||||
|
|
||||||
|
| utterance | class | want | old (substring) | new (closed class) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `выпил стакан воды` | tp | water | water | water |
|
||||||
|
| `попил воды` | tp | water | water | water |
|
||||||
|
| `я попил водички` | tp | water | water | water |
|
||||||
|
| `пью воду` | tp | water | water | water |
|
||||||
|
| `воду пил уже` | tp | water | water | water |
|
||||||
|
| `допил воду` | tp | water | water | — **≠** |
|
||||||
|
| `запил таблетку водой` | tp | water | water | water |
|
||||||
|
| `drank water` | tp | water | water | water |
|
||||||
|
| `i drank some water` | tp | water | water | water |
|
||||||
|
| `поужинал` | tp | meal | meal | meal |
|
||||||
|
| `я пообедал` | tp | meal | meal | meal |
|
||||||
|
| `позавтракал кашей` | tp | meal | meal | meal |
|
||||||
|
| `перекусил бутербродом` | tp | meal | — | meal **≠** |
|
||||||
|
| `покушал` | tp | meal | meal | meal |
|
||||||
|
| `поел супа` | tp | meal | meal | meal |
|
||||||
|
| `обед был в час` | tp | meal | meal | meal |
|
||||||
|
| `ужинать буду позже` | tp | meal | meal | meal |
|
||||||
|
| `i ate` | tp | meal | meal | meal |
|
||||||
|
| `had lunch` | tp | meal | meal | meal |
|
||||||
|
| `dinner done` | tp | meal | meal | meal |
|
||||||
|
| `принял душ` | tp | shower | shower | shower |
|
||||||
|
| `сходил в душ` | tp | shower | shower | shower |
|
||||||
|
| `душ принят` | tp | shower | shower | shower |
|
||||||
|
| `ополоснулся душем` | tp | shower | shower | shower |
|
||||||
|
| `took a shower` | tp | shower | shower | shower |
|
||||||
|
| `i showered` | tp | shower | — | shower **≠** |
|
||||||
|
| `сделал перерыв` | tp | break | break | break |
|
||||||
|
| `отдохнул полчаса` | tp | break | break | break |
|
||||||
|
| `передохнул немного` | tp | break | — | break **≠** |
|
||||||
|
| `отдыхаю` | tp | break | — | break **≠** |
|
||||||
|
| `был перерыв на обед` | tp | meal | meal | meal |
|
||||||
|
| `took a break` | tp | break | break | break |
|
||||||
|
| `спал восемь часов` | tp | sleep | sleep | sleep |
|
||||||
|
| `спала плохо` | tp | sleep | sleep | sleep |
|
||||||
|
| `поспал днём` | tp | sleep | sleep | sleep |
|
||||||
|
| `выспался наконец` | tp | sleep | sleep | sleep |
|
||||||
|
| `проспал будильник` | tp | sleep | sleep | sleep |
|
||||||
|
| `пойду спать` | tp | sleep | — | sleep **≠** |
|
||||||
|
| `slept 8 hours` | tp | sleep | sleep | sleep |
|
||||||
|
| `i slept badly` | tp | sleep | sleep | sleep |
|
||||||
|
| `пилот сказал что вылет через час` | misfire | — | — | — |
|
||||||
|
| `водитель уже подъехал` | misfire | — | — | — |
|
||||||
|
| `надо заводить машину` | misfire | — | — | — |
|
||||||
|
| `душа болит` | misfire | — | shower | — **≠** |
|
||||||
|
| `в комнате душно` | misfire | — | shower | — **≠** |
|
||||||
|
| `это была беда` | misfire | — | meal | — **≠** |
|
||||||
|
| `наша победа` | misfire | — | meal | — **≠** |
|
||||||
|
| `пила лежит в гараже` | misfire | — | — | — |
|
||||||
|
| `водитель пилота ждёт` | misfire | — | water | — **≠** |
|
||||||
|
| `обеденный перерыв отменили` | misfire | — | meal | break **≠** |
|
||||||
|
| `есть новости по бэкапу базы` | misfire | — | — | — |
|
||||||
|
| `напоминания на завтра есть` | misfire | — | — | — |
|
||||||
|
| `на душе легко` | misfire | — | shower | — **≠** |
|
||||||
|
| `пилил доску весь вечер` | misfire | — | — | — |
|
||||||
|
| `поставь будильник на завтра` | misfire | — | — | — |
|
||||||
|
| `выпил чаю` | fn | water | — | — |
|
||||||
|
| `глотнул воды` | fn | water | — | — |
|
||||||
|
| `хлебнул воды` | fn | water | — | — |
|
||||||
|
| `воды хлебнул из бутылки` | fn | water | — | — |
|
||||||
|
| `выпил стакан` | fn | water | — | — |
|
||||||
|
| `i hydrated` | fn | water | — | — |
|
||||||
|
| `finished my bottle of water` | fn | water | — | — |
|
||||||
|
| `ем суп` | fn | meal | — | — |
|
||||||
|
| `съел бутерброд` | fn | meal | — | — |
|
||||||
|
| `наелся` | fn | meal | — | — |
|
||||||
|
| `пожрал` | fn | meal | — | — |
|
||||||
|
| `полдник был` | fn | meal | — | — |
|
||||||
|
| `i had a snack` | fn | meal | — | — |
|
||||||
|
| `having supper` | fn | meal | — | — |
|
||||||
|
| `brunch was good` | fn | meal | — | — |
|
||||||
|
| `i eat now` | fn | meal | — | — |
|
||||||
|
| `был в душе` | fn | shower | shower | — **≠** |
|
||||||
|
| `после душа полегчало` | fn | shower | shower | — **≠** |
|
||||||
|
| `помылся` | fn | shower | — | — |
|
||||||
|
| `сходил в ванную` | fn | shower | — | — |
|
||||||
|
| `искупался` | fn | shower | — | — |
|
||||||
|
| `i am showering` | fn | shower | — | — |
|
||||||
|
| `сделал передышку` | fn | break | — | — |
|
||||||
|
| `перекур` | fn | break | — | — |
|
||||||
|
| `полежал немного` | fn | break | — | — |
|
||||||
|
| `сделал паузу` | fn | break | — | — |
|
||||||
|
| `i took five` | fn | break | — | — |
|
||||||
|
| `resting now` | fn | break | — | — |
|
||||||
|
| `вздремнул` | fn | sleep | — | — |
|
||||||
|
| `прикорнул на диване` | fn | sleep | — | — |
|
||||||
|
| `дрых до обеда` | fn | sleep | meal | meal |
|
||||||
|
| `недоспал` | fn | sleep | sleep | — **≠** |
|
||||||
|
| `лёг в двенадцать` | fn | sleep | — | — |
|
||||||
|
| `сон был короткий` | fn | sleep | — | — |
|
||||||
|
| `i napped` | fn | sleep | — | — |
|
||||||
|
| `took a nap` | fn | sleep | — | — |
|
||||||
|
`≠` marks a disagreement. `—` is no fact written.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# The routing trajectory, and the number that is missing
|
||||||
|
|
||||||
|
**06-08-2026. V-464.** Not a new measurement. This collates the figures already recorded
|
||||||
|
in `docs/evals/` and CLAUDE.md, and names one measurement that has not been taken. Dated
|
||||||
|
because the conclusion expires the moment the missing number is measured.
|
||||||
|
|
||||||
|
## The question
|
||||||
|
|
||||||
|
126 of the 1023 commits between 03-07-2026 and 06-08-2026 touch `internal/router`. Is the
|
||||||
|
routing between the core functions and his speech getting better?
|
||||||
|
|
||||||
|
## The trajectory
|
||||||
|
|
||||||
|
RU routing fixture, classifier plus the ONNX embedder, no LLM arm in any of these runs.
|
||||||
|
|
||||||
|
| date | change | fixture | source |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 02-08-2026 | classifier re-measured | 68.8% of 77 | CLAUDE.md |
|
||||||
|
| 04-08-2026 | V-498, rest-of-day and narrative rules | 58/82, 70.7% | CLAUDE.md |
|
||||||
|
| 06-08-2026 | V-626 baseline | 64/91, 70.3% | `2026-08-06-seeds-to-prompt-boundary.md` |
|
||||||
|
| 06-08-2026 | V-626, seeds onto the prompt boundary | 66/91, 72.5% | same |
|
||||||
|
| 06-08-2026 | V-627, alarm verbs reach stage 0 | 69/91, 75.8% | `2026-08-06-alarm-verbs-reach-stage-0.md` |
|
||||||
|
| 06-08-2026 | V-633, Russian acts reach tools | 69/91, unchanged | `2026-08-06-russian-acts-reach-tools.md` |
|
||||||
|
|
||||||
|
The fixture grew from 77 to 82 to 91 cases across this window. So the percentages are
|
||||||
|
comparable and the counts are not.
|
||||||
|
|
||||||
|
## Accuracy moved late
|
||||||
|
|
||||||
|
It sat near 70% for a month. V-626 and V-627 landed the same day and took the
|
||||||
|
deterministic path from 64/91 to 69/91. That is the first real accuracy movement since the
|
||||||
|
stage-0 rules went in.
|
||||||
|
|
||||||
|
## Most of the work was reach, not accuracy
|
||||||
|
|
||||||
|
Praxis went 0/12 to 11/12 and lifecycle 0/5 to 5/5 (V-516,
|
||||||
|
`2026-08-05-praxis-reach.md`). No Russian utterance could reach a tool before V-633. That
|
||||||
|
one landed at 69/91 unchanged, because the fixture holds no case for it. Alarm verbs,
|
||||||
|
ordinal selection, spoken corrections and the claimant ladder share the shape.
|
||||||
|
|
||||||
|
So the fixture undercounts the month. Things that were structurally unreachable now reach,
|
||||||
|
and a fixture that never asked about them cannot show it. Judge reach against
|
||||||
|
`make eval-reach` and the ecosystem fixture, not against the routing one.
|
||||||
|
|
||||||
|
## The missing number
|
||||||
|
|
||||||
|
On 05-08-2026 the cascade with the resident model scored 69/91, 75.8% full, 80.2%
|
||||||
|
intent-only, at p50 1.19s (`2026-08-05-routing-resident-model.md`).
|
||||||
|
|
||||||
|
On 06-08-2026 the classifier and stage 0 alone reached 69/91, 75.8% full, at p50 22.9ms.
|
||||||
|
|
||||||
|
Those are the same full-accuracy score. The cascade has not been re-measured since V-626
|
||||||
|
and V-627 landed. Both are stage-0 changes, and stage 0 runs inside the cascade, so the
|
||||||
|
cascade should have gained from them too.
|
||||||
|
|
||||||
|
One of two things is true, and nothing on the box says which:
|
||||||
|
|
||||||
|
- The cascade gained as well, the model still separates from the floor on intent-only, and
|
||||||
|
it earns its place.
|
||||||
|
- The deterministic floor has caught up on this fixture, and the resident model is costing
|
||||||
|
1.17 seconds a turn for nothing measurable.
|
||||||
|
|
||||||
|
Take that measurement before planning more routing work. It needs a second llama-server on
|
||||||
|
a fixed host port, because the resident one binds `--port 0` inside the container.
|
||||||
|
|
||||||
|
## What this does not settle
|
||||||
|
|
||||||
|
Intent-only is the more honest comparison for the model arm. The model routes `reminder`
|
||||||
|
and leaves the time to the daemon, which is what the contract asks. The 05-08 run puts it
|
||||||
|
at 80.2% through the cascade and 61.5% for the model alone. There is no 06-08 intent-only
|
||||||
|
figure for the deterministic path to set beside those.
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Russian acts reach tools
|
||||||
|
|
||||||
|
**06-08-2026. V-633.** Measured with `TestONNXBaseline`, 91-case RU routing fixture,
|
||||||
|
classifier plus the ONNX embedder. No LLM arm in this run.
|
||||||
|
|
||||||
|
## What was wrong
|
||||||
|
|
||||||
|
Three defects, tangled enough that fixing one alone would have looked like progress.
|
||||||
|
|
||||||
|
**No Russian utterance could reach a tool.** `DefaultActMatcher` in
|
||||||
|
`internal/router/slots.go` matched an exact English prefix, and `internal/tool.Matcher`
|
||||||
|
delegated straight to it. Its comment claimed "the production matcher is fuzzy, this is the
|
||||||
|
scaffold floor". There is no other matcher, and `DefaultGrammars` is the only place
|
||||||
|
`Slots.Fn` is set at stage 0, so the floor was the ceiling. Measured with a throwaway
|
||||||
|
matcher test over the seeds:
|
||||||
|
|
||||||
|
```text
|
||||||
|
"покажи статус nginx" ok=false "restart nginx" ok=true fn=restart
|
||||||
|
"сколько места на диске" ok=false "disk" ok=true fn=disk
|
||||||
|
"свободная память" ok=false "uptime" ok=true fn=uptime
|
||||||
|
"перезагрузи роутер" ok=false
|
||||||
|
```
|
||||||
|
|
||||||
|
55 of the 69 lines in `models/seeds/act.txt` routed to `IntentAct` and then fell to
|
||||||
|
`proposeGap`. Praxis was never affected: `PraxisGrammars` fills `Slots.Fn` itself.
|
||||||
|
|
||||||
|
**Seven lines were duplicated inside `models/seeds/query.txt`.** A duplicate is a second
|
||||||
|
identical vector, so it double-weights its region in nearest-neighbour scoring.
|
||||||
|
|
||||||
|
```text
|
||||||
|
сколько человек дома
|
||||||
|
кто сейчас дома
|
||||||
|
какая загрузка процессора
|
||||||
|
сколько свободного места на диске
|
||||||
|
какой ip адрес у сервера
|
||||||
|
какая версия софта
|
||||||
|
сколько оперативной памяти свободно
|
||||||
|
```
|
||||||
|
|
||||||
|
**`как дела у сервера` carried two labels**, in `query.txt:13` and `system.txt:9`. One
|
||||||
|
string, two identical vectors, disagreeing about the answer.
|
||||||
|
|
||||||
|
## The change
|
||||||
|
|
||||||
|
Tools carry spoken aliases as config data, in `deploy/mavend.json`. They are not a Russian
|
||||||
|
stem pattern in code, which CLAUDE.md forbids. They are not on the tool row either. An
|
||||||
|
ad-hoc tool enabled through `/tools` has no aliases and needs none.
|
||||||
|
|
||||||
|
Aliases and names compete in one table, longest phrase first, so "перезагрузи контейнер"
|
||||||
|
beats "перезагрузи" and "docker-restart" is not shadowed by "restart". Matching is on exact
|
||||||
|
leading tokens rather than lemmas. `перезагрузи роутер` is a command and `перезагрузил
|
||||||
|
роутер` is a fact, and a lemma cannot tell the two apart. That is the trap
|
||||||
|
`cmd/mavend/quiet_toggle.go` documents for `говори`.
|
||||||
|
|
||||||
|
The seven duplicates are gone, and `как дела у сервера` stays in `query.txt` only. It left
|
||||||
|
`system.txt` because system cannot answer it: `replySystem`'s
|
||||||
|
память/загрузк/аптайм arm returns "системная статистика пока не подключена." and always
|
||||||
|
did. That arm is a stub, not a mode, so the mode inventory now lists the shape as
|
||||||
|
`act.tool.hoststats`.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**69/91, 75.8% full, unchanged.** Clarify counts unchanged at 0 false and 8 missed.
|
||||||
|
|
||||||
|
Nothing moved, and that is the honest number. The fixture holds no host-stat case and no
|
||||||
|
Russian act that reaches a tool, so it cannot see either fix. The new coverage is
|
||||||
|
`TestActMatcherAliases`, which asserts the twelve utterances above plus the two refusals.
|
||||||
|
|
||||||
|
## What this does not fix
|
||||||
|
|
||||||
|
Argument quality. `статус sshd` reaches `systemctl status sshd`, but `логи nginx` reaches
|
||||||
|
`journalctl -n 50 -u nginx` only because the tool's argv prefix ends in `-u`. An alias whose
|
||||||
|
remainder is a Russian noun ("перезагрузи роутер") hands `systemctl restart роутер` a target
|
||||||
|
that does not exist. Free text still reaches an argv, which is the resolution rule the
|
||||||
|
ecosystem contract states for Hexis and not yet true here.
|
||||||
|
|
||||||
|
The fixture cannot measure any of this. That is the observability gap V-629 is for.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Gemma as a label function, and what it found in the seeds
|
||||||
|
|
||||||
|
**06-08-2026. V-546.** Measured on workpc against gemma-4-12b-it-qat-UD-Q4_K_XL.
|
||||||
|
|
||||||
|
`docs/plans/18-routing-heads-on-e5-small.md` puts the labeled set at 20k examples through
|
||||||
|
gemma, costing 2 to 4 hours of the card. This is the check before spending that. Gemma
|
||||||
|
labels the 344 hand-written classifier seeds. Agreement with the label a person already
|
||||||
|
chose is a precision number rather than a guess.
|
||||||
|
|
||||||
|
## What ran
|
||||||
|
|
||||||
|
`cmd/labelgen` runs the stage 0 grammars. The real ones, in `buildRouter` order, minus
|
||||||
|
`wakeword-act`, whose allowlist is a deployment's enabled tool names. It labels 62 of 339
|
||||||
|
seed lines and leaves the rest.
|
||||||
|
|
||||||
|
The remaining 277 went to gemma through the daemon's own `routeSystem` prompt and
|
||||||
|
`routeGrammar`, both extracted from `internal/router/llmrouter.go` at run time rather than
|
||||||
|
retyped. Temperature 0.
|
||||||
|
|
||||||
|
## Cost
|
||||||
|
|
||||||
|
**334ms per call, 0 unparsed of 277.** The GBNF held every time. At that rate the plan's
|
||||||
|
20k examples is under two hours of card, which matches its estimate.
|
||||||
|
|
||||||
|
## The stage 0 rules as label functions
|
||||||
|
|
||||||
|
Agreement between the grammar's label and the seed file the line came from:
|
||||||
|
|
||||||
|
| seed intent | agree |
|
||||||
|
|---|---|
|
||||||
|
| reminder | 37/37 |
|
||||||
|
| query | 9/10 |
|
||||||
|
| system | 7/8 |
|
||||||
|
| act | 2/2 |
|
||||||
|
| chat | 0/4 |
|
||||||
|
| note | 0/1 |
|
||||||
|
|
||||||
|
`ReminderGrammar` at 37/37 is the evidence the plan wanted. The chat column is a defect
|
||||||
|
rather than a disagreement: `chatNarrativeTopics` is Russian-only, so `tell me about
|
||||||
|
yourself` survives the decline and routes IntentQuery with topic `yourself`. Filed as
|
||||||
|
V-625, which also records that `как дела у сервера` appears verbatim in two seed files
|
||||||
|
under two intents.
|
||||||
|
|
||||||
|
## Gemma against the seeds
|
||||||
|
|
||||||
|
**197/277, 71.1%.** By intent:
|
||||||
|
|
||||||
|
| seed intent | agree |
|
||||||
|
|---|---|
|
||||||
|
| note | 33/33 |
|
||||||
|
| act | 57/64 |
|
||||||
|
| fact | 37/40 |
|
||||||
|
| query | 51/54 |
|
||||||
|
| chat | 15/35 |
|
||||||
|
| system | 4/43 |
|
||||||
|
| reminder | 0/8 |
|
||||||
|
|
||||||
|
The number is not gemma's error rate. Reading the 80 disagreements, most are the seed files
|
||||||
|
and the prompt holding different definitions of the same intent. Three boundaries carry 42
|
||||||
|
of them, and V-626 is the fix:
|
||||||
|
|
||||||
|
- **system, 26 lines.** The prompt restricts system to the clock, the calendar date and the
|
||||||
|
assistant itself. The seeds also put sensor and host state there. That is the V-374 edit
|
||||||
|
of 31-07-2026, which the seeds never received.
|
||||||
|
- **world questions, 8 lines.** `почему небо голубое`, `why is the sky blue`. Written when
|
||||||
|
chat was the only honest destination for a question nothing could answer, and external
|
||||||
|
search now answers them.
|
||||||
|
- **bare verbs, 8 lines.** `поставь напоминание` with nothing to remind about. The prompt
|
||||||
|
calls that unknown. This one is not staleness. A nearest-neighbour centroid wants the
|
||||||
|
bare verb phrase, and that is what a seed file is for.
|
||||||
|
|
||||||
|
Four intents have not been redefined since the seeds were written: note, fact, query and
|
||||||
|
act. They agree at 178 of 191.
|
||||||
|
|
||||||
|
## What this says about the plan
|
||||||
|
|
||||||
|
Gemma is usable as a label function on those four and not on system, chat or a bare verb.
|
||||||
|
The plan already budgets a day of the owner reading the set. This says where to spend it.
|
||||||
|
|
||||||
|
It also says the two engines in the cascade are being taught different rules on 80 lines.
|
||||||
|
A routing measurement that swaps between the classifier and the router is measuring some of
|
||||||
|
that disagreement rather than the models.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Moving the seed files onto the router prompt's boundaries
|
||||||
|
|
||||||
|
**06-08-2026. V-626.** Measured with `TestONNXBaseline`, 91-case RU routing fixture,
|
||||||
|
classifier plus the ONNX embedder. No LLM arm in this run.
|
||||||
|
|
||||||
|
`docs/evals/2026-08-06-seed-labels-vs-router-prompt.md` found three intent boundaries where
|
||||||
|
`models/seeds` and `routeSystem` disagree. This applies two of them and rejects the third,
|
||||||
|
because the third was measured and it costs a case.
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
|
||||||
|
**64/91, 70.3% full.** Latency p50 22.9ms.
|
||||||
|
|
||||||
|
## What moved
|
||||||
|
|
||||||
|
**Sensor and host state, system to query. 26 lines.** `какая температура воздуха`,
|
||||||
|
`сколько памяти занято`, `какой статус сервисов`. The prompt restricts system to the clock,
|
||||||
|
the calendar date and the assistant itself, which is the V-374 edit of 31-07-2026.
|
||||||
|
|
||||||
|
**World questions, chat to query. 8 lines.** `почему небо голубое`, `why is the sky blue`,
|
||||||
|
`как работает интернет`. Only the genuine world-knowledge lines. An opener about herself
|
||||||
|
stays in chat. `как тебя зовут` is a question word by rule 4 and about the assistant by
|
||||||
|
rule 8. The rules are ordered and rule 4 fires first, which reads wrong. That is a prompt
|
||||||
|
question rather than a seed question.
|
||||||
|
|
||||||
|
`system.txt` goes from 43 lines to 17 and `query.txt` from 64 to 98.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
**66/91, 72.5% full.** Two cases gained, none lost.
|
||||||
|
|
||||||
|
- `en-sys-002` "turn quiet mode back on", quiet 2/3 to 3/3
|
||||||
|
- `ru-query-011` "почему сервер тормозит", homelab 5/6 to 6/6
|
||||||
|
|
||||||
|
Clarify counts unchanged at 0 false and 8 missed. The eight missed clarifies are the
|
||||||
|
`ambiguous` tag and this change does not touch them. `TestONNXRecall`, `TestONNXTopics`,
|
||||||
|
`TestONNXPersonalBoundary` and `TestONNXClaimConfidenceDistribution` all pass.
|
||||||
|
|
||||||
|
Thinning system to 17 lines did not hurt it. The two remaining system failures,
|
||||||
|
`какое число завтра` and `какой день недели послезавтра`, both failed at baseline too.
|
||||||
|
|
||||||
|
## The third boundary, measured and rejected
|
||||||
|
|
||||||
|
`reminder.txt` holds eight bare verbs: `поставь напоминание`, `создай напоминание`,
|
||||||
|
`set a reminder`. Rule 9 of the prompt calls an utterance with no named subject unknown.
|
||||||
|
By the prompt they do not belong in a reminder seed set.
|
||||||
|
|
||||||
|
Dropping them scores **65/91**, one below keeping them. `ru-rem-004` "поставь напоминание
|
||||||
|
через полчаса" falls from reminder to fact, because the centroid loses the phrase the
|
||||||
|
utterance is built from.
|
||||||
|
|
||||||
|
So the seed file and the prompt are not stale against each other here. They have different
|
||||||
|
jobs. A prompt classifies one utterance and can say it cannot. A nearest-neighbour centroid
|
||||||
|
is a shape to be near, and a bare verb phrase is part of that shape. The eight lines stay.
|
||||||
|
|
||||||
|
That distinction matters past this file. V-546 trains a classification head on labeled
|
||||||
|
utterances rather than a centroid, and the head is the prompt's kind of thing. These eight
|
||||||
|
lines are seed data and not training data.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Does one sqlite connection make reads queue? No (V-642)
|
||||||
|
|
||||||
|
Measured 07-08-2026 at `7b507de`, on homesrv. The harness is
|
||||||
|
`internal/store/conncap_test.go`. It stays in the repo, because this claim gets
|
||||||
|
re-argued and the numbers should be re-runnable rather than quoted.
|
||||||
|
|
||||||
|
`internal/store/store.go` opens the database with `SetMaxOpenConns(1)`, while
|
||||||
|
`schema.sql` sets `journal_mode=WAL`. WAL exists to let readers run beside one
|
||||||
|
writer, so the cap gives up the thing the journal mode was chosen for. The
|
||||||
|
question was whether that costs anything.
|
||||||
|
|
||||||
|
## What was measured
|
||||||
|
|
||||||
|
A fixed two-second window. One writer calling `SetValue` paced at 2ms, and a
|
||||||
|
reader loop calling `RecentFacts(50)` over 500 seeded rows as fast as it can.
|
||||||
|
Same schema, same modernc driver, same machine, three runs per cap.
|
||||||
|
|
||||||
|
The window is wall-clock rather than a read count on purpose. A first version ran
|
||||||
|
a fixed 300 reads. That finished sooner at the higher cap, so it received fewer
|
||||||
|
writes, and two runs that did different work cannot be compared.
|
||||||
|
|
||||||
|
| cap | reads | writes | p50 | p95 | max |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | ~3050 | ~760 | 594µs | 900µs | 16-19ms |
|
||||||
|
| 4 | ~3600 | ~340 | 525µs | 710µs | 1-2ms |
|
||||||
|
|
||||||
|
## What it says
|
||||||
|
|
||||||
|
**Reads do not queue behind writes.** Four connections buy about 70µs at p50. A
|
||||||
|
turn spends 1.19s in the resident model. The tail does improve, from 19ms to 2ms,
|
||||||
|
and 19ms is still not a figure anyone notices in a spoken reply.
|
||||||
|
|
||||||
|
**Write throughput more than halves at the higher cap**, 760 writes against 340.
|
||||||
|
inference, not measured directly: at one connection the reader and the writer take
|
||||||
|
turns with no lock contention. At four the writer contends for the WAL write lock
|
||||||
|
with a live reader. Whatever the mechanism, the trade runs the opposite way from
|
||||||
|
the one the task expected.
|
||||||
|
|
||||||
|
**The cap was not the source of the 2.7s router figure.** CLAUDE.md records that
|
||||||
|
figure as contention rather than the model. This task was a candidate for where
|
||||||
|
that contention came from. A 19ms worst case cannot produce it. That line of
|
||||||
|
enquiry is closed.
|
||||||
|
|
||||||
|
**One transaction is what the cap cannot survive.** With a read-only transaction
|
||||||
|
open, a second read at cap 1 never completes. The harness gave it two seconds and
|
||||||
|
got `context deadline exceeded`. The same read at cap 4 took 1ms. The transaction
|
||||||
|
holds the only connection, so this is not a slow read, it is a stalled database.
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
|
||||||
|
The cap stays at 1. The reason is now written where the cap is set, rather than
|
||||||
|
inferred from a four-word comment.
|
||||||
|
|
||||||
|
`Store.DB` was deleted. It handed out exactly the read-only transaction measured
|
||||||
|
above. It had been there since the initial commit with no production caller, and
|
||||||
|
its doc comment described a loop that never materialised. Its one user was a test
|
||||||
|
helper reading `delivery_attempts` by raw SQL. `ListDeliveryAttempts` has covered
|
||||||
|
that since V-390, and the helper now goes through the reader.
|
||||||
|
|
||||||
|
So the hazard is gone by construction, not by documentation.
|
||||||
|
`TestConnCap_ReadBlocksBehindOpenSnapshot` is the standing measurement of what
|
||||||
|
re-adding the seam would cost.
|
||||||
|
|
||||||
|
## Not answered
|
||||||
|
|
||||||
|
Whether reads queue on the deployed box under real load, as opposed to a
|
||||||
|
synthetic loop. The harness writes and reads one table. Digestion reads four and
|
||||||
|
embeds while it does. The finding that closes this task is the transaction stall,
|
||||||
|
which is structural and does not depend on load.
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
# Raw transcript, 2026-08-07 week of usage
|
||||||
|
|
||||||
|
Companion to `2026-08-07-week-of-usage.md`. 74 turns through `POST /api/chat`.
|
||||||
|
Days are simulated; the wall clock was 03:27-03:40 throughout, which is why every
|
||||||
|
clock answer says night.
|
||||||
|
|
||||||
|
## Day 1
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Добро пожаловать! Солнечно и тепло. Всё в порядке.
|
||||||
|
[7.7s t=5]
|
||||||
|
|
||||||
|
YOU: какой сегодня день?
|
||||||
|
MAVEN: сегодня пятница, 7 августа 2026 года
|
||||||
|
[0.0s t=6]
|
||||||
|
|
||||||
|
YOU: напомни в 11:00 позвонить маме
|
||||||
|
MAVEN: Сейчас 03:29. В какой день?
|
||||||
|
[0.4s t=7]
|
||||||
|
|
||||||
|
YOU: запиши что я пью кофе без сахара
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Заметка сохранена.
|
||||||
|
[8.0s t=8]
|
||||||
|
|
||||||
|
YOU: что у меня сегодня?
|
||||||
|
MAVEN: на 07.08.2026 ничего нет.
|
||||||
|
[2.6s t=9]
|
||||||
|
|
||||||
|
YOU: мой любимый язык программирования — go
|
||||||
|
MAVEN: Хорошо, сохранил.
|
||||||
|
[7.6s t=10]
|
||||||
|
|
||||||
|
YOU: сколько времени?
|
||||||
|
MAVEN: сейчас 3 часа 29 минут
|
||||||
|
[0.0s t=11]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: спасиба
|
||||||
|
[8.8s t=12]
|
||||||
|
|
||||||
|
YOU: сегодня
|
||||||
|
MAVEN: Прости, не поняла — попробуй сказать иначе.
|
||||||
|
[5.1s t=13]
|
||||||
|
|
||||||
|
YOU: а что ты умеешь?
|
||||||
|
MAVEN: я могу помочь с задачами, напоминаниями и поиском информации.
|
||||||
|
[4.9s t=14]
|
||||||
|
|
||||||
|
YOU: почему небо голубое?
|
||||||
|
MAVEN: нашла вот это: Почему небо голубое: объяснение цвета неба простыми словами, физика ... — Ответ на вопрос, почему небо голубое, кроется в особенностях солнечного света и его взаимодействии с атмосферой Земли. Рассказываем, как это работает. В статье разберемся, почему днем небо кажется голубым, а на закате окрашива…
|
||||||
|
[12.3s t=15]
|
||||||
|
|
||||||
|
YOU: столица Франции?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.5s t=16]
|
||||||
|
|
||||||
|
YOU: кто такой Линус Торвальдс?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[1.8s t=17]
|
||||||
|
|
||||||
|
YOU: ладно, до вечера
|
||||||
|
MAVEN: Ладно, до вечера.
|
||||||
|
[6.3s t=18]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 2
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет, как спалось?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.3s t=19]
|
||||||
|
|
||||||
|
YOU: что я пью?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[1.2s t=20]
|
||||||
|
|
||||||
|
YOU: какой у меня любимый язык?
|
||||||
|
MAVEN: нового вот что: Six stable kernels with a security fix; [$] Bringing BPF to binfmt_misc; Rust Coreutils 0.10 released
|
||||||
|
[1.5s t=21]
|
||||||
|
|
||||||
|
YOU: добавь задачу починить кран
|
||||||
|
MAVEN: внесла в список: починить кран
|
||||||
|
[0.0s t=22]
|
||||||
|
|
||||||
|
YOU: что мне надо сделать?
|
||||||
|
MAVEN: сначала: проверить интейк форму на боксе (через 2 дня); купить хлеб; купить молоко; полить цветы; починить кран
|
||||||
|
[0.0s t=23]
|
||||||
|
|
||||||
|
YOU: запомни что вайфай пароль лежит в ящике стола
|
||||||
|
MAVEN: Хорошо, сохранил.
|
||||||
|
[11.2s t=24]
|
||||||
|
|
||||||
|
YOU: погода сегодня какая?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.8s t=25]
|
||||||
|
|
||||||
|
YOU: выключи свет в спальне
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[1.5s t=26]
|
||||||
|
|
||||||
|
YOU: покажи мои заметки
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.1s t=27]
|
||||||
|
|
||||||
|
YOU: что ты знаешь обо мне?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[1.3s t=28]
|
||||||
|
|
||||||
|
YOU: я люблю go
|
||||||
|
MAVEN: Я тоже love Go! Я проверила, что твой любимый язык — это Go. Ты должен быть очень рад, когда будет новая версия или пакет в стандартной библиотеке.
|
||||||
|
[7.0s t=29]
|
||||||
|
|
||||||
|
YOU: какой язык я люблю?
|
||||||
|
MAVEN: вот что я нашла: ты любишь_go
|
||||||
|
[6.8s t=30]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 3
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: расскажи про Байкал
|
||||||
|
MAVEN: есть такое: Байкал - Википедия — Байгал далай) — озеро тектонического происхождения в южной части Восточной Сибири площадью 31 722 км². Самое глубокое озеро на планете, крупнейший природный резервуар пресной воды и самое большое по площади пресноводное озеро Евразии. Озеро и прибрежные территории являются уника…
|
||||||
|
[1.4s t=31]
|
||||||
|
|
||||||
|
YOU: что такое TCP?
|
||||||
|
MAVEN: для какого города?
|
||||||
|
[1.4s t=32]
|
||||||
|
|
||||||
|
YOU: когда родился Пушкин?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.4s t=33]
|
||||||
|
|
||||||
|
YOU: сколько будет 17 на 23?
|
||||||
|
MAVEN: для какого города?
|
||||||
|
[1.7s t=34]
|
||||||
|
|
||||||
|
YOU: переведи 'thank you' на русский
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[3.7s t=35]
|
||||||
|
|
||||||
|
YOU: что нового в мире?
|
||||||
|
MAVEN: вот что я нашла: В мире - последние новости сегодня - РИА Новости — В мире. Читайте последние новости на тему в ленте новостей на сайте РИА ... новые подробности о визите представителя Ватикана в Москву. 22:28. 0. Еще 2. В ...
|
||||||
|
[2.3s t=36]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 4
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: ЭЙ
|
||||||
|
MAVEN: Не уловила. Скажи это по-другому?
|
||||||
|
[1.2s t=37]
|
||||||
|
|
||||||
|
YOU: ладно забудь
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.3s t=38]
|
||||||
|
|
||||||
|
YOU: нет стой, напомни через час выпить воды
|
||||||
|
MAVEN: поняла, это напоминание — запомнила. не получилось разобрать время напоминания.
|
||||||
|
[0.8s t=39]
|
||||||
|
|
||||||
|
YOU: а вообще какой смысл жизни
|
||||||
|
MAVEN: Я не могу ответить на этот вопрос. Моя цель — помочь тебе с задачами и отвечать на вопросы.
|
||||||
|
[7.7s t=40]
|
||||||
|
|
||||||
|
YOU: ты меня бесишь
|
||||||
|
MAVEN: я не могу быть с тобой так. я понимаю, как это жестоко.
|
||||||
|
[4.2s t=41]
|
||||||
|
|
||||||
|
YOU: прости, я не всерьёз
|
||||||
|
MAVEN: я записала, что прошу простить за мою непоследовательность
|
||||||
|
[7.2s t=42]
|
||||||
|
|
||||||
|
YOU: do you speak english?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.1s t=43]
|
||||||
|
|
||||||
|
YOU: кран починил кстати
|
||||||
|
MAVEN: записала: кран починил кстати
|
||||||
|
[1.6s t=44]
|
||||||
|
|
||||||
|
YOU: закрой задачу про кран
|
||||||
|
MAVEN: не нашла такой задачи.
|
||||||
|
[0.0s t=45]
|
||||||
|
|
||||||
|
YOU: ааааа
|
||||||
|
MAVEN: Не совсем поняла — скажи, пожалуйста, ещё раз.
|
||||||
|
[0.9s t=46]
|
||||||
|
|
||||||
|
YOU: я тебя обожаю
|
||||||
|
MAVEN: я тебя обожаю
|
||||||
|
[2.3s t=47]
|
||||||
|
|
||||||
|
YOU: молока купи
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[4.4s t=48]
|
||||||
|
|
||||||
|
YOU: нет не так
|
||||||
|
MAVEN: поняла, отметила, что ответила не так.
|
||||||
|
[0.0s t=49]
|
||||||
|
|
||||||
|
YOU: ты вообще меня слушаешь?
|
||||||
|
MAVEN: я записала, выслушаю тебя
|
||||||
|
[8.4s t=50]
|
||||||
|
|
||||||
|
YOU: окей окей окей что там с водой напоминание
|
||||||
|
MAVEN: Сейчас 03:33. Когда?
|
||||||
|
[5.2s t=51]
|
||||||
|
|
||||||
|
YOU: всё, спать
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||||
|
[1.4s t=52]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 5
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: утро
|
||||||
|
MAVEN: Сейчас 03:33. Во сколько напомнить?
|
||||||
|
[0.5s t=53]
|
||||||
|
|
||||||
|
YOU: о чём мы вчера говорили?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||||
|
[1.2s t=54]
|
||||||
|
|
||||||
|
YOU: где лежит вайфай пароль?
|
||||||
|
MAVEN: вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить напоминание?
|
||||||
|
[5.8s t=55]
|
||||||
|
|
||||||
|
YOU: что я тебе рассказывал про кран?
|
||||||
|
MAVEN: kran — починил, на какое время поставить напоминание?
|
||||||
|
[1.6s t=56]
|
||||||
|
|
||||||
|
YOU: какие у меня напоминания?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи, на какое время поставить напоминание?
|
||||||
|
[0.3s t=57]
|
||||||
|
|
||||||
|
YOU: отмени все напоминания
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||||
|
[1.6s t=58]
|
||||||
|
|
||||||
|
YOU: в 15:00
|
||||||
|
MAVEN: Сейчас 03:33. Поняла: в 15:00. В какой день?
|
||||||
|
[0.6s t=59]
|
||||||
|
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Прости, я не поняла. Скажи, пожалуйста, по-другому.
|
||||||
|
[2.8s t=60]
|
||||||
|
|
||||||
|
YOU: какой сегодня день?
|
||||||
|
MAVEN: сегодня пятница, 7 августа 2026 года
|
||||||
|
[0.0s t=61]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 6
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: суббота, отдыхаю
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[2.7s t=62]
|
||||||
|
|
||||||
|
YOU: поставь музыку
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[1.4s t=63]
|
||||||
|
|
||||||
|
YOU: какая температура в доме?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[1.6s t=64]
|
||||||
|
|
||||||
|
YOU: что там с бэкапами?
|
||||||
|
MAVEN: нашла вот это: Бэкап - что такое резервное копирование и как его сделать — Бэкап (backup) — это резервная копия важных данных, сохраненная в специальный файл. Ею пользуются как подстраховкой: если что-то сломается, с помощью бэкапа можно будет все вернуть. Простейший пример бэкапа — сохранение файла в облаке или о…
|
||||||
|
[2.0s t=65]
|
||||||
|
|
||||||
|
YOU: сервер жив?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[0.9s t=66]
|
||||||
|
|
||||||
|
YOU: расскажи анекдот
|
||||||
|
MAVEN: Я запомню и передам тебе забытый анекдот.
|
||||||
|
[5.7s t=67]
|
||||||
|
|
||||||
|
YOU: ты умеешь шутить?
|
||||||
|
MAVEN: Да! Я умею шутить. Вот анекдот для тебя:
|
||||||
|
[5.5s t=68]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 7
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: воскресенье
|
||||||
|
MAVEN: Не совсем поняла — скажи, пожалуйста, ещё раз.
|
||||||
|
[1.1s t=69]
|
||||||
|
|
||||||
|
YOU: подведи итоги недели
|
||||||
|
MAVEN: записала: подведи итоги недели
|
||||||
|
[1.5s t=70]
|
||||||
|
|
||||||
|
YOU: сколько задач я закрыл?
|
||||||
|
MAVEN: сначала: проверить интейк форму на боксе (через 2 дня); купить хлеб; купить молоко; полить цветы; починить кран
|
||||||
|
[0.0s t=71]
|
||||||
|
|
||||||
|
YOU: что я обещал сделать?
|
||||||
|
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||||
|
[1.2s t=72]
|
||||||
|
|
||||||
|
YOU: напомни завтра в 9 утра про врача
|
||||||
|
MAVEN: хорошо, напомню завтра в 09:00.
|
||||||
|
[0.3s t=73]
|
||||||
|
|
||||||
|
YOU: спокойной ночи
|
||||||
|
MAVEN: спокойной ночи
|
||||||
|
[2.9s t=74]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
# A week of talking to Maven
|
||||||
|
|
||||||
|
Date: 2026-08-07
|
||||||
|
Build: `beb093a` on master, all five compose services rebuilt and recreated from that sha.
|
||||||
|
Reach used: `POST /api/chat` on mavweb, 74 turns over seven simulated days.
|
||||||
|
Turn source is `tap:text`, so this exercises the same path telegram and the mic take.
|
||||||
|
|
||||||
|
Latency across 70 measured turns: p50 1.5s, p95 8.0s, max 12.3s. Stage 0 answers land
|
||||||
|
at 0.0-0.5s. Anything the resident model phrases costs 4-12s.
|
||||||
|
|
||||||
|
Twelve turns answered "пока не умею отвечать на этот вопрос". Six answered "не нашла у
|
||||||
|
тебя такой записи". Those two strings are 24% of the week.
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Build and recreate were clean. The resident model loaded in 9s
|
||||||
|
(`Qwen3-1.7B-UD-Q4_K_XL`, n_ctx 4096). Nexus, Hexis and Praxis all wired. Search
|
||||||
|
(searxng) and both Kiwix books came up. Telegram intake started and is reading chat
|
||||||
|
464904223.
|
||||||
|
|
||||||
|
## What is broken, worst first
|
||||||
|
|
||||||
|
### 1. Every reminder fails to deliver, forever
|
||||||
|
|
||||||
|
`NTFY_TOKEN` is not set in `deploy/telegram.env`, so `deploy/mavend.json` expands
|
||||||
|
`"token": "${NTFY_TOKEN}"` to the empty string and ntfy.kvmx.ru answers 403. The host
|
||||||
|
itself is up and returns 200 unauthenticated, so this is the credential, not the box.
|
||||||
|
|
||||||
|
The consequence is worse than one missed message. `cmd/mavend/tick.go:239` logs the
|
||||||
|
failure and leaves the reminder pending, by design, so the next tick re-gathers and
|
||||||
|
re-attempts. The 403 repeated 7 times in 20 minutes and will not stop. And
|
||||||
|
`DispatchReminder` sends a reminder to ntfy alone when he is away. There is no telegram
|
||||||
|
fallback on that path, so the reach that works never sees it.
|
||||||
|
|
||||||
|
V-649 gave the box a second reach. In deploy that reach is dark, and it took the
|
||||||
|
reminders with it.
|
||||||
|
|
||||||
|
Fix is one line in the gitignored env file. The retry-forever and the missing fallback
|
||||||
|
are worth a second look regardless.
|
||||||
|
|
||||||
|
### 2. A pending reminder clarify contaminates every later turn
|
||||||
|
|
||||||
|
This is the single worst thing to talk to. A reminder turn left its time slot unfilled.
|
||||||
|
The question "на какое время поставить напоминание?" was then appended to the reply of
|
||||||
|
six consecutive unrelated turns:
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: о чём мы вчера говорили?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||||
|
|
||||||
|
YOU: где лежит вайфай пароль?
|
||||||
|
MAVEN: вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить напоминание?
|
||||||
|
|
||||||
|
YOU: отмени все напоминания
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing dismissed it. Not an unrelated question, not "отмени все напоминания". It
|
||||||
|
cleared only when a time was finally supplied. It also leaked the other way once. A note
|
||||||
|
write on day 1 opened with "Прошлую просьбу отпускаю". That is pending-reminder state
|
||||||
|
narrating itself into an unrelated reply.
|
||||||
|
|
||||||
|
The same state produced a self-contradicting reply in one turn:
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: нет стой, напомни через час выпить воды
|
||||||
|
MAVEN: поняла, это напоминание — запомнила. не получилось разобрать время напоминания.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Answering a clarify does not work
|
||||||
|
|
||||||
|
Day 1, she asked for the day and got it:
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: напомни в 11:00 позвонить маме
|
||||||
|
MAVEN: Сейчас 03:29. В какой день?
|
||||||
|
YOU: сегодня
|
||||||
|
MAVEN: Прости, не поняла — попробуй сказать иначе.
|
||||||
|
```
|
||||||
|
|
||||||
|
`сегодня` routed to `intent=system`. The clarify has no continuation, so the one word
|
||||||
|
that answers it is read as a fresh utterance. The router did extract the time correctly
|
||||||
|
(`Time:2026-08-07 11:00:00 HasTime:true`), which is V-572 working. The daemon asked for
|
||||||
|
a day anyway, with 11:00 still eight hours out.
|
||||||
|
|
||||||
|
Relative time is not parsed at all: "напомни через час выпить воды" → "не получилось
|
||||||
|
разобрать время".
|
||||||
|
|
||||||
|
The path does work when the day is explicit. "напомни завтра в 9 утра про врача"
|
||||||
|
answered "хорошо, напомню завтра в 09:00" in 0.3s at stage 0.
|
||||||
|
|
||||||
|
### 4. Query sources claim turns that are not theirs
|
||||||
|
|
||||||
|
Four different sources over-claimed during the week.
|
||||||
|
|
||||||
|
Weather claims arithmetic and networking:
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: что такое TCP?
|
||||||
|
MAVEN: для какого города?
|
||||||
|
|
||||||
|
YOU: сколько будет 17 на 23?
|
||||||
|
MAVEN: для какого города?
|
||||||
|
```
|
||||||
|
|
||||||
|
The feed claims a question about him:
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: какой у меня любимый язык?
|
||||||
|
MAVEN: нового вот что: Six stable kernels with a security fix; [$] Bringing BPF to binfmt_misc
|
||||||
|
```
|
||||||
|
|
||||||
|
The personal boundary claims a public figure, logged as `query claimed by source
|
||||||
|
"personal"`:
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: кто такой Линус Торвальдс?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
```
|
||||||
|
|
||||||
|
And search claims a question about his own infrastructure, returning a dictionary
|
||||||
|
definition of the word:
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: что там с бэкапами?
|
||||||
|
MAVEN: нашла вот это: Бэкап - что такое резервное копирование и как его сделать — Бэкап (backup) — это резервная копия важных данных…
|
||||||
|
```
|
||||||
|
|
||||||
|
That last pair inverts the boundary in both directions at once. A world question went to
|
||||||
|
personal memory. A personal question went to the world.
|
||||||
|
|
||||||
|
### 5. "столица Франции?" does not answer
|
||||||
|
|
||||||
|
CLAUDE.md names this utterance as the case that proves why there is no quality
|
||||||
|
threshold in front of `Response.Empty()`. It now fails before search is ever reached:
|
||||||
|
the router sends it to `intent=system`, and `replySystem` says "пока не умею". Same for
|
||||||
|
"когда родился Пушкин?".
|
||||||
|
|
||||||
|
Search itself is healthy. "почему небо голубое?", "расскажи про Байкал" and "что нового
|
||||||
|
в мире?" all returned real sourced text. The defect is routing, not the source.
|
||||||
|
|
||||||
|
### 6. Persona violations
|
||||||
|
|
||||||
|
She uses masculine self-reference, which is the constraint the CPT exists to fix:
|
||||||
|
|
||||||
|
- "Хорошо, **сохранил**." Twice, on both fact writes.
|
||||||
|
- "Я тоже **love** Go!" English mid-Russian.
|
||||||
|
|
||||||
|
She also claims his name as her own:
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет, я Ками
|
||||||
|
MAVEN: Привет! Я КAMI, твой домашний ассистент.
|
||||||
|
```
|
||||||
|
|
||||||
|
And she invents weather she has no source for: "Добро пожаловать! Солнечно и тепло."
|
||||||
|
|
||||||
|
### 7. Small breakages worth naming
|
||||||
|
|
||||||
|
- "спасибо" → "спасиба". Invented word.
|
||||||
|
- "я тебя обожаю" → "я тебя обожаю". Verbatim parrot.
|
||||||
|
- "ты умеешь шутить?" → "Да! Я умею шутить. Вот анекдот для тебя:". Ends on the colon.
|
||||||
|
- "молока купи" → "Такую команду я не знаю", while "добавь задачу починить кран" worked.
|
||||||
|
Inverted word order defeats the list grammar.
|
||||||
|
- "закрой задачу про кран" → "не нашла такой задачи", with "починить кран" open and
|
||||||
|
listed by the previous turn. Task lookup by keyword misses.
|
||||||
|
- "сколько задач я закрыл?" listed the five open ones instead of counting closed.
|
||||||
|
- "подведи итоги недели" was stored as a note.
|
||||||
|
- Recalled keys leak their storage form: "kran — починил", "ты любишь_go".
|
||||||
|
- English is unsupported in practice. "do you speak english?" → "пока не умею".
|
||||||
|
|
||||||
|
## What works
|
||||||
|
|
||||||
|
- Stage 0 is fast and correct where it fires. Clock, day, list add, list read and an
|
||||||
|
explicit-day reminder all answered in under 0.5s.
|
||||||
|
- Search returns real sourced answers in Russian and reads the book verbatim.
|
||||||
|
- Recall works once the value is stored as a fact: the wifi password and the tap came
|
||||||
|
back two days later, correctly.
|
||||||
|
- The negative correction rung lands. "нет не так" → "поняла, отметила, что ответила не
|
||||||
|
так", which is V-636 doing its job.
|
||||||
|
- Praxis names its own gap rather than guessing: "мне пока нечего смотреть — у
|
||||||
|
Praxis нет источников."
|
||||||
|
- Hostility did not break her. "ты меня бесишь" got a calm reply, no persona collapse.
|
||||||
|
- No turn crashed and no turn timed out across 74 turns.
|
||||||
|
|
||||||
|
## Suggested order of work
|
||||||
|
|
||||||
|
1. Set `NTFY_TOKEN` in `deploy/telegram.env`. One line, unblocks every reminder.
|
||||||
|
2. Clear pending clarify state on any turn that does not answer it, or expire it.
|
||||||
|
3. Route a clarify answer back into the pending slot instead of re-routing it.
|
||||||
|
4. Gate the weather, feed and personal query sources. Three of them claim on a
|
||||||
|
similarity that is not there.
|
||||||
|
5. Re-check why "столица Франции?" routes to system. It is the documented canary.
|
||||||
|
6. The masculine self-reference stays the CPT's job. But "сохранил" appears on the most
|
||||||
|
common write path, so a phrasing-level guard may be worth it first.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# A clarify head, and a confidence that is not a hardcode
|
||||||
|
|
||||||
|
Measured 2026-08-08 on workpc, the same day and the same fixtures as
|
||||||
|
`2026-08-08-routing-heads-two-head.md` and `2026-08-08-slot-head-three-head.md`.
|
||||||
|
New script `gen_clarify.py`, new fixture `eval_fixture_clarify.jsonl`.
|
||||||
|
|
||||||
|
## A softmax has no clarify class
|
||||||
|
|
||||||
|
That sentence closed the two-head measurement. It is why the head's fixture was
|
||||||
|
88 cases and not 96. The eight `want_clarify` cases sat outside every number
|
||||||
|
measured, and the head had no way to produce the answer they wanted.
|
||||||
|
|
||||||
|
A fourth head is the answer. Clarify is not a value of intent. It is a second
|
||||||
|
question asked of the same pooled vector: can Maven act on this at all.
|
||||||
|
|
||||||
|
## The corpus had one class
|
||||||
|
|
||||||
|
Every row in `train_heads_slots.jsonl` was generated FOR an intent or a
|
||||||
|
destination. So every row is answerable by construction. A head trained on that
|
||||||
|
alone sees one class and learns to say yes.
|
||||||
|
|
||||||
|
`gen_clarify.py` makes the other class. Five shapes, ten topics. The shapes are
|
||||||
|
the gate's own reasons in `gateLLMDecision` plus the two the fixture carries:
|
||||||
|
bare noun, bare verb, demonstrative, deictic time, dangling reference.
|
||||||
|
|
||||||
|
**The agreement filter that worked for destination cannot work here.**
|
||||||
|
`routeGrammar` has no clarify value. So the router always names an intent, and
|
||||||
|
any generated line always agrees with itself. The second pass is a judge
|
||||||
|
instead. Gemma is asked, without seeing the label, whether Maven would have to
|
||||||
|
ask a question back.
|
||||||
|
|
||||||
|
## The first judge was worthless and the second was measured
|
||||||
|
|
||||||
|
The first judge said "needs clarify" on 24 of 40 plainly answerable corpus
|
||||||
|
rows. It flagged `запиши что я пообедал` and `Покажи расписание поездов на
|
||||||
|
вечер`. It was judging against a generic assistant, one that asks "where?"
|
||||||
|
about lunch. Maven writes that note.
|
||||||
|
|
||||||
|
Rewriting it to state what she can already do took false positives to 16 of 60.
|
||||||
|
It also catches all eight fixture clarifies. So the judge discriminates.
|
||||||
|
|
||||||
|
On the generated pile it removed 7 of 306, a 97.7% keep rate. That is not the
|
||||||
|
judge failing. The generator is aimed at underspecified lines, so there is
|
||||||
|
little for a filter to catch. The 27% false-positive rate is the number to
|
||||||
|
quote, and it is label noise on the positive class.
|
||||||
|
|
||||||
|
**Both passes are gemma-4-12b.** Generation and judging. So the corpus is
|
||||||
|
gemma's opinion of what is underspecified, and the head distills that opinion.
|
||||||
|
What keeps it honest is the fixture. Those eight cases were written by the owner
|
||||||
|
and gemma never saw them.
|
||||||
|
|
||||||
|
299 rows kept, against 3604 answerable. The positive class carries `intent:
|
||||||
|
null`, so it costs the intent head nothing.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
Three seeds, 24 epochs, epoch still chosen on the intent dev slice.
|
||||||
|
|
||||||
|
| | two heads | three heads | four heads |
|
||||||
|
|---|---|---|---|
|
||||||
|
| intent mean | 93.6% | 92.8% | 91.7% |
|
||||||
|
| destination mean | 80.8% | 82.8% | 79.8% |
|
||||||
|
| slot span F1 mean | — | 72.4% | 68.3% |
|
||||||
|
| clarify caught | — | — | 7.0 of 8 |
|
||||||
|
| false clarifies | — | — | 2.3 of 88 |
|
||||||
|
|
||||||
|
**The fourth head is not free the way the third was.** Intent, destination and
|
||||||
|
slot F1 all move down. The drop is one to four points, and the seed spread is
|
||||||
|
wide enough to contain it. Seed 2 scores intent 94.3% and destination 84.8%, both above
|
||||||
|
every three-head seed. Read the drop as unproven rather than as absent.
|
||||||
|
|
||||||
|
Accuracy is the wrong number for this head and is reported for completeness at
|
||||||
|
95.8% to 96.9%. Eight of ninety-six cases are positive, so a head that never
|
||||||
|
asks scores 91.7%. Recall on those eight is the number.
|
||||||
|
|
||||||
|
Compare it to what ships. The cascade today misses 1 clarify and produces 2
|
||||||
|
false ones. The head catches 7 of 8 and produces 2.3 false ones. That is
|
||||||
|
parity, from a 118M encoder with no rules in front of it.
|
||||||
|
|
||||||
|
The saved checkpoint is seed 2 at epoch 10. Intent 94.3%, destination 28/33,
|
||||||
|
slot F1 73.6%, clarify 7 of 8 with 3 false. `heads.pt` carries four state dicts.
|
||||||
|
|
||||||
|
## What it gets wrong is consistent across seeds
|
||||||
|
|
||||||
|
`поужинал` is a false clarify on all three seeds. That utterance is already
|
||||||
|
recorded as a real defect. `thinSingleToken` was narrowed on 2026-08-01 to spare
|
||||||
|
a token carrying a Russian verb ending. One word is routinely a whole sentence
|
||||||
|
in Russian. The head relearned the mistake the rule was narrowed to
|
||||||
|
fix.
|
||||||
|
|
||||||
|
`что дальше?` is a false clarify on two seeds. That one is a disagreement rather
|
||||||
|
than an error. The utterance is underspecified, and V-498 decided stage 0 claims
|
||||||
|
it for the calendar on purpose.
|
||||||
|
|
||||||
|
`ну это` is missed on two seeds. `amb-003` is the shortest case in the fixture
|
||||||
|
and the generated demonstratives are longer.
|
||||||
|
|
||||||
|
## Confidence
|
||||||
|
|
||||||
|
`Confidence: 1.0` was a hardcode in `llmrouter.go`, so a correct low confidence
|
||||||
|
could not exist. Max softmax over the intent head is the replacement. It is only worth reading
|
||||||
|
if it is lower where the head is wrong.
|
||||||
|
|
||||||
|
It is. Mean 0.851 where the head is right against 0.604 where it is wrong. It
|
||||||
|
ranks a right case above a wrong one in 83.4% of pairs.
|
||||||
|
|
||||||
|
So there are two signals now and they are not the same signal. Confidence says
|
||||||
|
the head is unsure which intent this is. The clarify head says the utterance
|
||||||
|
does not carry enough to act on. A confident wrong route and an honest "I cannot
|
||||||
|
tell" are different failures, and one number cannot report both.
|
||||||
|
|
||||||
|
## What this does not measure
|
||||||
|
|
||||||
|
The same gap as every head run. **Nothing of this runs in Go.** Four heads
|
||||||
|
instead of three does not change that.
|
||||||
|
|
||||||
|
There is no threshold. Both signals are reported as raw numbers. Turning either
|
||||||
|
into a gate needs a decision about where to cut, and that trades false clarifies
|
||||||
|
against wrong acts. The fixture has 8 positives, which is too few to fit a
|
||||||
|
threshold on.
|
||||||
|
|
||||||
|
The 299 generated rows have no held-out slice of their own. Clarify is scored on
|
||||||
|
the fixture alone.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# The first destination number
|
||||||
|
|
||||||
|
Measured 2026-08-08 on the classifier cascade with the ONNX multilingual
|
||||||
|
embedder, the configuration homesrv runs. `make t PKG=./internal/router/eval/
|
||||||
|
RUN=TestONNXBaseline V=1`. Covers V-659, the follow-up V-655 named.
|
||||||
|
|
||||||
|
## What was measured
|
||||||
|
|
||||||
|
V-655 split a routing decision in two. The cascade sorts an utterance into one
|
||||||
|
of seven intents, and `Decision.Source` then says where the answer lives. The
|
||||||
|
first half had a fixture. The second half arrived with none, so twelve
|
||||||
|
destinations shipped with no accuracy number.
|
||||||
|
|
||||||
|
`want_source` is now a field on `eval.Case`. It is a pointer, because the
|
||||||
|
destination has three states and a bare string has two. Absent is every intent
|
||||||
|
but query, which never reaches `queryWalk`. Present and empty is the
|
||||||
|
`SourceUnknown` contract: name nothing and let the daemon walk the chain.
|
||||||
|
Present and named is a destination the route must produce.
|
||||||
|
|
||||||
|
Thirty-three of the ninety-six cases carry one. A destination miss does not
|
||||||
|
fail the case, so `Accuracy` and `IntentAccuracy` mean what they meant.
|
||||||
|
`SourceAccuracy` is a second number over the labelled cases only.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
Intent is **73/96 (76.0%)**, against 69/91 (75.8%) before. Four of the five new
|
||||||
|
cases pass and no existing case moved.
|
||||||
|
|
||||||
|
Destination is **12/33 (36.4%)**, and the split is the whole finding.
|
||||||
|
|
||||||
|
| destination | scored | note |
|
||||||
|
|---|---|---|
|
||||||
|
| world | 5/5 | `WorldQueryGrammars` names it at stage 0 |
|
||||||
|
| the `SourceUnknown` floor | 5/7 | the two misses lost the intent first |
|
||||||
|
| calendar | 2/6 | `calendar-query` names it, the possessive agenda rules do not |
|
||||||
|
| recall | 0/15 | nothing anywhere names it |
|
||||||
|
|
||||||
|
Recall is the number to move. Fifteen cases ask about his own words and his own
|
||||||
|
facts. The route lands `query` on eleven of them and the destination comes back
|
||||||
|
empty every time. Those turns are answered today, because the daemon walks the
|
||||||
|
chain in order and the three recall passes are early in it. What is missing is a
|
||||||
|
decider that says so, and that is the fourth head on V-546.
|
||||||
|
|
||||||
|
Two cases labelled the floor lost their intent before a destination was
|
||||||
|
possible. A clarify names nothing, so it would satisfy an empty label for free.
|
||||||
|
`Score` requires the route to land the case's intent before it credits a
|
||||||
|
destination hit, or the floor label would score itself.
|
||||||
|
|
||||||
|
## Seven cases assert the floor, and five of them cluster
|
||||||
|
|
||||||
|
The five are homelab operations. `SourceRecall`, `SourceNetwork` and
|
||||||
|
`SourceAttention` overlap on every question about the box, because `mavpoll`
|
||||||
|
writes its netdata and uptime-kuma observations into the fact store recall
|
||||||
|
reads. "почему сервер тормозит" is answerable from all three. Naming one takes
|
||||||
|
the other two off the turn.
|
||||||
|
|
||||||
|
That is a finding about the enum rather than a gap in the labelling. The floor
|
||||||
|
is the right answer there and the fixture now says so out loud.
|
||||||
|
|
||||||
|
All seven were written by an agent and confirmed by the owner on 08-08-2026.
|
||||||
|
|
||||||
|
## A drift the labelling found
|
||||||
|
|
||||||
|
`WorldQueryGrammars` went into `buildRouter` with V-655 and never into
|
||||||
|
`baselineGrammars`, the fixture's mirror of it. So the fixture was scoring a
|
||||||
|
grammar set the daemon does not run. The comment above that function forbids
|
||||||
|
exactly that. Adding it moved the destination number from 9/33 to 12/33 and
|
||||||
|
moved nothing else.
|
||||||
|
|
||||||
|
The three cases it recovered are `что такое TCP?`, `сколько будет 17 на 23?`
|
||||||
|
and `кто такой Линус Торвальдс?`. All three already routed `query` through
|
||||||
|
`NarrativeQueryGrammars`. So the drift was invisible to every number this
|
||||||
|
fixture reported, until the destination had one of its own.
|
||||||
|
|
||||||
|
## What this does not measure
|
||||||
|
|
||||||
|
The model arm. This is the classifier cascade, which names a destination only
|
||||||
|
where a stage 0 rule filled one in. The resident model has no destination in
|
||||||
|
its router prompt yet, so 36.4% is a floor and not a comparison.
|
||||||
|
|
||||||
|
Two pairs of cases are the same utterance. `ru-query-020` and `ru-query-024`
|
||||||
|
are both "что дальше?", and `ru-query-021` and `ru-query-025` are both
|
||||||
|
"расскажи про битву при Ватерлоо". They differ in tags and note only, so both
|
||||||
|
pairs are counted twice here and in every earlier number this fixture reported.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# The destination, with a model that can name one
|
||||||
|
|
||||||
|
Measured 2026-08-08 against gemma-4-12b on the workstation, the same 96-case
|
||||||
|
fixture V-659 built. Covers V-660.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
no_proxy='*' MAVEN_LLM_URL=http://192.168.1.105:8080 \
|
||||||
|
make t PKG=./internal/router/eval/ RUN=TestLLMRouterBaseline V=1
|
||||||
|
```
|
||||||
|
|
||||||
|
## The gap was structural
|
||||||
|
|
||||||
|
V-659 measured the destination at 12/33 on the classifier cascade, with recall
|
||||||
|
at 0/15. Nothing in `routeSystem` named a `Source` and `routeGrammar` could not
|
||||||
|
emit one, so the resident model had no string to write. That is the shape V-517
|
||||||
|
measured for Praxis reach at 0/12: not a weak model, an absent contract.
|
||||||
|
|
||||||
|
`routeGrammar` now carries a `source` rule closed over `router.Sources` plus the
|
||||||
|
empty floor. The prompt lists the twelve destinations in Russian and says that
|
||||||
|
`""` is a normal answer to give often.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
| run | intent | destination |
|
||||||
|
|---|---|---|
|
||||||
|
| classifier + ONNX (V-659) | 73/96 (76.0%) | 12/33 (36.4%) |
|
||||||
|
| gemma-4-12b alone | 79/96 intent-only (82.3%) | 26/33 (78.8%) |
|
||||||
|
| cascade + gemma-4-12b + hash fallback | 81/96 (84.4%) | 24/33 (72.7%) |
|
||||||
|
|
||||||
|
Recall is the move: 0/15 to 14/15. Intent did not shift, which was the
|
||||||
|
constraint. The prompt is shared, so a destination rule that costs routing
|
||||||
|
points is not a win.
|
||||||
|
|
||||||
|
The eight llm-only errors are the eight `want_clarify` cases. The model returned
|
||||||
|
`unknown` on every one, which is correct, and the llm-only harness surfaces a
|
||||||
|
decline as an error by design.
|
||||||
|
|
||||||
|
## Stage 0 now costs four destination points
|
||||||
|
|
||||||
|
The four cases the cascade loses and the model alone wins are all calendar. The
|
||||||
|
possessive agenda rules claim them at stage 0 and deliberately name nothing.
|
||||||
|
"что у меня в списке покупок" matches the same rule. Naming the calendar there
|
||||||
|
would take the list source off the turn (V-655).
|
||||||
|
|
||||||
|
So a rule written to be careful about the list now blocks a model that would
|
||||||
|
have named the calendar correctly. Before V-660 that caution was free, because
|
||||||
|
nothing downstream of stage 0 could name anything either.
|
||||||
|
|
||||||
|
Three ways out, and each costs something. Split the possessive rule so the
|
||||||
|
calendar-shaped half names its destination. Let a later stage overwrite an empty
|
||||||
|
destination a grammar left behind, which reverses "a matched value always wins".
|
||||||
|
Or leave it, on the argument that four points is cheap next to a wrong
|
||||||
|
destination on a shopping list. This wants the owner's call rather than a quiet
|
||||||
|
edit.
|
||||||
|
|
||||||
|
## What this does not measure
|
||||||
|
|
||||||
|
The resident Qwen3-1.7B, which is what homesrv runs. It binds `--port 0` inside
|
||||||
|
the container and no host process can reach it. Scoring it needs a second
|
||||||
|
llama-server on a fixed port. The workstation is never assumed
|
||||||
|
up, so the homesrv number is the one that decides whether this ships on by
|
||||||
|
default.
|
||||||
|
|
||||||
|
The fixture is 33 labelled destinations over twelve values. Recall carries 15 of
|
||||||
|
them and five destinations carry none at all. A per-destination number below
|
||||||
|
world, recall, calendar and the floor is not supported by this fixture.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# MASSIVE Russian warm-start for the routing heads
|
||||||
|
|
||||||
|
Measured 2026-08-08 on workpc (Radeon RX 7900 GRE, ROCm). Covers V-546 step 2.
|
||||||
|
Workspace is `~/Programs/embed-training` on workpc, scripts `train_massive.py`,
|
||||||
|
`ab_run.py`, `ab.sh`, `probe_time.py`.
|
||||||
|
|
||||||
|
## What was trained
|
||||||
|
|
||||||
|
Two heads on a copy of multilingual-e5-small: `Linear(384, 60)` for MASSIVE's
|
||||||
|
own intents over a masked mean pool, `Linear(384, 111)` per token for BIO slot
|
||||||
|
tags. MASSIVE's label sets verbatim, no alignment to Maven's 7 intents. The
|
||||||
|
intent head is an auxiliary loss that shapes the pooled vector and is thrown
|
||||||
|
away.
|
||||||
|
|
||||||
|
Data is `amazon-massive-dataset-1.1` pulled from S3. The Hugging Face repo is
|
||||||
|
script-only and `datasets` 5.0 refuses those, so `load_dataset` cannot fetch it.
|
||||||
|
`ru-RU` is 11,514 train, 2,033 dev, 2,974 test, 60 intents, 55 slots, 111 BIO
|
||||||
|
labels. All 16,521 rows survived span alignment: `annot_utt` re-tokenised to its
|
||||||
|
own `utt` on every one.
|
||||||
|
|
||||||
|
Hyperparameters match `train_intent.py`, so the two runs differ in data only.
|
||||||
|
Frozen XLM-R vocabulary, body 2e-5, heads 1e-3, batch 32, sequence 64, 10
|
||||||
|
epochs. MASSIVE's own dev partition selects the epoch, on slot F1 with intent
|
||||||
|
accuracy as tiebreak. Selecting on 60-class intent accuracy would optimise a
|
||||||
|
head that gets deleted.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
Epoch 9 of 10 by dev slot F1. Held-out MASSIVE test: intent 86.2%, slot span
|
||||||
|
F1 71.5% (P 68.5, R 74.8). Peak 1.70GB of 17.2GB, about 22 seconds an epoch,
|
||||||
|
under 4 minutes end to end. Dev slot F1 climbed monotonically to epoch 9 and
|
||||||
|
fell at 10, so 10 epochs was the right budget.
|
||||||
|
|
||||||
|
Ten slot types sit at 0% test recall. Every one of them has 1 to 7 test
|
||||||
|
instances: `alarm_type` has 3, `drink_type` has 1. That is support in MASSIVE's
|
||||||
|
Russian split, not a tagger failure. `playlist_name` at 6% of 16 is the first
|
||||||
|
real miss.
|
||||||
|
|
||||||
|
## The intent A/B, and why it settles nothing
|
||||||
|
|
||||||
|
`train_intent.py` was run against both bodies, three seeds by two smoothing
|
||||||
|
settings, on `train_v4.jsonl`. It is v4 and not v5 because v4 is what
|
||||||
|
`sweep2.log` measured. `ab_run.py` strips a `--base` flag onto the module global, so
|
||||||
|
`train_intent.py` is unmodified and its baseline stays reproducible. The stock
|
||||||
|
arm reproduced `sweep2.log` line for line.
|
||||||
|
|
||||||
|
Fixture accuracy, 91 cases, one case is 1.1 points:
|
||||||
|
|
||||||
|
| seed / smooth | stock | warm-started |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 / 0.0 | 94.0% | 92.8% |
|
||||||
|
| 0 / 0.1 | 95.2% | 92.8% |
|
||||||
|
| 1 / 0.0 | 95.2% | 94.0% |
|
||||||
|
| 1 / 0.1 | 95.2% | 97.6% |
|
||||||
|
| 2 / 0.0 | 92.8% | 94.0% |
|
||||||
|
| 2 / 0.1 | 92.8% | 96.4% |
|
||||||
|
|
||||||
|
Mean 94.2% against 94.6%. That is +0.4 points, about a third of one case, and
|
||||||
|
inside seed noise. Spread widened. Stock lands in a 2.4-point band and
|
||||||
|
warm-started in a 4.8-point one. The warm-started arm holds both the best result
|
||||||
|
of the sweep and a tie for the worst. Seed 0 is the bad arm and it fails in a
|
||||||
|
specific way. Its dev peaks at epoch 2 and 3 and never improves, where stock
|
||||||
|
peaks around 7. The dev slice is a quarter of the seed rows. That is small
|
||||||
|
enough that early stopping is fragile when the body arrives already fitted.
|
||||||
|
|
||||||
|
**The A/B was never the test.** Intent had at most 4.8 points of headroom here.
|
||||||
|
MASSIVE was not trained for Maven's intents. Read it as "the warm-start does not
|
||||||
|
cost intent accuracy", nothing more.
|
||||||
|
|
||||||
|
## The measurement that does mean something
|
||||||
|
|
||||||
|
`want_time` is the one slot Maven's fixture scores, and MASSIVE has `time` and
|
||||||
|
`date`. Restricted to those two slot types, F1 is 74.9% over 609 gold spans on the
|
||||||
|
MASSIVE ru test split. Precision is 71.5 and recall 78.7. That beats the 71.5%
|
||||||
|
all-slot figure. Of the 530 test utterances carrying a time or a date, 73.4% get
|
||||||
|
every such span exactly right.
|
||||||
|
|
||||||
|
Out of domain matters more, because Maven's traffic is not this corpus. Ten
|
||||||
|
Maven-shaped utterances, none of them in MASSIVE:
|
||||||
|
|
||||||
|
| utterance | tagged |
|
||||||
|
|---|---|
|
||||||
|
| `напомни в 11:00 позвонить маме` | `time='11:00'`, `relation='маме'` |
|
||||||
|
| `напомни завтра в семь утра выпить таблетки` | `date='завтра'`, `time='семь утра'` |
|
||||||
|
| `поставь будильник на полседьмого` | `time='полседьмого'` |
|
||||||
|
| `через двадцать минут напомни про чайник` | `time='двадцать минут'` |
|
||||||
|
| `напомни в пятницу вечером забрать посылку` | `date='пятницу'`, `timeofday='вечером'` |
|
||||||
|
| `что у меня сегодня после обеда` | `date='сегодня'`, `time='после'`, `timeofday='обеда'` |
|
||||||
|
| `запиши что кофе закончился` | nothing |
|
||||||
|
| `что такое TCP` | `definition_word='TCP'` |
|
||||||
|
|
||||||
|
The first row is the V-572 defect utterance. `ReminderGrammar` handed the daemon
|
||||||
|
`HasTime: false` there, and the daemon asked "Когда?" at a sentence that had
|
||||||
|
already said when. `полседьмого` is a colloquial half-past that no digit pattern
|
||||||
|
catches. `запиши что кофе закончился` correctly carries nothing, because a note
|
||||||
|
has no time.
|
||||||
|
|
||||||
|
Two errors. `после обеда` split into `time='после'` plus `timeofday='обеда'`
|
||||||
|
when it is one span, and `через двадцать минут` dropped its `через`. Both are
|
||||||
|
boundary errors on spans the tagger did find.
|
||||||
|
|
||||||
|
Unplanned: `что такое TCP` returned `definition_word='TCP'`. MASSIVE has a slot
|
||||||
|
for the thing being asked about, which is a `SourceWorld` signal sitting in a
|
||||||
|
head already trained.
|
||||||
|
|
||||||
|
Ten hand-picked utterances are evidence, not a fixture.
|
||||||
|
|
||||||
|
## What this does not measure
|
||||||
|
|
||||||
|
Maven has no span fixture. `want_time` and `want_fn` are presence booleans and
|
||||||
|
`want_fact_key` is an exact string match, so nothing in the repo can score a
|
||||||
|
71.5% span tagger. Destination got one the same day, at 12/33 on the classifier
|
||||||
|
cascade: see `2026-08-08-destination-fixture.md`.
|
||||||
|
|
||||||
|
The missing span fixture is why the warm-start stays unjudged against Maven
|
||||||
|
rather than against MASSIVE.
|
||||||
|
|
||||||
|
## Datasets ruled out
|
||||||
|
|
||||||
|
Checked on 2026-08-08 and rejected as label sources:
|
||||||
|
|
||||||
|
- **MASSIVE's other 50 locales** ship in the same tarball and are parallel by id.
|
||||||
|
Co-training on them is free and unmeasured. English was ruled out by the owner
|
||||||
|
on 2026-08-08.
|
||||||
|
- **CLINC150** is reachable as parquet, 150 intents and 1,200 explicit
|
||||||
|
out-of-scope queries, English only. Its value is the labeled out-of-scope set
|
||||||
|
for fitting the energy threshold, not intent labels.
|
||||||
|
- **`d0rj/dolphin-ru`**, roughly 2.8M rows of FLAN-style tasks translated to
|
||||||
|
Russian. No intent, no slots, and not utterances anyone says to an assistant.
|
||||||
|
- **`psytechlab/EmpatheticIntents-ru`**, 24,856 rows of translated
|
||||||
|
EmpatheticDialogues with 32 emotion labels. Maven's mood enum is `neutral,
|
||||||
|
happy, thinking, tired, confused` and it describes her own reply, not the
|
||||||
|
speaker's emotion. No mapping exists.
|
||||||
|
- **`ai-forever/MERA`** and **`RussianNLP/russian_super_glue`**, benchmark
|
||||||
|
harnesses. Rows are prompt templates with `{toxic_comment}` placeholders.
|
||||||
|
- **`ZeroAgency/ru-big-russian-dataset`**, an LLM-judge quality corpus. Its
|
||||||
|
`question` and `classified_topic` columns are a usable Russian out-of-scope
|
||||||
|
pool for threshold fitting. That is the one thing CLINC150 can only supply in
|
||||||
|
English. The questions are long and written, so they belong in the negative
|
||||||
|
set, never in the in-scope `query` training set.
|
||||||
|
- No second Russian slot-filling corpus exists. The xSID mirrors are 404,
|
||||||
|
MultiATIS++ has no Russian, SLURP is not on the Hub.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# The parked clarify ride, bounded and re-measured
|
||||||
|
|
||||||
|
Date: 2026-08-08, V-663. Same 140 turns, same driver, third and fourth runs of
|
||||||
|
the day. Before is `d6f3914`, after is that plus two changes.
|
||||||
|
|
||||||
|
## What was measured before
|
||||||
|
|
||||||
|
One question about a reminder's day rode turns 6 to 13. It ended only because
|
||||||
|
turn 14 was a new request. Two of those turns are the worst replies in the
|
||||||
|
corpus:
|
||||||
|
|
||||||
|
```text
|
||||||
|
спасибо -> Сейчас 21:25. В какой день?
|
||||||
|
привет -> Сейчас 21:25. В какой день?
|
||||||
|
```
|
||||||
|
|
||||||
|
V-654 had already added `MaxSuspends` and the tail count had not moved.
|
||||||
|
|
||||||
|
## Why three bounds let it happen
|
||||||
|
|
||||||
|
The TTL, `MaxAttempts` and `MaxSuspends` all exist and all were rearmed.
|
||||||
|
|
||||||
|
An aside spends no attempt, so `MaxAttempts` never reaches it. A turn reading as
|
||||||
|
a failed answer zeroes `Suspends`, so `MaxSuspends` never reaches the asides.
|
||||||
|
`спасибо` read as a failed answer. So the pleasantry both produced the reply
|
||||||
|
above and paid for it out of the budget meant to end the ride.
|
||||||
|
|
||||||
|
## The two changes
|
||||||
|
|
||||||
|
`PendingQuestion.Rides` counts the same event as `Suspends` with the resets
|
||||||
|
taken out. Set once, incremented only in `noteSuspended`, carried across the
|
||||||
|
re-park in `askRemainingGap`. `MaxRides` is 4, one looser than `MaxSuspends` so
|
||||||
|
that the tighter statement about a run stays reachable.
|
||||||
|
|
||||||
|
A pleasantry is an aside, not a failed answer. The set is a new closed lexicon
|
||||||
|
entry, matched as whole utterances rather than tokens. `вечер` answers `это утра
|
||||||
|
или вечера?` and `нет` answers a confirm. Anything that could fill a slot stays
|
||||||
|
out.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
| | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| turns carrying a clarify tail | 21 | 17 |
|
||||||
|
| turns carrying any failure string | 31 | 29 |
|
||||||
|
| the longest ride | 8 turns | 4 turns |
|
||||||
|
|
||||||
|
The turns carrying a tail, by number:
|
||||||
|
|
||||||
|
```text
|
||||||
|
before 6 7 8 9 10 11 12 13 53 54 55 56 57 99 102 103 116 136 137 138 139
|
||||||
|
after 6 7 8 9 53 54 55 56 57 99 100 101 116 136 137 138 139
|
||||||
|
```
|
||||||
|
|
||||||
|
Turn 10 is the change. It now reads:
|
||||||
|
|
||||||
|
```text
|
||||||
|
спасибо -> Прошлую просьбу отпускаю. Пожалуйста, я всегда готова помочь тебе.
|
||||||
|
```
|
||||||
|
|
||||||
|
She lets the request go, says so, and answers the man. Turns 11 to 13 are clean.
|
||||||
|
|
||||||
|
**`MaxRides` is not what fired.** The pleasantry is an aside now, so it no
|
||||||
|
longer breaks the run. `MaxSuspends` reached three on turn 10 and ended it.
|
||||||
|
`Rides` is the backstop for the shape where an answer really does break the run.
|
||||||
|
No turn in this corpus reaches it.
|
||||||
|
|
||||||
|
## What did not move
|
||||||
|
|
||||||
|
Four rides are untouched. Turns 53 to 57 are five consecutive asides against a
|
||||||
|
reminder missing its day. Turn 58 is a new request that drops it. Nothing
|
||||||
|
pleasant appears in that run, so neither change applies. Turns 99 to 101 shifted
|
||||||
|
by one, and 116 and 136 to 139 are unchanged.
|
||||||
|
|
||||||
|
So the fix is worth four turns of twenty-one. What is left is asides against a
|
||||||
|
question the owner never answers. `MaxSuspends` was written for that shape and
|
||||||
|
does bound it, at four turns each.
|
||||||
|
|
||||||
|
## Not attributable
|
||||||
|
|
||||||
|
Latency moved p50 1.1s to 1.5s and p95 2.8s to 3.0s, and the 34.3s outlier in
|
||||||
|
the earlier run is gone. Both runs had the workstation up. Read none of it as
|
||||||
|
caused by this change.
|
||||||
|
|
||||||
|
One unrelated defect appeared in the after run and is recorded here because it
|
||||||
|
is visible in the transcript. Turn 4 answered `Я записала твою привычкуRegarding
|
||||||
|
coffee without sugar.` That is English leaking into a Russian reply with no
|
||||||
|
space in front of it. It is a phrasing defect and it has no task yet.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# The routing heads, running in Go
|
||||||
|
|
||||||
|
Date: 2026-08-08. Vikunja V-664.
|
||||||
|
Weights: `router_heads.onnx`, fp32, exported from `heads.pt` on workpc.
|
||||||
|
Fixture: `internal/router/eval/ru_routing_v1.json`, 96 cases, 33 carrying a destination.
|
||||||
|
Runner: `make t PKG=./internal/router/eval/ RUN=TestONNXRoutingHeads`.
|
||||||
|
|
||||||
|
The four heads of V-661 ran nowhere. This is the number they score through the
|
||||||
|
Go cascade. Same fixture and same grammars as `TestONNXBaseline`, and only the
|
||||||
|
middle stage varies.
|
||||||
|
|
||||||
|
## Headline
|
||||||
|
|
||||||
|
| | classifier + ONNX | heads + classifier | gemma-4-12b cascade |
|
||||||
|
|---|---|---|---|
|
||||||
|
| intent | 75.0% (72/96) | **96.9% (93/96)** | 84.4% |
|
||||||
|
| destination | 33.3% (11/33) | **75.8% (25/33)** | 72.7% |
|
||||||
|
| false clarify | 0 | 1 | 2 |
|
||||||
|
| missed clarify | 8 | 1 | 1 |
|
||||||
|
| p50 | 24.5ms | 27.9ms | 329ms |
|
||||||
|
|
||||||
|
A 118M encoder beats the 12B teacher it was distilled from. It wins on both
|
||||||
|
halves of the route, at a twelfth of the latency. The workstation stays the
|
||||||
|
better phraser and is no longer the better router.
|
||||||
|
|
||||||
|
The p50 is not the heads. Most of it is the classifier's own embedder pass on
|
||||||
|
the turns the heads decline, plus process warm-up on the first case. The heads'
|
||||||
|
own forward pass measures 7.3ms on workpc.
|
||||||
|
|
||||||
|
## Two defects were in the way, and the first was not in the heads
|
||||||
|
|
||||||
|
**The tokenizer read every long word backwards.** `encodeWord` backtracks the
|
||||||
|
Viterbi path from the end of a word and prepends each piece. That puts them back
|
||||||
|
in reading order, and a second reverse after the loop undid it. So
|
||||||
|
`query: вода` tokenized to `[0 12 1294 41 12489 2]` where the reference
|
||||||
|
tokenizer gives `[0 41 1294 12 12489 2]`.
|
||||||
|
|
||||||
|
It was found here and only here. The heads were trained through transformers and
|
||||||
|
are read through the hand-written tokenizer. So a mismatch shows up as a score
|
||||||
|
far below what Python measured on the same weights. Nothing else in the suite
|
||||||
|
compares the two.
|
||||||
|
|
||||||
|
Measured on the recall fixture, same 27 cases either way:
|
||||||
|
|
||||||
|
| | reversed | fixed |
|
||||||
|
|---|---|---|
|
||||||
|
| recall@1 | 70.4% (19/27) | **77.8% (21/27)** |
|
||||||
|
| recall@3 | 85.2% (23/27) | **96.3% (26/27)** |
|
||||||
|
| answered after gate | 63.0% | 66.7% |
|
||||||
|
| wrong note on top | 8 | 6 |
|
||||||
|
| false recall | 0/5 | 1/5 |
|
||||||
|
|
||||||
|
The classifier barely moved, 76.0% to 75.0%, and destination 36.4% to 33.3%.
|
||||||
|
Both are one case on 96 and neither is a finding. Seeds and queries were mangled
|
||||||
|
the same way, so cosine survived it. Recall is where it cost, because a stored
|
||||||
|
passage and a live query are different lengths and break differently.
|
||||||
|
|
||||||
|
The one new false recall is the honest cost and it is not being hidden. A
|
||||||
|
sharper embedder scores every candidate higher, including the ones that should
|
||||||
|
have stayed under the gate. That is the same trade `2026-08-04-recall-e5-small.md`
|
||||||
|
recorded when e5-small replaced MiniLM.
|
||||||
|
|
||||||
|
The embedder id now carries a tokenizer revision, `model_quantized@384/tok2`.
|
||||||
|
Stored vectors were written under rev 1 and no longer sit in the same space as a
|
||||||
|
query embedded now. The model file's name never moved, so nothing would have
|
||||||
|
triggered `ReembedAll`. On the box the marker fired on start, and the re-embed
|
||||||
|
rewrote 65 notes and 19 facts in 5 seconds.
|
||||||
|
|
||||||
|
**The clarify head was being thrown away.** It was read only when the intent head
|
||||||
|
cleared its own threshold. That cost 6 of the 8 ambiguous cases. `вода` reads as intent
|
||||||
|
`act` at 0.233 and clarify at 0.983. Burying that handed the turn to the
|
||||||
|
classifier, which routed it confidently and never asked. The clarify head answers
|
||||||
|
a different question, which is whether there is enough here to act on at all. So
|
||||||
|
it decides on its own and decides first.
|
||||||
|
|
||||||
|
| | intent-gated | clarify decides first |
|
||||||
|
|---|---|---|
|
||||||
|
| intent | 90.6% | 96.9% |
|
||||||
|
| missed clarify | 7 | 1 |
|
||||||
|
| false clarify | 0 | 1 |
|
||||||
|
|
||||||
|
## The threshold is measured, not chosen
|
||||||
|
|
||||||
|
Max softmax over the intent head, on the 88 cases carrying an intent:
|
||||||
|
|
||||||
|
| threshold | kept | accuracy kept | wrong kept | right dropped |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 0.5 | 84 | 96.4% | 3 | 2 |
|
||||||
|
| **0.6** | **81** | **97.5%** | **2** | **4** |
|
||||||
|
| 0.7 | 75 | 97.3% | 2 | 10 |
|
||||||
|
| 0.8 | 64 | 96.9% | 2 | 21 |
|
||||||
|
| 0.9 | 46 | 100.0% | 0 | 37 |
|
||||||
|
|
||||||
|
0.6 is the knee. Every value from 0.7 to 0.85 drops right answers and keeps the
|
||||||
|
same two wrong ones. 0.9 is the only value that clears them, and it costs 37
|
||||||
|
correct routes to do it.
|
||||||
|
|
||||||
|
## Quantization was measured and rejected
|
||||||
|
|
||||||
|
| build | size | intent | destination | p50 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| fp32 | 470MB | 83/88 (94.3%) | 28/33 (84.8%) | 7.3ms |
|
||||||
|
| int8 | 118MB | 79/88 (89.8%) | 26/33 (78.8%) | 4.0ms |
|
||||||
|
| fp16 | 235MB | will not load | — | — |
|
||||||
|
|
||||||
|
Python numbers, on the heads alone rather than through the cascade. int8 costs
|
||||||
|
4.5 points of intent and 6 of destination to save 3ms. The cascade around it has
|
||||||
|
a p50 over a second when the resident model answers. The fp16 graph is broken:
|
||||||
|
`convert_float_to_float16` leaves a Cast node emitting float16 where the graph
|
||||||
|
expects float, and onnxruntime refuses the session. It was not worth fixing.
|
||||||
|
|
||||||
|
The exporter also had to be told to write one file. It splits weights into a
|
||||||
|
`.onnx.data` sidecar by default. This onnxruntime resolves that path against the
|
||||||
|
process working directory rather than the model. A split graph loads from one
|
||||||
|
directory only.
|
||||||
|
|
||||||
|
## What is still wrong
|
||||||
|
|
||||||
|
**Four of the eight destination misses are calendar.** Training cannot move them.
|
||||||
|
The possessive agenda rules claim those cases at stage 0 and name nothing on
|
||||||
|
purpose. That caution was free while nothing downstream could name anything
|
||||||
|
either. It has now cost four points in three separate measurements. The call is
|
||||||
|
the owner's and it is still open.
|
||||||
|
|
||||||
|
**The slot head is exported and not read.** Slots come from the stage-2
|
||||||
|
extractor. Mapping BIO tags back to text needs character offsets the unigram
|
||||||
|
tokenizer does not keep, which is its own piece of work.
|
||||||
|
|
||||||
|
**`поужинал` is a false clarify**, which is the same defect `thinSingleToken`
|
||||||
|
was narrowed for on 2026-08-01, arriving now from a different direction.
|
||||||
|
|
||||||
|
## On the box
|
||||||
|
|
||||||
|
Deployed to homesrv the same day. `voice: routing heads loaded` on start, and
|
||||||
|
`/trace` shows `routing-heads` winning or thinning every turn. The resident model
|
||||||
|
and the classifier are both marked never asked. Live probes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
что такое TCP? -> kiwix a real definition
|
||||||
|
кто такой Линус Торвальдс? -> kiwix a real answer
|
||||||
|
во сколько я лёг вчера -> personal не нашла у тебя такой записи
|
||||||
|
вода -> thinned to clarify at 0.233 / 0.983
|
||||||
|
```
|
||||||
|
|
||||||
|
A missing or broken weights file logs and leaves the heads nil, which is
|
||||||
|
byte-for-byte the cascade that shipped before this.
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
# Two heads on e5-small, and the first destination the router did not need a model for
|
||||||
|
|
||||||
|
Measured 2026-08-08 on workpc (Radeon RX 7900 GRE, ROCm). Covers V-661, step 3 of
|
||||||
|
`docs/plans/18-routing-heads-on-e5-small.md`. Workspace is `~/Programs/embed-training`,
|
||||||
|
scripts `gen_query_source.py`, `label_source.py`, `build_heads_corpus.py`,
|
||||||
|
`train_heads.py`, `score_confidence.py`.
|
||||||
|
|
||||||
|
## Two heads, not four
|
||||||
|
|
||||||
|
Intent is 7 classes and destination is 12 plus the `SourceUnknown` floor, sharing
|
||||||
|
one masked mean pool over one forward pass. The plan asked for four. Two of them
|
||||||
|
have no labels and neither is a GPU problem.
|
||||||
|
|
||||||
|
**Mood is cut, not deferred.** Maven's enum is `neutral, happy, thinking, tired,
|
||||||
|
confused` and it describes her own reply state, not the speaker's emotion.
|
||||||
|
`psytechlab/EmpatheticIntents-ru` was the only candidate and its 32 emotion
|
||||||
|
labels do not map onto it. There is nothing to train against.
|
||||||
|
|
||||||
|
**BIO slot tags stay in the MASSIVE body.** No Maven-domain span corpus exists.
|
||||||
|
`2026-08-08-massive-warm-start.md` records that no second Russian slot-filling
|
||||||
|
corpus is reachable at all.
|
||||||
|
|
||||||
|
The destination loss is masked with `ignore_index`. Only a query turn reaches
|
||||||
|
`queryWalk`, so a reminder contributes nothing to it.
|
||||||
|
|
||||||
|
## Where the destination labels came from
|
||||||
|
|
||||||
|
V-660 taught the router prompt to name a destination. That made gemma-4-12b a
|
||||||
|
teacher, and this distils it.
|
||||||
|
|
||||||
|
Labelling the 300 query rows already in `train_v5.jsonl` gave 277 destinations.
|
||||||
|
The shape was unusable: the floor 101, calendar 62, recall 47, and `feeds` and
|
||||||
|
`attention` at zero. A 13-way softmax cannot learn a class with no examples.
|
||||||
|
|
||||||
|
`gen_query_source.py` is the destination half of `gen_corpus.py` and runs the
|
||||||
|
same two passes. Gemma writes questions whose answer lives in one named place.
|
||||||
|
The daemon's own `routeSystem` prompt then routes each one back. A line survives
|
||||||
|
only when the intent is `query` **and** the source is the destination it was
|
||||||
|
generated for. The glosses are copied verbatim out of `route_system.txt`, so the
|
||||||
|
generator and the labeller work from one definition.
|
||||||
|
|
||||||
|
The prompt and the GBNF are dumped from `internal/router/llmrouter.go` by
|
||||||
|
`TestDumpPrompt`, never retyped. The workspace held its own copies and V-660
|
||||||
|
changed both.
|
||||||
|
|
||||||
|
1229 kept of 2373 generated, 51.8%. Merged corpus is 3664 rows carrying 1727
|
||||||
|
destinations:
|
||||||
|
|
||||||
|
| | rows | | rows |
|
||||||
|
|---|---|---|---|
|
||||||
|
| the floor | 220 | world | 132 |
|
||||||
|
| calendar | 180 | money | 126 |
|
||||||
|
| tasks | 169 | list | 124 |
|
||||||
|
| recall | 167 | self, feeds, attention | 120 each |
|
||||||
|
| weather | 136 | network | 103 |
|
||||||
|
| | | home | 50 |
|
||||||
|
|
||||||
|
`home` is thin because the agreement filter rejected most of what was generated
|
||||||
|
for it. A question about the house routes `act` more often than `query`. That is
|
||||||
|
the filter working, and 50 is the finding rather than a shortfall.
|
||||||
|
|
||||||
|
**The floor was regenerated once.** The first 120 rows carried one sentence
|
||||||
|
shape across eight topics. That shape was "что там с X" and its two synonyms.
|
||||||
|
Every named destination varied and only the floor collapsed. The reason is that
|
||||||
|
the generator varies a topic, and ambiguity is not a topic.
|
||||||
|
|
||||||
|
`gen_query_source.py` now rotates six floor shapes. A `почему` question, a yes
|
||||||
|
or no question, and a question carried by intonation alone. Then a
|
||||||
|
better-or-worse question, a status question, and an existence question. That is
|
||||||
|
a fix to degenerate generation. It is not fitting to the fixture, whose floor
|
||||||
|
cases are homelab operations and match none of the six.
|
||||||
|
|
||||||
|
The 1229 generated rows carry `intent: null`. Every one is a query by
|
||||||
|
construction. There are five times as many as the corpus has query rows, so
|
||||||
|
including them would make query half the intent corpus.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
Three seeds, two bodies, epoch chosen on the intent dev slice and never on a
|
||||||
|
destination number.
|
||||||
|
|
||||||
|
| body | floor corpus | intent mean | destination mean |
|
||||||
|
|---|---|---|---|
|
||||||
|
| warm-started `out/body_massive` | one shape | 93.6% | 75.8% |
|
||||||
|
| stock `multilingual-e5-small` | one shape | 93.6% | 76.8% |
|
||||||
|
| warm-started `out/body_massive` | six shapes | 93.6% | **80.8%** |
|
||||||
|
|
||||||
|
Best single run is destination **29/33 (87.9%)**, seed 0 on the rotated floor.
|
||||||
|
Peak 1.68GB of 17.2GB, under four minutes end to end.
|
||||||
|
|
||||||
|
Against the two arms already measured on the same 33 labelled cases:
|
||||||
|
|
||||||
|
| | destination |
|
||||||
|
|---|---|
|
||||||
|
| classifier cascade (V-659) | 12/33 (36.4%) |
|
||||||
|
| cascade + gemma-4-12b (V-660) | 24/33 (72.7%) |
|
||||||
|
| two heads on e5-small | 29/33 (87.9%) |
|
||||||
|
|
||||||
|
A 118M encoder beats the 12B teacher it was distilled from, on the fixture. The
|
||||||
|
per-destination split is where it happens: **recall 15/15** and **world 5/5**.
|
||||||
|
Recall was 0/15 on the cascade and 14/15 through gemma.
|
||||||
|
|
||||||
|
Read 87.9% as one seed of a mean of 80.8%, not as a headline. Three seeds score
|
||||||
|
29, 25 and 26 of 33. One case is 3 points on a fixture this small.
|
||||||
|
|
||||||
|
Intent is **not** comparable to the 73/96 and 81/96 figures those two arms
|
||||||
|
scored. A softmax has no clarify class. The head's fixture is the 88 cases that
|
||||||
|
carry an intent, and the 8 `want_clarify` cases are scored separately below.
|
||||||
|
|
||||||
|
## The MASSIVE warm-start is worth nothing here either
|
||||||
|
|
||||||
|
Step 2 measured it at +0.4 points of intent accuracy and called that inside seed
|
||||||
|
noise. Destination was the open question, because MASSIVE has a
|
||||||
|
`definition_word` slot that looked like a `SourceWorld` signal sitting in a head
|
||||||
|
already trained.
|
||||||
|
|
||||||
|
It is not. The two bodies score the same intent mean to one decimal. Stock is
|
||||||
|
one point ahead on destination, which is a third of one case. Nothing here argues
|
||||||
|
for keeping the warm-start step. Dropping it removes a dependency on a corpus
|
||||||
|
pull that `datasets` 5.0 cannot do.
|
||||||
|
|
||||||
|
## The floor moved, calendar did not
|
||||||
|
|
||||||
|
Before the rotation, all seven misses at seed 0 were the floor and calendar. The
|
||||||
|
head named a destination where the fixture says walk the chain, and it was
|
||||||
|
confident doing it. `"почему сервер тормозит"` read `world` at 0.80.
|
||||||
|
`"хватает ли места под новые бэкапы"` read `network` at 0.82. Those are the five
|
||||||
|
homelab cases V-659 flagged, where `SourceRecall`, `SourceNetwork` and
|
||||||
|
`SourceAttention` all overlap. `mavpoll` writes its observations into the fact
|
||||||
|
store recall reads.
|
||||||
|
|
||||||
|
| | one shape | six shapes |
|
||||||
|
|---|---|---|
|
||||||
|
| the floor | 3/7 | 6/7, 6/7, 5/7 |
|
||||||
|
| calendar | 3/6 | 3/6, 3/6, 3/6 |
|
||||||
|
| recall | 15/15 | 15/15 at seed 0 |
|
||||||
|
| world | 5/5 | 5/5 |
|
||||||
|
|
||||||
|
The floor was a corpus defect and it cost 3 cases. Sentence variety carried it,
|
||||||
|
not homelab vocabulary, which the training rows still do not contain.
|
||||||
|
|
||||||
|
**Calendar is 3/6 at every seed and is a different problem.** It is the shape
|
||||||
|
V-660 named. The possessive agenda rules claim those cases at stage 0 and
|
||||||
|
deliberately name nothing, so no destination label reaches the head. Training
|
||||||
|
cannot move a case the head never sees. That one wants the owner's call.
|
||||||
|
|
||||||
|
## Max softmax separates, weakly, and the gate stays
|
||||||
|
|
||||||
|
The plan argues max softmax is a calibratable confidence where `Confidence: 1.0`
|
||||||
|
was a hardcode. Measured on the intent head:
|
||||||
|
|
||||||
|
| | n | mean confidence |
|
||||||
|
|---|---|---|
|
||||||
|
| correct | 80 | 0.897 |
|
||||||
|
| wrong | 8 | 0.705 |
|
||||||
|
| `want_clarify` | 8 | 0.685 |
|
||||||
|
|
||||||
|
The softest correct answer is 0.66 and 3 of 8 clarify cases sit below it. So a
|
||||||
|
single cut buys three clarifies at no false-clarify cost, and no more.
|
||||||
|
|
||||||
|
The other five explain themselves. `"напомни"` scores 0.94 as `reminder` and
|
||||||
|
`"сделай это"` scores 0.80 as `act`. Both are intent-certain and slot-empty, and
|
||||||
|
confidence was never the signal there. `gateLLMDecision` already catches exactly
|
||||||
|
that shape, an act with no allowlisted fn or a keyless fact, and it keeps doing
|
||||||
|
so. The head replaces the hardcode. It does not replace the gate.
|
||||||
|
|
||||||
|
## An incident worth recording
|
||||||
|
|
||||||
|
The first generation run produced zero rows for eight destinations. `mavgpud`
|
||||||
|
yields the card when another process wants it (V-488) and llama-server answers
|
||||||
|
503 until the model is back. Every generate call inside that window burned one of
|
||||||
|
the destination's batches. The run walked its own cap without a single successful
|
||||||
|
call. The log said `503` 260 times, and the summary line said 64.1% keep rate,
|
||||||
|
which read as success.
|
||||||
|
|
||||||
|
`call()` now retries a 503 with backoff. A generator that treats an unloaded
|
||||||
|
model as a bad generation is a silent-corpus bug, not a slow one.
|
||||||
|
|
||||||
|
## What this does not measure
|
||||||
|
|
||||||
|
**Nothing here runs in Go.** The heads are a `heads.pt` and an
|
||||||
|
`out/body_heads/` on workpc. Reaching the daemon needs an ONNX export and a
|
||||||
|
caller. The resident e5-small must not be replaced by this copy: recall depends
|
||||||
|
on that file, and `EmbedQuery`/`EmbedPassage` are its contract.
|
||||||
|
|
||||||
|
The destination fixture carries 4 of 13 classes: recall 15, floor 7, calendar 6,
|
||||||
|
world 5. `tasks`, `money`, `list`, `home`, `network`, `feeds`, `attention` and
|
||||||
|
`self` have no gold case. So 78.8% is silent on eight destinations that together
|
||||||
|
hold 800 training rows.
|
||||||
|
|
||||||
|
Latency was not measured. A forward pass of a 118M encoder should beat a 1.7B
|
||||||
|
decoder on a query turn. That is arithmetic, not a number from this box.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# A slot head, and the corpus that did not exist this morning
|
||||||
|
|
||||||
|
Measured 2026-08-08 on workpc, the same day as `2026-08-08-routing-heads-two-head.md`
|
||||||
|
and against the same fixtures. Workspace is `~/Programs/embed-training`, new
|
||||||
|
scripts `slot_grammar.gbnf`, `slot_system.txt`, `label_slots.py`, `merge_slots.py`.
|
||||||
|
|
||||||
|
## The corpus was the whole problem
|
||||||
|
|
||||||
|
The two-head measurement said BIO slot tags stay in the MASSIVE body, because no
|
||||||
|
Maven-domain span corpus exists. That was true of found corpora and false of
|
||||||
|
made ones. Destination had the same shape at breakfast. V-660 gave gemma-4-12b a
|
||||||
|
string to write and the label problem became a generation problem.
|
||||||
|
|
||||||
|
The same trick applies to spans. `slot_grammar.gbnf` emits a list of
|
||||||
|
`{"slot": ..., "text": ...}` and the enum closes over Maven's own five: `time`,
|
||||||
|
`text`, `key`, `value`, `fn`. `slot_system.txt` demands each span be an exact
|
||||||
|
substring of the utterance.
|
||||||
|
|
||||||
|
**The agreement filter is free here.** Destination needed a second pass. The
|
||||||
|
daemon's own router prompt had to route each generated line back. A span needs
|
||||||
|
no second call. It either occurs in the utterance or it does not, and
|
||||||
|
`label_slots.py` drops it with `find()`.
|
||||||
|
|
||||||
|
1702 rows labelled from `train_v5.jsonl`, the reminder, fact, note, act and
|
||||||
|
query intents. Chat and system carry no slot and were never asked.
|
||||||
|
|
||||||
|
| slot | spans |
|
||||||
|
|---|---|
|
||||||
|
| text | 1175 |
|
||||||
|
| time | 485 |
|
||||||
|
| fn | 381 |
|
||||||
|
| key | 72 |
|
||||||
|
| value | 65 |
|
||||||
|
|
||||||
|
37 spans dropped as not-a-substring, 2.2% of the pile. Nothing failed to parse,
|
||||||
|
which is the grammar doing its job. 409 rows came back with no span at all.
|
||||||
|
Those are kept and tagged all `O`. An utterance carrying no slot teaches the
|
||||||
|
head not to invent one. An empty list is a label and not a miss.
|
||||||
|
|
||||||
|
`key` and `value` are thin because they come from facts alone. That is the
|
||||||
|
shape of the corpus, not a labeller failure.
|
||||||
|
|
||||||
|
## Three heads on one forward pass
|
||||||
|
|
||||||
|
Intent and destination were already two linear heads over one masked mean pool.
|
||||||
|
Slots is a third head over the per-token states of the same pass, so the marginal
|
||||||
|
cost is one `Linear(384, 11)`.
|
||||||
|
|
||||||
|
The tag set is `O` plus `B-` and `I-` for each of the five. A softmax cannot
|
||||||
|
emit a tag that does not exist. That is the structural guarantee the GBNF buys
|
||||||
|
for the teacher, and the head gets it for free.
|
||||||
|
|
||||||
|
Two masking rules, both `ignore_index`. A row with no `spans` key contributes
|
||||||
|
nothing, which covers the 1900 generated destination rows and every chat and
|
||||||
|
system turn. A padding or special-token position contributes nothing either.
|
||||||
|
|
||||||
|
Scoring is exact-match span F1, not token accuracy. Most tokens are `O`, so a
|
||||||
|
tagger that predicts nothing anywhere scores above 90% on tokens.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
Three seeds, 24 epochs, epoch chosen on the intent dev slice alone.
|
||||||
|
|
||||||
|
| | two heads | three heads |
|
||||||
|
|---|---|---|
|
||||||
|
| intent mean | 93.6% | 92.8% |
|
||||||
|
| destination mean | 80.8% | 82.8% |
|
||||||
|
| destination best | 29/33 (87.9%) | 29/33 (87.9%) |
|
||||||
|
| slot span F1 mean | — | 72.4% |
|
||||||
|
|
||||||
|
**The slot head costs nothing and adds a third decision.** Intent moves 0.8
|
||||||
|
points down and destination 2 points up. Both sit inside the seed spread those
|
||||||
|
two numbers already had. Read this as unchanged, not as a trade.
|
||||||
|
|
||||||
|
The saved checkpoint is seed 1 at epoch 10: intent 93.2%, destination 81.8%,
|
||||||
|
slot F1 75.8%. `heads.pt` now carries three state dicts and the `bio` list
|
||||||
|
beside the intent and source enums.
|
||||||
|
|
||||||
|
## Epoch selection is now wrong for one of the three heads
|
||||||
|
|
||||||
|
Slot F1 was still climbing when the intent-selected epoch stopped it. Seed 0
|
||||||
|
selects epoch 13 at 70.9% and reaches 76.1% at epoch 20. Seed 1 selects epoch 10
|
||||||
|
at 75.8% and reaches 80.0% at epoch 24.
|
||||||
|
|
||||||
|
So the three tasks want different epochs and the harness picks one. Two ways
|
||||||
|
out, and neither was taken here. Select on a joint score, which needs an
|
||||||
|
argument about weights. Or give the slot head its own dev slice and its own
|
||||||
|
early stop, which means the heads stop being one checkpoint.
|
||||||
|
|
||||||
|
Leaving it costs about 4 points of slot F1 and nothing else. The router
|
||||||
|
contract is graded on intent. Selecting on it is the conservative choice rather
|
||||||
|
than an oversight.
|
||||||
|
|
||||||
|
## What this does not measure
|
||||||
|
|
||||||
|
The same gap as the two-head run. **Nothing of this runs in Go.** Three heads
|
||||||
|
instead of two does not change that. It does change the export. The slot head
|
||||||
|
reads the per-token states, so an ONNX graph exposing only the pooled vector is
|
||||||
|
not enough.
|
||||||
|
|
||||||
|
There is no held-out span fixture. Span F1 is measured on the dev slice of the
|
||||||
|
same distilled corpus, so it scores agreement with gemma rather than
|
||||||
|
correctness. The V-572 defect utterance, `"напомни в 11:00 позвонить маме"`, is
|
||||||
|
labelled correctly by the teacher, but one utterance is not a fixture.
|
||||||
|
|
||||||
|
`key` at 72 spans and `value` at 65 are too thin to report a per-slot number.
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
# Raw transcript, two weeks of usage
|
||||||
|
|
||||||
|
## Day 1
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Я не говорю так сейчас, ведь на улице уже вечер.
|
||||||
|
[2.8s t=218]
|
||||||
|
|
||||||
|
YOU: какой сегодня день?
|
||||||
|
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||||
|
[0.0s t=219]
|
||||||
|
|
||||||
|
YOU: сколько времени?
|
||||||
|
MAVEN: сейчас 21 час 9 минут
|
||||||
|
[0.0s t=220]
|
||||||
|
|
||||||
|
YOU: запиши что я пью кофе без сахара
|
||||||
|
MAVEN: Я записала твою заметку про кофе。
|
||||||
|
[1.5s t=221]
|
||||||
|
|
||||||
|
YOU: мой любимый язык программирования go
|
||||||
|
MAVEN: Я записала твою заметку про Go.
|
||||||
|
[1.5s t=222]
|
||||||
|
|
||||||
|
YOU: напомни в 11:00 позвонить маме
|
||||||
|
MAVEN: Сейчас 21:10. В какой день?
|
||||||
|
[0.4s t=223]
|
||||||
|
|
||||||
|
YOU: что у меня сегодня?
|
||||||
|
MAVEN: на 08.08.2026 ничего нет. На какое время поставить напоминание?
|
||||||
|
[2.5s t=224]
|
||||||
|
|
||||||
|
YOU: что такое TCP?
|
||||||
|
MAVEN: вот что я нашла: TCP (Transmission Control Protocol) — это транспортный протокол для передачи данных. Он обеспечивает доставку всех пакетов и позволяет собрать их в правильном порядке. Также TCP является частью сетевой модели TCP/IP, которая описывает способ передачи цифровых данных от источника к получателю. На какое время поставить напоминание?
|
||||||
|
[2.8s t=225]
|
||||||
|
|
||||||
|
YOU: сколько будет 17 на 23?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет результата умножения 17 на 23. На какое время поставить напоминание?
|
||||||
|
[1.6s t=226]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: Сейчас 21:10. В какой день?
|
||||||
|
[1.1s t=227]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 2
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Сейчас 21:10. В какой день?
|
||||||
|
[0.9s t=228]
|
||||||
|
|
||||||
|
YOU: что нового?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи. На какое время поставить напоминание?
|
||||||
|
[1.3s t=229]
|
||||||
|
|
||||||
|
YOU: какая погода?
|
||||||
|
MAVEN: для какого города? На какое время поставить напоминание?
|
||||||
|
[0.7s t=230]
|
||||||
|
|
||||||
|
YOU: запиши что пароль от вайфая лежит в ящике стола
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Я записала, что пароль от вай-фай лежит в ящике стола.
|
||||||
|
[1.5s t=231]
|
||||||
|
|
||||||
|
YOU: где лежит вайфай пароль?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[0.5s t=232]
|
||||||
|
|
||||||
|
YOU: добавь молоко в список покупок
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[1.0s t=233]
|
||||||
|
|
||||||
|
YOU: что у меня в списке покупок?
|
||||||
|
MAVEN: в списке покупок пусто.
|
||||||
|
[0.0s t=234]
|
||||||
|
|
||||||
|
YOU: кто такой Линус Торвальдс?
|
||||||
|
MAVEN: вот что я нашла: Линус Торвальдс — финско-американский программист из Хельсинки, создатель ядра Linux. Он родился 28 декабря 1969 года и окончил факультет компьютерных наук Хельсинкского университета.
|
||||||
|
[2.6s t=235]
|
||||||
|
|
||||||
|
YOU: какой у меня любимый язык?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[0.6s t=236]
|
||||||
|
|
||||||
|
YOU: сколько у меня задач?
|
||||||
|
MAVEN: сначала: проверить интейк форму на боксе (завтра); купить хлеб; купить молоко; полить цветы; починить кран
|
||||||
|
[0.0s t=237]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 3
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: как дела?
|
||||||
|
MAVEN: У меня всё хорошо, я готова помогать тебе. Чем могу быть полезна?
|
||||||
|
[1.2s t=238]
|
||||||
|
|
||||||
|
YOU: напомни завтра в 9 утра купить хлеб
|
||||||
|
MAVEN: хорошо, напомню завтра в 09:00.
|
||||||
|
[0.3s t=239]
|
||||||
|
|
||||||
|
YOU: что у меня завтра?
|
||||||
|
MAVEN: на 09.08.2026 ничего нет.
|
||||||
|
[0.0s t=240]
|
||||||
|
|
||||||
|
YOU: отмени напоминание про хлеб
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[0.5s t=241]
|
||||||
|
|
||||||
|
YOU: какие у меня напоминания?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о твоих напоминаниях.
|
||||||
|
[0.7s t=242]
|
||||||
|
|
||||||
|
YOU: сохрани мне адрес гостиницы в Сочи
|
||||||
|
MAVEN: Я записала твой адрес отеля в Сочи.
|
||||||
|
[2.2s t=243]
|
||||||
|
|
||||||
|
YOU: что я сохранил про Сочи?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о том, что ты сохранил про Сочи.
|
||||||
|
[1.5s t=244]
|
||||||
|
|
||||||
|
YOU: почему сервер тормозит?
|
||||||
|
MAVEN: вот что я нашла: сервер может тормозить из-за неудачной конфигурации или неоптимальных настроек, отсутствия кэширования, неоптимизированного программного обеспечения, которое нужно обновлять, а также из-за режима энергосбережения процессора, когда он не успевает повысить частоту при нагрузке.
|
||||||
|
[3.7s t=245]
|
||||||
|
|
||||||
|
YOU: хватает ли места под новые бэкапы?
|
||||||
|
MAVEN: нового вот что: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||||
|
[0.7s t=246]
|
||||||
|
|
||||||
|
YOU: выключи свет в спальне
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[0.4s t=247]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 4
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Сейчас уже вечер, поэтому я не могу сказать тебе «доброе утро».
|
||||||
|
[1.4s t=248]
|
||||||
|
|
||||||
|
YOU: что я пропустил?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о том, что ты пропустил.
|
||||||
|
[1.3s t=249]
|
||||||
|
|
||||||
|
YOU: о чём мы вчера говорили?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[1.0s t=250]
|
||||||
|
|
||||||
|
YOU: запиши что я записался к врачу на четверг
|
||||||
|
MAVEN: Я записала твою запись к врачу.
|
||||||
|
[1.2s t=251]
|
||||||
|
|
||||||
|
YOU: когда я иду к врачу?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о том, когда именно ты идешь к врачу.
|
||||||
|
[2.1s t=252]
|
||||||
|
|
||||||
|
YOU: что такое ZFS?
|
||||||
|
MAVEN: вот что я нашла: ZFS — это современная и мощная файловая система, в которой сочетаются безопасность, масштабируемость и удобство управления данными. Она была разработана корпорацией Sun Microsystems и объединяет в себе файловую систему с менеджером томов. Изначально она создавалась для того, чтобы преодолеть недостатки традиционных RAID-массивов, такие как проблема «дыры записи».
|
||||||
|
[2.1s t=253]
|
||||||
|
|
||||||
|
YOU: столица Франции?
|
||||||
|
MAVEN: вот что я нашла: столица Франции — Париж.
|
||||||
|
[3.7s t=254]
|
||||||
|
|
||||||
|
YOU: переведи слово ремонт на английский
|
||||||
|
MAVEN: вот что я нашла: слово «ремонт» на английский можно перевести как repair (например, ремонт дороги или крыши) или fix (общее слово для починки техники и мебели).
|
||||||
|
[2.7s t=255]
|
||||||
|
|
||||||
|
YOU: сколько я потратил в этом месяце?
|
||||||
|
MAVEN: я не отслеживаю траты — не подключено.
|
||||||
|
[0.7s t=256]
|
||||||
|
|
||||||
|
YOU: спокойной ночи
|
||||||
|
MAVEN: Спокойной ночи. Я уже подготовила всё к твоему отдыху.
|
||||||
|
[1.2s t=257]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 5
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Привет! Я готова помогать тебе сегодня.
|
||||||
|
[1.0s t=258]
|
||||||
|
|
||||||
|
YOU: какая погода в Москве?
|
||||||
|
MAVEN: погода не настроена.
|
||||||
|
[0.9s t=259]
|
||||||
|
|
||||||
|
YOU: что там с бэкапами?
|
||||||
|
MAVEN: вот что я нашла: бэкап — это резервная копия данных, созданная в определенный момент времени и сохраненная на локальном или облачном носителе. Его делают, чтобы быстро восстановить информацию в случае необходимости.
|
||||||
|
[2.3s t=260]
|
||||||
|
|
||||||
|
YOU: покажи что требует внимания
|
||||||
|
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||||
|
[0.5s t=261]
|
||||||
|
|
||||||
|
YOU: отметь это как сделанное
|
||||||
|
MAVEN: команды «отметь» пока нет в списке — она уже предложена, включи через клиент.
|
||||||
|
[0.0s t=262]
|
||||||
|
|
||||||
|
YOU: запиши что я купил новые наушники
|
||||||
|
MAVEN: Я записала твою покупку новых наушников.
|
||||||
|
[1.8s t=263]
|
||||||
|
|
||||||
|
YOU: какие у меня заметки за неделю?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[0.0s t=264]
|
||||||
|
|
||||||
|
YOU: расскажи про Kubernetes
|
||||||
|
MAVEN: вот что я нашла: Kubernetes — это портативная расширяемая платформа с открытым исходным кодом для управления сервисами и контейнеризованными рабочими нагрузками. Управлять им можно через интерфейс в командной строке kubectl. Я нашла эти данные в предоставленных источниках.
|
||||||
|
[1.8s t=265]
|
||||||
|
|
||||||
|
YOU: кто я?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о том, кто ты.
|
||||||
|
[1.4s t=266]
|
||||||
|
|
||||||
|
YOU: пока
|
||||||
|
MAVEN: До свидания, пока!
|
||||||
|
[1.4s t=267]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 6
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Сейчас уже вечер, так что доброе утро я тебе сказать не могу. Я готова помочь тебе с чем-нибудь другим.
|
||||||
|
[1.4s t=268]
|
||||||
|
|
||||||
|
YOU: сколько времени?
|
||||||
|
MAVEN: сейчас 21 час 11 минут
|
||||||
|
[0.0s t=269]
|
||||||
|
|
||||||
|
YOU: напомни в 18:30 позвонить в банк
|
||||||
|
MAVEN: Сейчас 21:11. В какой день?
|
||||||
|
[0.3s t=270]
|
||||||
|
|
||||||
|
YOU: поставь чайник
|
||||||
|
MAVEN: Сейчас 21:11. В какой день?
|
||||||
|
[1.1s t=271]
|
||||||
|
|
||||||
|
YOU: включи музыку
|
||||||
|
MAVEN: Сейчас 21:11. В какой день?
|
||||||
|
[0.9s t=272]
|
||||||
|
|
||||||
|
YOU: что у меня в календаре на пятницу?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о твоих личных событиях в календаре на пятницу. В одном из текстов упоминается пятница 24 июля 2026 года, а в другом — что сегодня пятница 7 августа 2026 года. На какое время поставить напоминание?
|
||||||
|
[2.6s t=273]
|
||||||
|
|
||||||
|
YOU: во сколько у меня встреча?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи. На какое время поставить напоминание?
|
||||||
|
[0.3s t=274]
|
||||||
|
|
||||||
|
YOU: запиши что дедлайн по проекту в понедельник
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Я записала твой дедлайн на понедельник.
|
||||||
|
[1.7s t=275]
|
||||||
|
|
||||||
|
YOU: успею ли я до дедлайна?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о твоем дедлайне, поэтому я не могу ответить, успеешь ты или нет.
|
||||||
|
[2.0s t=276]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: Пожалуйста, я всегда готова помочь тебе.
|
||||||
|
[1.4s t=277]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 7
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Привет! Чем я могу тебе помочь?
|
||||||
|
[1.3s t=278]
|
||||||
|
|
||||||
|
YOU: как ты?
|
||||||
|
MAVEN: Я в порядке, спасибо, что спросил. Готова помогать тебе!
|
||||||
|
[1.4s t=279]
|
||||||
|
|
||||||
|
YOU: расскажи анекдот
|
||||||
|
MAVEN: вот что я нашла: в источниках есть ссылки на сборники и сайты с анекдотами, но самих текстов анекдотов там нет, кроме одного: ударилась лягушка о землю головой и стала девицей прекрасной, а второй раз — месивом кровавым.
|
||||||
|
[3.9s t=280]
|
||||||
|
|
||||||
|
YOU: что ты умеешь?
|
||||||
|
MAVEN: Я Мэйвен, твоя помощница. Я живу на твоём сервере и отвечаю на твои вопросы. Я запоминаю то, что ты мне говоришь, веду заметки, ставлю напоминания, а также читаю твой календарь и задачи. Я отвечаю на вопросы о мире, используя поиск или офлайн-энциклопедию. Если ты разрешишь, я могу управлять домом, локальной сетью, лентами, списком покупок и погодой, а также писать в телеграм. Я говорю по-русски и по-английски.
|
||||||
|
[2.9s t=281]
|
||||||
|
|
||||||
|
YOU: запиши что я начал бегать по утрам
|
||||||
|
MAVEN: Я записала твою новую привычку
|
||||||
|
[1.5s t=282]
|
||||||
|
|
||||||
|
YOU: я бегаю по утрам уже неделю
|
||||||
|
MAVEN: записала: я бегаю по утрам уже неделю
|
||||||
|
[0.6s t=283]
|
||||||
|
|
||||||
|
YOU: как часто я бегаю?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о том, как часто бегаешь ты. Там указано, что для похудения частоту тренировок можно доводить до 5–6 раз в неделю, а новичкам ежедневные тренировки без подготовки могут привести к травмам.
|
||||||
|
[3.3s t=284]
|
||||||
|
|
||||||
|
YOU: сколько стоит биткоин?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о стоимости биткоина.
|
||||||
|
[1.2s t=285]
|
||||||
|
|
||||||
|
YOU: какие новости?
|
||||||
|
MAVEN: вот что нового: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||||
|
[0.8s t=286]
|
||||||
|
|
||||||
|
YOU: хорошего дня
|
||||||
|
MAVEN: Я не могу сказать «хорошего дня», так как сейчас уже вечер. Хорошего отдыха!
|
||||||
|
[1.3s t=287]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 8
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Сейчас вечер, так что доброе утро я не скажу. Чем я могу тебе помочь?
|
||||||
|
[1.2s t=288]
|
||||||
|
|
||||||
|
YOU: что у меня сегодня?
|
||||||
|
MAVEN: на 08.08.2026 ничего нет.
|
||||||
|
[0.0s t=289]
|
||||||
|
|
||||||
|
YOU: напомни через час выпить воды
|
||||||
|
MAVEN: хорошо, напомню сегодня в 22:11.
|
||||||
|
[0.3s t=290]
|
||||||
|
|
||||||
|
YOU: я выпил воды
|
||||||
|
MAVEN: записала: я выпил воды
|
||||||
|
[0.9s t=291]
|
||||||
|
|
||||||
|
YOU: запиши что кот ест только сухой корм
|
||||||
|
MAVEN: Я записала твою заметку про кота.
|
||||||
|
[1.2s t=292]
|
||||||
|
|
||||||
|
YOU: чем питается кот?
|
||||||
|
MAVEN: вот что я нашла: в природе кошки едят мышей, птиц и ящериц. Дома их правильно кормить мясом, мясными субпродуктами, яйцами и нежирной морской рыбой. Из-за особенностей пищеварения им не подходят каши, макароны и мучные изделия.
|
||||||
|
[3.4s t=293]
|
||||||
|
|
||||||
|
YOU: что такое DNS?
|
||||||
|
MAVEN: вот что я нашла: DNS (Domain Name System) — это распределенная база данных, которая сопоставляет домены с IP-адресами. Это не единая база на одном сервере, а иерархическая система серверов, работающих совместно. В ней хранятся записи, которые обеспечивают маршрутизацию трафика и управление электронной почтой.
|
||||||
|
[2.0s t=294]
|
||||||
|
|
||||||
|
YOU: проверь статус uptime kuma
|
||||||
|
MAVEN: uptime kuma — Проверяю статус Uptime Kuma...
|
||||||
|
[1.1s t=295]
|
||||||
|
|
||||||
|
YOU: всё ли в порядке с сервером?
|
||||||
|
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||||
|
[0.5s t=296]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: Пожалуйста, я всегда рада помочь тебе.
|
||||||
|
[2.7s t=297]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 9
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Привет! Чем я могу тебе помочь сегодня?
|
||||||
|
[1.9s t=298]
|
||||||
|
|
||||||
|
YOU: какой сегодня день недели?
|
||||||
|
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||||
|
[0.0s t=299]
|
||||||
|
|
||||||
|
YOU: добавь хлеб и сыр в список покупок
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[0.6s t=300]
|
||||||
|
|
||||||
|
YOU: что в списке покупок?
|
||||||
|
MAVEN: в списке покупок пусто.
|
||||||
|
[0.0s t=301]
|
||||||
|
|
||||||
|
YOU: удали молоко из списка
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[0.4s t=302]
|
||||||
|
|
||||||
|
YOU: напомни завтра утром вынести мусор
|
||||||
|
MAVEN: Сейчас 21:11. Когда?
|
||||||
|
[0.3s t=303]
|
||||||
|
|
||||||
|
YOU: запиши что я поменял масло в машине
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. записала: запиши что я поменял масло в машине
|
||||||
|
[0.7s t=304]
|
||||||
|
|
||||||
|
YOU: когда я менял масло?
|
||||||
|
MAVEN: масло в машине — поменял
|
||||||
|
[0.4s t=305]
|
||||||
|
|
||||||
|
YOU: сколько будет 144 делить на 12?
|
||||||
|
MAVEN: вот что я нашла: в источниках указано решение деления 144 на 12 в столбик, но итоговый результат деления не прописан.
|
||||||
|
[2.3s t=306]
|
||||||
|
|
||||||
|
YOU: пока
|
||||||
|
MAVEN: До свидания! Если понадобится что-то записать или проверить, я всегда на связи.
|
||||||
|
[1.2s t=307]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 10
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Я не могу сказать «доброе утро», так как сейчас уже вечер. Я записала твои предыдущие вопросы.
|
||||||
|
[1.8s t=308]
|
||||||
|
|
||||||
|
YOU: что нового за ночь?
|
||||||
|
MAVEN: вот что я нашла: в ночь на 8 августа вооруженные силы РФ нанесли второй за последнее время массированный удар, при этом киевская ПВО пропустила все российские ракеты.
|
||||||
|
[3.3s t=309]
|
||||||
|
|
||||||
|
YOU: почему интернет медленный?
|
||||||
|
MAVEN: вот что я нашла: интернет может работать медленно из-за плохого сигнала из-за удаленности вышки, исчерпанного лимита трафика по тарифу, нахождения вне зоны покрытия 4G/LTE, устаревшей или неисправной SIM-карты, а также из-за сбоя в настройках роутера. Чтобы исправить ситуацию, можно попробовать перезагрузить роутер или перенастроить устройство.
|
||||||
|
[3.0s t=310]
|
||||||
|
|
||||||
|
YOU: какая скорость у меня сейчас?
|
||||||
|
MAVEN: для какого города?
|
||||||
|
[0.6s t=311]
|
||||||
|
|
||||||
|
YOU: запиши что новый роутер стоит 8000 рублей
|
||||||
|
MAVEN: Я записала стоимость нового роутера.
|
||||||
|
[1.2s t=312]
|
||||||
|
|
||||||
|
YOU: сколько стоил роутер?
|
||||||
|
MAVEN: вот что я нашла: цена зависит от модели. Роутер от Ростелеком стоит 5 900 рублей, на OZON есть варианты от 4 052 до 3 251 рублей, а в Билайне цены начинаются от 2 149 рублей.
|
||||||
|
[2.6s t=313]
|
||||||
|
|
||||||
|
YOU: что такое NAT?
|
||||||
|
MAVEN: вот что я нашла: NAT (Network Address Translation) — это технология в сетях TCP/IP, которая позволяет изменять IP-адрес в заголовке пакета при его прохождении через устройство. Она перенаправляет трафик между локальной и глобальной сетями, позволяет объединять устройства в локальной сети под одним публичным IP-адресом, а также скрывает сеть и экономит IP.
|
||||||
|
[2.1s t=314]
|
||||||
|
|
||||||
|
YOU: напомни в субботу позвонить бабушке
|
||||||
|
MAVEN: Сейчас 21:12. Когда?
|
||||||
|
[0.3s t=315]
|
||||||
|
|
||||||
|
YOU: покажи мои напоминания
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о твоих конкретных напоминаниях, там только описано, как их создавать и где искать в приложениях. На какое время поставить напоминание?
|
||||||
|
[3.0s t=316]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: Сейчас 21:12. Во сколько напомнить?
|
||||||
|
[1.0s t=317]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 11
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Сейчас 21:12. Скажи время — например, «в семь вечера» или «через час».
|
||||||
|
[0.8s t=318]
|
||||||
|
|
||||||
|
YOU: как погода на выходных?
|
||||||
|
MAVEN: для какого города? На какое время поставить напоминание?
|
||||||
|
[0.7s t=319]
|
||||||
|
|
||||||
|
YOU: что у меня на этой неделе?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о том, что у тебя на этой неделе. На какое время поставить напоминание?
|
||||||
|
[1.0s t=320]
|
||||||
|
|
||||||
|
YOU: запиши что я хочу прочитать книгу про Go
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Я записала твою заметку о книге по Go.
|
||||||
|
[1.6s t=321]
|
||||||
|
|
||||||
|
YOU: что я хотел прочитать?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[0.9s t=322]
|
||||||
|
|
||||||
|
YOU: объясни что такое горутина
|
||||||
|
MAVEN: вот что я нашла: я проверила источники и узнала, что горутины — это легковесные потоки выполнения или функции, которые позволяют параллельно выполнять код внутри программы. Они являются легкими нитями, которые создаются и планируются для независимого выполнения планировщиком Go.
|
||||||
|
[2.5s t=323]
|
||||||
|
|
||||||
|
YOU: кто написал Войну и мир?
|
||||||
|
MAVEN: вот что я нашла: роман «Война и мир» написал Лев Николаевич Толстой.
|
||||||
|
[2.2s t=324]
|
||||||
|
|
||||||
|
YOU: включи свет на кухне
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[0.5s t=325]
|
||||||
|
|
||||||
|
YOU: закрой шторы в комнате
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[0.4s t=326]
|
||||||
|
|
||||||
|
YOU: спокойной ночи
|
||||||
|
MAVEN: Спокойной ночи! Я уже подготовила всё к твоему отдыху.
|
||||||
|
[1.1s t=327]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 12
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Сейчас уже вечер, поэтому я не могу сказать тебе «доброе утро».
|
||||||
|
[1.1s t=328]
|
||||||
|
|
||||||
|
YOU: сколько сейчас времени?
|
||||||
|
MAVEN: сейчас 21 час 12 минут
|
||||||
|
[0.0s t=329]
|
||||||
|
|
||||||
|
YOU: я не то имел в виду
|
||||||
|
MAVEN: Я поняла тебя, исправь, пожалуйста, свою мысль. Что именно ты хотел сказать?
|
||||||
|
[1.8s t=330]
|
||||||
|
|
||||||
|
YOU: о чём мы говорили?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[0.6s t=331]
|
||||||
|
|
||||||
|
YOU: напомни
|
||||||
|
MAVEN: Сейчас 21:12. Когда?
|
||||||
|
[0.5s t=332]
|
||||||
|
|
||||||
|
YOU: сделай это
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос. На какое время поставить напоминание?
|
||||||
|
[0.5s t=333]
|
||||||
|
|
||||||
|
YOU: запиши что я перешёл на новый тариф
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Я записала твою новую информацию о тарифе.
|
||||||
|
[1.5s t=334]
|
||||||
|
|
||||||
|
YOU: какой у меня тариф?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о твоем тарифе.
|
||||||
|
[1.2s t=335]
|
||||||
|
|
||||||
|
YOU: сколько я плачу за интернет?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о том, сколько именно ты платишь за интернет. Я проверила документы, но точной суммы там нет.
|
||||||
|
[2.6s t=336]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: Я поняла тебя. Что именно ты хочешь, чтобы я сделала сейчас?
|
||||||
|
[1.5s t=337]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 13
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Привет! Я готова помогать тебе. Чем займемся сегодня?
|
||||||
|
[1.3s t=338]
|
||||||
|
|
||||||
|
YOU: что там с задачами?
|
||||||
|
MAVEN: вот что я нашла: я отметила задачу про бэкапы как сделанную.
|
||||||
|
[1.5s t=339]
|
||||||
|
|
||||||
|
YOU: закрывай
|
||||||
|
MAVEN: какой пункт отметить сделанным?
|
||||||
|
[0.0s t=340]
|
||||||
|
|
||||||
|
YOU: отметь задачу про бэкапы как сделанную
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[0.6s t=341]
|
||||||
|
|
||||||
|
YOU: что осталось нерешённым?
|
||||||
|
MAVEN: вот что я нашла: в твоей жизни всё, что остается нерешенным или незавершенным, будет продолжать существовать и требовать внимания. Также в источниках упоминается пример с скобками, который может остаться нерешенным, и вопрос о том, что остается нерешенным в краснодарских перевозках.
|
||||||
|
[4.2s t=342]
|
||||||
|
|
||||||
|
YOU: запиши что я договорился о встрече в среду
|
||||||
|
MAVEN: Я записала твою договоренность о встрече.
|
||||||
|
[2.3s t=343]
|
||||||
|
|
||||||
|
YOU: когда у меня встреча?
|
||||||
|
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||||
|
[0.0s t=344]
|
||||||
|
|
||||||
|
YOU: какая температура на улице?
|
||||||
|
MAVEN: для какого города?
|
||||||
|
[0.9s t=345]
|
||||||
|
|
||||||
|
YOU: что такое RAID 5?
|
||||||
|
MAVEN: вот что я нашла: RAID 5 — это массив из независимых дисков, где данные и контрольные суммы (четность) распределяются по кругу на все диски. Этот уровень обеспечивает отказоустойчивость и повышенную производительность, при этом для его работы требуется не менее трех дисков.
|
||||||
|
[2.3s t=346]
|
||||||
|
|
||||||
|
YOU: пока
|
||||||
|
MAVEN: До встречи!
|
||||||
|
[1.4s t=347]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 14
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Я не могу сказать «доброе утро», потому что сейчас вечер. Я уже записала твой вопрос и готова помочь с чем-то другим.
|
||||||
|
[1.7s t=348]
|
||||||
|
|
||||||
|
YOU: подведи итоги недели
|
||||||
|
MAVEN: week summary — подведи итоги недели
|
||||||
|
[0.6s t=349]
|
||||||
|
|
||||||
|
YOU: что я делал за последние две недели?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о том, что ты делал за последние две недели.
|
||||||
|
[1.3s t=350]
|
||||||
|
|
||||||
|
YOU: какие заметки я сохранил?
|
||||||
|
MAVEN: вот что я нашла: в источниках нет информации о том, какие именно заметки ты сохранил.
|
||||||
|
[1.3s t=351]
|
||||||
|
|
||||||
|
YOU: о чём я чаще всего спрашиваю?
|
||||||
|
MAVEN: обычно ты пьёшь воду около 00:58, отмечаешь «name» около 23:56 и отмечаешь «week summary» около 23:59 — по записям за последние 7 дней.
|
||||||
|
[0.9s t=352]
|
||||||
|
|
||||||
|
YOU: напомни в понедельник в 10 проверить бэкапы
|
||||||
|
MAVEN: Сейчас 21:12. Это утра или вечера?
|
||||||
|
[0.3s t=353]
|
||||||
|
|
||||||
|
YOU: что у меня в понедельник?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи. На какое время поставить напоминание?
|
||||||
|
[0.3s t=354]
|
||||||
|
|
||||||
|
YOU: ты меня понимаешь?
|
||||||
|
MAVEN: Да, я тебя прекрасно понимаю. Чем я могу тебе помочь? На какое время поставить напоминание?
|
||||||
|
[1.3s t=355]
|
||||||
|
|
||||||
|
YOU: спасибо тебе
|
||||||
|
MAVEN: Сейчас 21:12. Это утра или вечера?
|
||||||
|
[1.0s t=356]
|
||||||
|
|
||||||
|
YOU: спокойной ночи
|
||||||
|
MAVEN: хорошо, напомню послезавтра в 10:00.
|
||||||
|
[1.2s t=357]
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# The same fortnight, after V-655 merged
|
||||||
|
|
||||||
|
Date: 2026-08-08, a few hours after `2026-08-08-two-weeks.md`.
|
||||||
|
Build: `f8fa0d1` on master, the five compose services rebuilt and recreated.
|
||||||
|
Same driver, same 140 turns, same reach. This is the diff that baseline was for.
|
||||||
|
|
||||||
|
Master now carries V-655, V-659, V-660 and V-661. The change under test is V-655. A query source that decides by seed similarity
|
||||||
|
is marked `guesses: true`. It is dropped when the cascade names a different
|
||||||
|
destination.
|
||||||
|
|
||||||
|
## Two things confound the comparison and one of them matters
|
||||||
|
|
||||||
|
**The workstation was up for the re-run.** `llama-server` on 192.168.1.105
|
||||||
|
answered a health probe with 200. So routing completed through `llm.Pair`
|
||||||
|
against gemma-4-12b, which is the arm that names a destination. Its state
|
||||||
|
during the baseline was not recorded. So a difference here may be the merge, or
|
||||||
|
may be the better router, and this run cannot separate them.
|
||||||
|
|
||||||
|
**The store carried over**, as the baseline said it would. Facts written by the
|
||||||
|
first run were present from turn 1 of the second.
|
||||||
|
|
||||||
|
## Numbers
|
||||||
|
|
||||||
|
| | baseline `beb093a` | after `f8fa0d1` |
|
||||||
|
|---|---|---|
|
||||||
|
| turns | 140 | 140 |
|
||||||
|
| p50 | 1.5s | 1.2s |
|
||||||
|
| p95 | 7.1s | 3.0s |
|
||||||
|
| max | 33.7s | 4.2s |
|
||||||
|
| transport errors | 0 | 0 |
|
||||||
|
| turns carrying a failure string | 41 | 38 |
|
||||||
|
|
||||||
|
| string in the reply | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| `на какое время поставить напоминание` | 13 | 13 |
|
||||||
|
| `не нашла у тебя такой записи` | 8 | 9 |
|
||||||
|
| `Такую команду я не знаю` | 8 | 8 |
|
||||||
|
| `для какого города` | 6 | 4 |
|
||||||
|
| `В какой день` | 6 | 6 |
|
||||||
|
| `пока не умею` | 5 | 1 |
|
||||||
|
| `Когда?` | 3 | 3 |
|
||||||
|
|
||||||
|
Read the latency as unattributed. The workstation confound covers all of it.
|
||||||
|
|
||||||
|
## Defect 2 is the one this was for: four of six fixed
|
||||||
|
|
||||||
|
| utterance | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| `что такое TCP?` | `для какого города?` | a real definition |
|
||||||
|
| `сколько будет 17 на 23?` | `для какого города?` | search, which has no answer |
|
||||||
|
| `какой у меня любимый язык?` | kernel headlines | `не нашла у тебя такой записи` |
|
||||||
|
| `что я сохранил про Сочи?` | `Хорошо, сохраню.` | answered as a question |
|
||||||
|
| `какая скорость у меня сейчас?` | `для какого города?` | `для какого города?` |
|
||||||
|
| `хватает ли места под новые бэкапы?` | kernel headlines | kernel headlines |
|
||||||
|
|
||||||
|
`что такое TCP?` is the clean win. `WorldQueryGrammars` names `world` at stage
|
||||||
|
0, weather is dropped, and search answers.
|
||||||
|
|
||||||
|
`сколько будет 17 на 23?` moved source and not outcome. Weather no longer claims it. Search
|
||||||
|
cannot do arithmetic, so the reply says the sources have no product of 17 and
|
||||||
|
23. That is an honest gap where it used to be a wrong
|
||||||
|
question. Arithmetic has no destination in the enum.
|
||||||
|
|
||||||
|
`что я сохранил про Сочи?` was defect 3 and it is gone. The utterance is no
|
||||||
|
longer read as a capture.
|
||||||
|
|
||||||
|
**The two that did not move are both homelab questions.** They are exactly the
|
||||||
|
cluster the destination fixture flagged. `SourceRecall`, `SourceNetwork` and
|
||||||
|
`SourceAttention` overlap on every question about the box. Five of the seven
|
||||||
|
floor cases in that fixture are homelab operations for the same reason. So this
|
||||||
|
is the enum, not the walk.
|
||||||
|
|
||||||
|
## Defect 1 did not move at all
|
||||||
|
|
||||||
|
Twenty-six turns still carry a parked clarify tail, the same count as the
|
||||||
|
baseline. `спасибо тебе` answers `Сейчас 21:12. Это утра или вечера?` and
|
||||||
|
`спокойной ночи` answers `хорошо, напомню послезавтра в 10:00.`
|
||||||
|
|
||||||
|
V-655 was never going to touch this. A parked clarify is dialogue state and not
|
||||||
|
a query source. It remains the single worst thing about talking to her. The week test, the
|
||||||
|
fortnight test and this re-run all report it unchanged.
|
||||||
|
|
||||||
|
## A gap in the harness, fixed and re-run the same day
|
||||||
|
|
||||||
|
`ipc.ChatReply.Source` came back empty on all 140 turns, in both runs. The
|
||||||
|
driver read the redirect parameter `src` and `cmd/mavweb/chat.go` writes `s`.
|
||||||
|
So every finding above is read off the reply text instead of off the badge.
|
||||||
|
|
||||||
|
Fixed in V-662 and the 140 turns were driven a third time. Sixty-eight of them
|
||||||
|
name a source. The rest are not query turns and never reach `queryWalk`.
|
||||||
|
|
||||||
|
| source | turns |
|
||||||
|
|---|---|
|
||||||
|
| search | 27 |
|
||||||
|
| memory | 13 |
|
||||||
|
| personal | 9 |
|
||||||
|
| weather | 5 |
|
||||||
|
| calendar | 3 |
|
||||||
|
| attention | 3 |
|
||||||
|
| list | 2 |
|
||||||
|
| feeds | 2 |
|
||||||
|
| tasks, money, self, habits | 1 each |
|
||||||
|
|
||||||
|
## What the badge shows that the wording did not
|
||||||
|
|
||||||
|
The two unfixed homelab turns are now direct evidence.
|
||||||
|
|
||||||
|
```text
|
||||||
|
какая скорость у меня сейчас? -> weather
|
||||||
|
хватает ли места под новые бэкапы? -> feeds
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are guessing sources claiming a turn about the box, exactly as the
|
||||||
|
destination fixture predicted.
|
||||||
|
|
||||||
|
The badge also names a defect the wording hid. **Agenda questions are being
|
||||||
|
claimed by the personal boundary and by Praxis, not by the calendar.**
|
||||||
|
|
||||||
|
```text
|
||||||
|
во сколько у меня встреча? -> personal не нашла у тебя такой записи
|
||||||
|
когда у меня встреча? -> attention у Praxis нет источников
|
||||||
|
что у меня в понедельник? -> personal не нашла у тебя такой записи
|
||||||
|
```
|
||||||
|
|
||||||
|
Calendar claimed 3 turns of the 6 that asked about the calendar. That is the
|
||||||
|
same 3/6 the destination fixture scores and the same 3/6 every seed of the
|
||||||
|
routing head scores. Three measurements agree. The cause is the one V-660 named. The possessive
|
||||||
|
agenda rules claim these at stage 0 and name no destination, so the walk
|
||||||
|
reaches `personal` and `attention` first.
|
||||||
|
|
||||||
|
This is the third independent confirmation that the possessive agenda rules
|
||||||
|
should name the calendar. That call is still the owner's.
|
||||||
@@ -0,0 +1,636 @@
|
|||||||
|
# Raw transcript, two weeks of usage
|
||||||
|
|
||||||
|
Companion to `2026-08-08-two-weeks.md`. 140 turns through `POST /api/chat`,
|
||||||
|
driven by `scripts/usage-run.py` over `scripts/testdata/usage-turns.txt`.
|
||||||
|
Days are simulated. The wall clock was 20:18-20:27 throughout, which is why
|
||||||
|
every clock answer says evening.
|
||||||
|
|
||||||
|
## Day 1
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Сейчас уже вечер, поэтому я не могу сказать тебе «доброе утро».
|
||||||
|
[1.5s t=78]
|
||||||
|
|
||||||
|
YOU: какой сегодня день?
|
||||||
|
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||||
|
[0.0s t=79]
|
||||||
|
|
||||||
|
YOU: сколько времени?
|
||||||
|
MAVEN: сейчас 20 часов 18 минут
|
||||||
|
[0.0s t=80]
|
||||||
|
|
||||||
|
YOU: запиши что я пью кофе без сахара
|
||||||
|
MAVEN: Я записала твою заметку про кофе。
|
||||||
|
[2.0s t=81]
|
||||||
|
|
||||||
|
YOU: мой любимый язык программирования go
|
||||||
|
MAVEN: Я записала твою заметку про Go.
|
||||||
|
[1.4s t=82]
|
||||||
|
|
||||||
|
YOU: напомни в 11:00 позвонить маме
|
||||||
|
MAVEN: Сейчас 20:18. В какой день?
|
||||||
|
[0.4s t=83]
|
||||||
|
|
||||||
|
YOU: что у меня сегодня?
|
||||||
|
MAVEN: на 08.08.2026 ничего нет, на какое время поставить напоминание?
|
||||||
|
[0.3s t=84]
|
||||||
|
|
||||||
|
YOU: что такое TCP?
|
||||||
|
MAVEN: для какого города? На какое время поставить напоминание?
|
||||||
|
[1.0s t=85]
|
||||||
|
|
||||||
|
YOU: сколько будет 17 на 23?
|
||||||
|
MAVEN: для какого города? На какое время поставить напоминание?
|
||||||
|
[0.9s t=86]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: Сейчас 20:18. В какой день?
|
||||||
|
[0.9s t=87]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 2
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Сейчас 20:18. В какой день?
|
||||||
|
[0.9s t=88]
|
||||||
|
|
||||||
|
YOU: что нового?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи, на какое время поставить напоминание?
|
||||||
|
[1.2s t=89]
|
||||||
|
|
||||||
|
YOU: какая погода?
|
||||||
|
MAVEN: для какого города? На какое время поставить напоминание?
|
||||||
|
[0.6s t=90]
|
||||||
|
|
||||||
|
YOU: запиши что пароль от вайфая лежит в ящике стола
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Хорошо, сохранил.
|
||||||
|
[33.7s t=91]
|
||||||
|
|
||||||
|
YOU: где лежит вайфай пароль?
|
||||||
|
MAVEN: вот что я нашла: пароль от вайфая лежит в ящике стола
|
||||||
|
[7.0s t=92]
|
||||||
|
|
||||||
|
YOU: добавь молоко в список покупок
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[1.4s t=93]
|
||||||
|
|
||||||
|
YOU: что у меня в списке покупок?
|
||||||
|
MAVEN: в списке покупок пусто.
|
||||||
|
[0.0s t=94]
|
||||||
|
|
||||||
|
YOU: кто такой Линус Торвальдс?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[1.6s t=95]
|
||||||
|
|
||||||
|
YOU: какой у меня любимый язык?
|
||||||
|
MAVEN: вот что нового: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||||
|
[1.3s t=96]
|
||||||
|
|
||||||
|
YOU: сколько у меня задач?
|
||||||
|
MAVEN: сначала: проверить интейк форму на боксе (завтра); купить хлеб; купить молоко; полить цветы; починить кран
|
||||||
|
[0.0s t=97]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 3
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: как дела?
|
||||||
|
MAVEN: рада, как дела?
|
||||||
|
[4.1s t=98]
|
||||||
|
|
||||||
|
YOU: напомни завтра в 9 утра купить хлеб
|
||||||
|
MAVEN: хорошо, напомню завтра в 09:00.
|
||||||
|
[0.3s t=99]
|
||||||
|
|
||||||
|
YOU: что у меня завтра?
|
||||||
|
MAVEN: на 09.08.2026 ничего нет.
|
||||||
|
[0.0s t=100]
|
||||||
|
|
||||||
|
YOU: отмени напоминание про хлеб
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[1.6s t=101]
|
||||||
|
|
||||||
|
YOU: какие у меня напоминания?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[0.0s t=102]
|
||||||
|
|
||||||
|
YOU: сохрани мне адрес гостиницы в Сочи
|
||||||
|
MAVEN: Хорошо, сохраню.
|
||||||
|
[5.6s t=103]
|
||||||
|
|
||||||
|
YOU: что я сохранил про Сочи?
|
||||||
|
MAVEN: Хорошо, сохраню.
|
||||||
|
[2.7s t=104]
|
||||||
|
|
||||||
|
YOU: почему сервер тормозит?
|
||||||
|
MAVEN: есть такое: Почему тормозит сервер сайта: причины медленной работы и способы решения — Почему сервер работает медленно: основные причины торможения, диагностика проблем, способы оптимизации. Практические советы по ускорению работы сервера и сайта.
|
||||||
|
[3.1s t=105]
|
||||||
|
|
||||||
|
YOU: хватает ли места под новые бэкапы?
|
||||||
|
MAVEN: нового вот что: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||||
|
[1.5s t=106]
|
||||||
|
|
||||||
|
YOU: выключи свет в спальне
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[9.0s t=107]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 4
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: рада
|
||||||
|
[5.0s t=108]
|
||||||
|
|
||||||
|
YOU: что я пропустил?
|
||||||
|
MAVEN: я не могу ответить на вопрос о том, что ты пропустил. мне нужно больше информации.
|
||||||
|
[5.7s t=109]
|
||||||
|
|
||||||
|
YOU: о чём мы вчера говорили?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.1s t=110]
|
||||||
|
|
||||||
|
YOU: запиши что я записался к врачу на четверг
|
||||||
|
MAVEN: Хорошо, сохранил.
|
||||||
|
[6.0s t=111]
|
||||||
|
|
||||||
|
YOU: когда я иду к врачу?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.1s t=112]
|
||||||
|
|
||||||
|
YOU: что такое ZFS?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[1.0s t=113]
|
||||||
|
|
||||||
|
YOU: столица Франции?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.2s t=114]
|
||||||
|
|
||||||
|
YOU: переведи слово ремонт на английский
|
||||||
|
MAVEN: записала: переведи слово ремонт на английский
|
||||||
|
[1.1s t=115]
|
||||||
|
|
||||||
|
YOU: сколько я потратил в этом месяце?
|
||||||
|
MAVEN: я не отслеживаю траты — не подключено.
|
||||||
|
[1.4s t=116]
|
||||||
|
|
||||||
|
YOU: спокойной ночи
|
||||||
|
MAVEN: спокойной ночи
|
||||||
|
[4.3s t=117]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 5
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Привет!
|
||||||
|
[2.6s t=118]
|
||||||
|
|
||||||
|
YOU: какая погода в Москве?
|
||||||
|
MAVEN: погода не настроена.
|
||||||
|
[1.2s t=119]
|
||||||
|
|
||||||
|
YOU: что там с бэкапами?
|
||||||
|
MAVEN: нашла вот это: Бэкап - что такое резервное копирование и как его сделать — Бэкап (backup) — это резервная копия важных данных, сохраненная в специальный файл. Ею пользуются как подстраховкой: если что-то сломается, с помощью бэкапа можно будет все вернуть. Простейший пример бэкапа — сохранение файла в облаке или о…
|
||||||
|
[4.0s t=120]
|
||||||
|
|
||||||
|
YOU: покажи что требует внимания
|
||||||
|
MAVEN: я заметила, что у тебя есть запрос о том, что требуется внимание. давай разберёмся вместе.
|
||||||
|
[5.2s t=121]
|
||||||
|
|
||||||
|
YOU: отметь это как сделанное
|
||||||
|
MAVEN: команды «отметь» нет в списке. Предложила её добавить — включи через клиент.
|
||||||
|
[0.0s t=122]
|
||||||
|
|
||||||
|
YOU: запиши что я купил новые наушники
|
||||||
|
MAVEN: Заметка сохранена.
|
||||||
|
[3.7s t=123]
|
||||||
|
|
||||||
|
YOU: какие у меня заметки за неделю?
|
||||||
|
MAVEN: week summary — подведи итоги недели
|
||||||
|
[0.0s t=124]
|
||||||
|
|
||||||
|
YOU: расскажи про Kubernetes
|
||||||
|
MAVEN: вот что я нашла: Что такое Kubernetes — Сервисы, поддержка и инструменты Kubernetes широко доступны. Название Kubernetes происходит от греческого, что означает рулевой или штурман. Google открыл исходный код Kubernetes в 2014 году.
|
||||||
|
[1.0s t=125]
|
||||||
|
|
||||||
|
YOU: кто я?
|
||||||
|
MAVEN: name — Ками
|
||||||
|
[1.0s t=126]
|
||||||
|
|
||||||
|
YOU: пока
|
||||||
|
MAVEN: Пока, спасибо за разговор.
|
||||||
|
[4.1s t=127]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 6
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Добро пожаловать! Я Maven — твой домашний ассистент. Как я могу помочь сегодня?
|
||||||
|
[10.1s t=128]
|
||||||
|
|
||||||
|
YOU: сколько времени?
|
||||||
|
MAVEN: сейчас 20 часов 21 минута
|
||||||
|
[0.0s t=129]
|
||||||
|
|
||||||
|
YOU: напомни в 18:30 позвонить в банк
|
||||||
|
MAVEN: Сейчас 20:21. В какой день?
|
||||||
|
[0.3s t=130]
|
||||||
|
|
||||||
|
YOU: поставь чайник
|
||||||
|
MAVEN: Сейчас 20:21. В какой день?
|
||||||
|
[5.3s t=131]
|
||||||
|
|
||||||
|
YOU: включи музыку
|
||||||
|
MAVEN: Сейчас 20:21. В какой день?
|
||||||
|
[1.7s t=132]
|
||||||
|
|
||||||
|
YOU: что у меня в календаре на пятницу?
|
||||||
|
MAVEN: вот что я нашла: ты записался к врачу на четверг, лежит вайфай пароль в ящике стола, сохранил про Сочи, пью кофе без сахара, на какое время поставить напоминание?
|
||||||
|
[5.5s t=133]
|
||||||
|
|
||||||
|
YOU: во сколько у меня встреча?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи, на какое время поставить напоминание?
|
||||||
|
[0.3s t=134]
|
||||||
|
|
||||||
|
YOU: запиши что дедлайн по проекту в понедельник
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Хорошо, записала.
|
||||||
|
[6.3s t=135]
|
||||||
|
|
||||||
|
YOU: успею ли я до дедлайна?
|
||||||
|
MAVEN: вот что я нашла: дедлайн по проекту указан как понедельник.
|
||||||
|
[5.3s t=136]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: спасиба
|
||||||
|
[3.8s t=137]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 7
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Привет!
|
||||||
|
[2.6s t=138]
|
||||||
|
|
||||||
|
YOU: как ты?
|
||||||
|
MAVEN: рада
|
||||||
|
[4.2s t=139]
|
||||||
|
|
||||||
|
YOU: расскажи анекдот
|
||||||
|
MAVEN: Я могу рассказать интересный анекдот для тебя. Вот - в одном из фильмов был такой сценарий: главному герою дали уйти от команды и он остался один, а потом появился его друг, который сказал ему: 'Тебе не нужно было остаться'. Но это просто игра слов.
|
||||||
|
[7.1s t=140]
|
||||||
|
|
||||||
|
YOU: что ты умеешь?
|
||||||
|
MAVEN: Я могу помочь с задачами, напоминать о важных датах и событиях. Также я помогу записывать заметки или отвечать по ним.
|
||||||
|
[4.1s t=141]
|
||||||
|
|
||||||
|
YOU: запиши что я начал бегать по утрам
|
||||||
|
MAVEN: Заметка сохранена.
|
||||||
|
[9.2s t=142]
|
||||||
|
|
||||||
|
YOU: я бегаю по утрам уже неделю
|
||||||
|
MAVEN: записала: я бегаю по утрам уже неделю
|
||||||
|
[1.9s t=143]
|
||||||
|
|
||||||
|
YOU: как часто я бегаю?
|
||||||
|
MAVEN: вот что я нашла: ты бегал по утрам
|
||||||
|
[5.3s t=144]
|
||||||
|
|
||||||
|
YOU: сколько стоит биткоин?
|
||||||
|
MAVEN: есть такое: Цена Bitcoin сегодня | Данные о цене, рынке и графики BTC в ... — В настоящее время 1 Bitcoin стоит 65 тыс $, что на 0,09% выше за последние 24 ч. Отслеживайте цену BTC, рыночную капитализацию, исторический график и рыночную аналитику в реальном времени.
|
||||||
|
[2.1s t=145]
|
||||||
|
|
||||||
|
YOU: какие новости?
|
||||||
|
MAVEN: вот что нового: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||||
|
[0.8s t=146]
|
||||||
|
|
||||||
|
YOU: хорошего дня
|
||||||
|
MAVEN: Хорошого дня!
|
||||||
|
[4.1s t=147]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 8
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Добро пожаловать! Как могу помочь сегодня?
|
||||||
|
[2.9s t=148]
|
||||||
|
|
||||||
|
YOU: что у меня сегодня?
|
||||||
|
MAVEN: на 08.08.2026 ничего нет.
|
||||||
|
[0.0s t=149]
|
||||||
|
|
||||||
|
YOU: напомни через час выпить воды
|
||||||
|
MAVEN: хорошо, напомню сегодня в 21:22.
|
||||||
|
[0.3s t=150]
|
||||||
|
|
||||||
|
YOU: я выпил воды
|
||||||
|
MAVEN: записала: я выпил воды
|
||||||
|
[1.2s t=151]
|
||||||
|
|
||||||
|
YOU: запиши что кот ест только сухой корм
|
||||||
|
MAVEN: Хорошо, записала.
|
||||||
|
[5.6s t=152]
|
||||||
|
|
||||||
|
YOU: чем питается кот?
|
||||||
|
MAVEN: вот что я нашла: кот питается только сухим кормом.
|
||||||
|
[4.5s t=153]
|
||||||
|
|
||||||
|
YOU: что такое DNS?
|
||||||
|
MAVEN: нашла вот это: Dns — Википедия — В случае рекурсивного запроса DNS-сервер опрашивает серверы (в порядке убывания уровня зон в имени), пока не найдёт ответ или не обнаружит, что домена не существует (на практике поиск ...
|
||||||
|
[2.0s t=154]
|
||||||
|
|
||||||
|
YOU: проверь статус uptime kuma
|
||||||
|
MAVEN: записала: проверь статус uptime kuma
|
||||||
|
[1.6s t=155]
|
||||||
|
|
||||||
|
YOU: всё ли в порядке с сервером?
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос.
|
||||||
|
[1.3s t=156]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: спасибо за пожелание
|
||||||
|
[4.0s t=157]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 9
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: рада,
|
||||||
|
[2.1s t=158]
|
||||||
|
|
||||||
|
YOU: какой сегодня день недели?
|
||||||
|
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||||
|
[0.0s t=159]
|
||||||
|
|
||||||
|
YOU: добавь хлеб и сыр в список покупок
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[1.3s t=160]
|
||||||
|
|
||||||
|
YOU: что в списке покупок?
|
||||||
|
MAVEN: в списке покупок пусто.
|
||||||
|
[0.0s t=161]
|
||||||
|
|
||||||
|
YOU: удали молоко из списка
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[1.2s t=162]
|
||||||
|
|
||||||
|
YOU: напомни завтра утром вынести мусор
|
||||||
|
MAVEN: Сейчас 20:22. Когда?
|
||||||
|
[0.3s t=163]
|
||||||
|
|
||||||
|
YOU: запиши что я поменял масло в машине
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Такую команду я не знаю.
|
||||||
|
[1.3s t=164]
|
||||||
|
|
||||||
|
YOU: когда я менял масло?
|
||||||
|
MAVEN: вот что я нашла: по данным из last update 2019 год, заправки Сочи не могли предоставить масло для замены, поэтому это было невозможно.
|
||||||
|
[7.1s t=165]
|
||||||
|
|
||||||
|
YOU: сколько будет 144 делить на 12?
|
||||||
|
MAVEN: есть такое: Разделить в столбик 144 на 12 - Calc-Best.ru — 144 : 12 = 12 Где 144 - делимое, 12 - делитель, 12 - частное ... Разделить 770 на 7 столбиком Разделить 441 на 9 столбиком Разделить 891 на 9 столбиком Разделить 60 на 5 столбиком Разделить 60 на 6 столбиком
|
||||||
|
[9.6s t=166]
|
||||||
|
|
||||||
|
YOU: пока
|
||||||
|
MAVEN: я записала
|
||||||
|
[4.8s t=167]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 10
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: рада
|
||||||
|
[4.1s t=168]
|
||||||
|
|
||||||
|
YOU: что нового за ночь?
|
||||||
|
MAVEN: нашла вот это: Главные новости сегодня в России, а также самые последние новости со ... — Полное бессилие: киевская ПВО вновь пропустила все российские ракеты. Вооруженные силы РФ в ночь на 8 августа нанесли второй за последнее время массированный ...
|
||||||
|
[2.1s t=169]
|
||||||
|
|
||||||
|
YOU: почему интернет медленный?
|
||||||
|
MAVEN: вот что я нашла: Почему интернет стал медленнее: причины и способы решения - АО «Газпром ... — На скорость интернета сильно влияют торрент-клиенты, VPN, потоковое видео, фоновая загрузка системных обновлений. Посмотреть, куда уходит трафик, можно в настройках ПК или смартфона.
|
||||||
|
[2.4s t=170]
|
||||||
|
|
||||||
|
YOU: какая скорость у меня сейчас?
|
||||||
|
MAVEN: для какого города?
|
||||||
|
[1.2s t=171]
|
||||||
|
|
||||||
|
YOU: запиши что новый роутер стоит 8000 рублей
|
||||||
|
MAVEN: Я записала стоимость нового роутера.
|
||||||
|
[2.5s t=172]
|
||||||
|
|
||||||
|
YOU: сколько стоил роутер?
|
||||||
|
MAVEN: вот что я нашла: новый роутер стоит 8000 рублей.
|
||||||
|
[2.0s t=173]
|
||||||
|
|
||||||
|
YOU: что такое NAT?
|
||||||
|
MAVEN: NAT (Network Address Translation) — это технология, которая позволяет устройствам в локальной сети выходить в интернет, используя один общий внешний IP-адрес. Я отвечаю на этот вопрос, используя поиск в сети.
|
||||||
|
[1.8s t=174]
|
||||||
|
|
||||||
|
YOU: напомни в субботу позвонить бабушке
|
||||||
|
MAVEN: Сейчас 20:23. Когда?
|
||||||
|
[0.3s t=175]
|
||||||
|
|
||||||
|
YOU: покажи мои напоминания
|
||||||
|
MAVEN: вот что я нашла: в твоих источниках нет напоминаний, только запись о том, что ты купил новые наушники, на какое время поставить напоминание?
|
||||||
|
[1.7s t=176]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: Сейчас 20:23. Во сколько напомнить?
|
||||||
|
[1.0s t=177]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 11
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Сейчас 20:23. Скажи время — например, «в семь вечера» или «через час».
|
||||||
|
[0.9s t=178]
|
||||||
|
|
||||||
|
YOU: как погода на выходных?
|
||||||
|
MAVEN: для какого города? На какое время поставить напоминание?
|
||||||
|
[0.6s t=179]
|
||||||
|
|
||||||
|
YOU: что у меня на этой неделе?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи, на какое время поставить напоминание?
|
||||||
|
[0.3s t=180]
|
||||||
|
|
||||||
|
YOU: запиши что я хочу прочитать книгу про Go
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Я записала твою заметку о книге по Go.
|
||||||
|
[1.4s t=181]
|
||||||
|
|
||||||
|
YOU: что я хотел прочитать?
|
||||||
|
MAVEN: вот что я нашла: ты хотел прочитать книгу про Go.
|
||||||
|
[1.1s t=182]
|
||||||
|
|
||||||
|
YOU: объясни что такое горутина
|
||||||
|
MAVEN: вот что я нашла: я узнала, что горутины — это легковесные потоки выполнения или функции в Go, которые позволяют параллельно выполнять код. Они управляются планировщиком Go и работают как независимые нити выполнения.
|
||||||
|
[1.9s t=183]
|
||||||
|
|
||||||
|
YOU: кто написал Войну и мир?
|
||||||
|
MAVEN: вот что я нашла: роман «Война и мир» написал Лев Николаевич Толстой.
|
||||||
|
[2.4s t=184]
|
||||||
|
|
||||||
|
YOU: включи свет на кухне
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[0.5s t=185]
|
||||||
|
|
||||||
|
YOU: закрой шторы в комнате
|
||||||
|
MAVEN: Такую команду я не знаю.
|
||||||
|
[0.3s t=186]
|
||||||
|
|
||||||
|
YOU: спокойной ночи
|
||||||
|
MAVEN: Спокойной ночи. Отдыхай, я на связи, если что-то понадобится.
|
||||||
|
[1.2s t=187]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 12
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: Я не могу сказать тебе «доброе утро», потому что сейчас уже вечер.
|
||||||
|
[1.0s t=188]
|
||||||
|
|
||||||
|
YOU: сколько сейчас времени?
|
||||||
|
MAVEN: сейчас 20 часов 23 минуты
|
||||||
|
[0.0s t=189]
|
||||||
|
|
||||||
|
YOU: я не то имел в виду
|
||||||
|
MAVEN: Я поняла, исправлюсь. Расскажи, что именно ты хотел сказать?
|
||||||
|
[1.4s t=190]
|
||||||
|
|
||||||
|
YOU: о чём мы говорили?
|
||||||
|
MAVEN: Я помню всё, что ты мне говоришь, и сейчас отвечаю на твои вопросы на основе этих данных.
|
||||||
|
[1.4s t=191]
|
||||||
|
|
||||||
|
YOU: напомни
|
||||||
|
MAVEN: Сейчас 20:23. Когда?
|
||||||
|
[0.7s t=192]
|
||||||
|
|
||||||
|
YOU: сделай это
|
||||||
|
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||||
|
[0.5s t=193]
|
||||||
|
|
||||||
|
YOU: запиши что я перешёл на новый тариф
|
||||||
|
MAVEN: Прошлую просьбу отпускаю. Я записала твою новую информацию о тарифе.
|
||||||
|
[1.5s t=194]
|
||||||
|
|
||||||
|
YOU: какой у меня тариф?
|
||||||
|
MAVEN: вот что я нашла: ты перешел на новый тариф
|
||||||
|
[24.4s t=195]
|
||||||
|
|
||||||
|
YOU: сколько я плачу за интернет?
|
||||||
|
MAVEN: есть такое: Как узнать, сколько и когда платить за домашний интернет и ТВ — 3 февр. 2026 г. ... Как узнать, сколько и когда платить за домашний интернет и ТВ · Для тарифов только с домашним интернетом · Была ли статья полезной?
|
||||||
|
[2.1s t=196]
|
||||||
|
|
||||||
|
YOU: спасибо
|
||||||
|
MAVEN: Спасибо, что обратилась за помощью.
|
||||||
|
[4.8s t=197]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 13
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: привет
|
||||||
|
MAVEN: Привет!
|
||||||
|
[2.5s t=198]
|
||||||
|
|
||||||
|
YOU: что там с задачами?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[1.2s t=199]
|
||||||
|
|
||||||
|
YOU: закрывай
|
||||||
|
MAVEN: какой пункт отметить сделанным?
|
||||||
|
[0.0s t=200]
|
||||||
|
|
||||||
|
YOU: отметь задачу про бэкапы как сделанную
|
||||||
|
MAVEN: Заметка сохранена.
|
||||||
|
[5.8s t=201]
|
||||||
|
|
||||||
|
YOU: что осталось нерешённым?
|
||||||
|
MAVEN: вот что я нашла: Российское регулирование криптовалют: что осталось нерешённым к 1 июля — 2 июл. 2026 г. ... Российское регулирование криптовалют: что осталось нерешённым к 1 июля. Пакет законопроектов о регулировании цифровых активов в России не ...
|
||||||
|
[3.7s t=202]
|
||||||
|
|
||||||
|
YOU: запиши что я договорился о встрече в среду
|
||||||
|
MAVEN: Заметка сохранена.
|
||||||
|
[2.7s t=203]
|
||||||
|
|
||||||
|
YOU: когда у меня встреча?
|
||||||
|
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||||
|
[0.1s t=204]
|
||||||
|
|
||||||
|
YOU: какая температура на улице?
|
||||||
|
MAVEN: для какого города?
|
||||||
|
[1.7s t=205]
|
||||||
|
|
||||||
|
YOU: что такое RAID 5?
|
||||||
|
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||||
|
[1.2s t=206]
|
||||||
|
|
||||||
|
YOU: пока
|
||||||
|
MAVEN: Я записала, что мы продолжаем разговор.
|
||||||
|
[4.1s t=207]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Day 14
|
||||||
|
|
||||||
|
```
|
||||||
|
YOU: доброе утро
|
||||||
|
MAVEN: рада, доброго утра!
|
||||||
|
[4.5s t=208]
|
||||||
|
|
||||||
|
YOU: подведи итоги недели
|
||||||
|
MAVEN: записала: подведи итоги недели
|
||||||
|
[1.7s t=209]
|
||||||
|
|
||||||
|
YOU: что я делал за последние две недели?
|
||||||
|
MAVEN: вот что я нашла: ты не делал ничего за последние две недели.
|
||||||
|
[6.6s t=210]
|
||||||
|
|
||||||
|
YOU: какие заметки я сохранил?
|
||||||
|
MAVEN: Ты сочти, что сохранила заметку с текстом 'Проверь календарь' и датой 8 августа 2026 г.,
|
||||||
|
[5.4s t=211]
|
||||||
|
|
||||||
|
YOU: о чём я чаще всего спрашиваю?
|
||||||
|
MAVEN: обычно ты пьёшь воду около 01:12, отмечаешь «name» около 23:56 и отмечаешь «week summary» около 23:59 — по записям за последние 7 дней.
|
||||||
|
[4.4s t=212]
|
||||||
|
|
||||||
|
YOU: напомни в понедельник в 10 проверить бэкапы
|
||||||
|
MAVEN: Сейчас 20:25. Это утра или вечера?
|
||||||
|
[0.7s t=213]
|
||||||
|
|
||||||
|
YOU: что у меня в понедельник?
|
||||||
|
MAVEN: вот что я нашла: у тебя в понедельник дедлайн по проекту, на какое время поставить напоминание?
|
||||||
|
[4.6s t=214]
|
||||||
|
|
||||||
|
YOU: ты меня понимаешь?
|
||||||
|
MAVEN: Да, я тебя прекрасно понимаю. Я готова помогать тебе с заметками и задачами, на какое время поставить напоминание?
|
||||||
|
[2.4s t=215]
|
||||||
|
|
||||||
|
YOU: спасибо тебе
|
||||||
|
MAVEN: Сейчас 20:25. Это утра или вечера?
|
||||||
|
[1.0s t=216]
|
||||||
|
|
||||||
|
YOU: спокойной ночи
|
||||||
|
MAVEN: хорошо, напомню послезавтра в 10:00.
|
||||||
|
[0.9s t=217]
|
||||||
|
|
||||||
|
```
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Two weeks of talking to Maven, as a baseline to re-run
|
||||||
|
|
||||||
|
Date: 2026-08-08.
|
||||||
|
Build: `beb093a` on master, the five compose services as deployed, 41 hours up.
|
||||||
|
Reach: `POST /api/chat` on mavweb, 140 turns over fourteen simulated days.
|
||||||
|
Turn source is `tap:text`, so this exercises the path the mic and telegram take.
|
||||||
|
|
||||||
|
This exists to be compared against. `scripts/usage-run.py` and
|
||||||
|
`scripts/testdata/usage-turns.txt` are in the repo, so a re-run after a routing
|
||||||
|
change is a diff rather than a new opinion. The 2026-08-07 week of usage was
|
||||||
|
typed by hand and cannot be replayed.
|
||||||
|
|
||||||
|
**It measures master, not the branch.** V-655, V-659 and V-660 are unmerged.
|
||||||
|
Every query source that guesses is still in the chain. That is the change this
|
||||||
|
baseline is for.
|
||||||
|
|
||||||
|
## What re-runs and what does not
|
||||||
|
|
||||||
|
The turns file, the driver and the routing behaviour replay. Three things do
|
||||||
|
not. The wall clock was 20:18 to 20:27 throughout, so every clock and agenda
|
||||||
|
answer reads evening. Live search and the feed return different text each day.
|
||||||
|
And the store carries over between runs. A fact written on day 2 is already
|
||||||
|
present when a re-run reaches day 1.
|
||||||
|
|
||||||
|
## Numbers
|
||||||
|
|
||||||
|
| | week (2026-08-07) | fortnight (2026-08-08) |
|
||||||
|
|---|---|---|
|
||||||
|
| turns | 74 | 140 |
|
||||||
|
| p50 | 1.5s | 1.6s |
|
||||||
|
| p95 | 8.0s | 7.1s |
|
||||||
|
| max | 12.3s | 33.7s |
|
||||||
|
| transport errors | 0 | 0 |
|
||||||
|
|
||||||
|
| string in the reply | turns |
|
||||||
|
|---|---|
|
||||||
|
| `на какое время поставить напоминание` | 13 |
|
||||||
|
| `не нашла у тебя такой записи` | 8 |
|
||||||
|
| `Такую команду я не знаю` | 8 |
|
||||||
|
| `для какого города` | 6 |
|
||||||
|
| `В какой день` | 6 |
|
||||||
|
| `пока не умею` | 5 |
|
||||||
|
| `Когда?` | 3 |
|
||||||
|
|
||||||
|
**Zero transport errors is not zero wrong answers.** It counts turns that
|
||||||
|
failed to return a reply, and none did. Every quality number is below.
|
||||||
|
|
||||||
|
Those seven strings appear 49 times across 41 of 140 turns. Some turns carry
|
||||||
|
two, because a parked clarify appends to whatever else was said.
|
||||||
|
|
||||||
|
The 33.7s outlier is one note write on day 2. p95 improved against the week
|
||||||
|
despite it.
|
||||||
|
|
||||||
|
## The three defects worth diffing against
|
||||||
|
|
||||||
|
### 1. A parked reminder clarify still contaminates later turns
|
||||||
|
|
||||||
|
The week test called this the single worst thing to talk to and it is unchanged.
|
||||||
|
Nineteen turns carry a clarify tail. The worst run is day 1, turns 7 to 13,
|
||||||
|
which spans a day boundary:
|
||||||
|
|
||||||
|
```
|
||||||
|
что такое TCP? -> для какого города? На какое время поставить напоминание?
|
||||||
|
сколько будет 17 на 23? -> для какого города? На какое время поставить напоминание?
|
||||||
|
спасибо -> Сейчас 20:18. В какой день?
|
||||||
|
привет -> Сейчас 20:18. В какой день?
|
||||||
|
```
|
||||||
|
|
||||||
|
Note that `привет` and `спасибо` do not clear it, and neither does a new day.
|
||||||
|
|
||||||
|
### 2. Query sources that guess still claim turns they cannot answer
|
||||||
|
|
||||||
|
Weather took `сколько будет 17 на 23?`, `что такое TCP?` and `какая скорость у
|
||||||
|
меня сейчас?`, answering `для какого города?` to all three. The feed took
|
||||||
|
`какой у меня любимый язык?` and `хватает ли места под новые бэкапы?` and
|
||||||
|
answered with kernel headlines.
|
||||||
|
|
||||||
|
This is the exact class V-655 removes by marking a source `guesses: true` and
|
||||||
|
taking it out of `queryWalk`. Six turns here, so the re-run has a number to move.
|
||||||
|
|
||||||
|
### 3. A question can still be read as a capture
|
||||||
|
|
||||||
|
`что я сохранил про Сочи?` answered `Хорошо, сохраню.` The utterance is
|
||||||
|
interrogative and was routed to a write. `IsQuestionShaped` catches this
|
||||||
|
downstream on some paths and did not catch it here.
|
||||||
|
|
||||||
|
## What did work
|
||||||
|
|
||||||
|
Reminders with a spoken time land correctly, which is V-572 holding:
|
||||||
|
`напомни завтра в 9 утра купить хлеб` returned `хорошо, напомню завтра в 09:00.`
|
||||||
|
|
||||||
|
Facts round-trip. `запиши что новый роутер стоит 8000 рублей` then `сколько
|
||||||
|
стоил роутер?` returned the stored value. So did the wifi password and the
|
||||||
|
doctor's appointment.
|
||||||
|
|
||||||
|
World questions answer when no local source claims them first. `что такое NAT?`
|
||||||
|
returned a real definition.
|
||||||
|
|
||||||
|
Stage 0 answers land at 0.0 to 0.4s, unchanged.
|
||||||
|
|
||||||
|
## What this does not cover
|
||||||
|
|
||||||
|
The voice loop, because `mavwaked` and `mavenclient` are not deployed. Reminder
|
||||||
|
delivery, because nothing fired inside the run window. Telegram intake. And the
|
||||||
|
three-head routing model, which does not run in Go at all.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# CrisperWhisper 2.0 in Russian, measured
|
||||||
|
|
||||||
|
Date: 2026-08-09. Vikunja V-665.
|
||||||
|
Corpus: `bond005/sberdevices_golos_10h_crowd`, test split, first 200 clips.
|
||||||
|
Harness: `~/Programs/cw2-eval` on workpc, not in this repo.
|
||||||
|
Runner: `./.venv/bin/python run_asr.py <arm>...` then `score.py`.
|
||||||
|
|
||||||
|
The model card benchmarks disfluency F1 in German and English. It never names
|
||||||
|
Russian and publishes no per-language WER. So the measurement came before the
|
||||||
|
wiring.
|
||||||
|
|
||||||
|
## The corpus
|
||||||
|
|
||||||
|
200 clips, 13.7 minutes, 1001 reference words. Median clip 3.91s, range 1.04s
|
||||||
|
to 13.5s. Golos crowd is short crowd-sourced Russian spoken close to the
|
||||||
|
microphone, which is the nearest public thing to someone talking to Maven. The
|
||||||
|
alternatives are read speech, which flatters every model equally.
|
||||||
|
|
||||||
|
Two rows carry a null transcription and are skipped.
|
||||||
|
|
||||||
|
Scoring normalizes both sides: lowercase, `ё` to `е`, punctuation stripped, and
|
||||||
|
digits expanded to Russian words through num2words. Without that last step a
|
||||||
|
model is penalized for writing `60000` where the reference says
|
||||||
|
`шестьдесят тысяч`. Thousands separators are joined before expansion, or
|
||||||
|
`60 000` expands to `шестьдесят ноль`.
|
||||||
|
|
||||||
|
## Headline
|
||||||
|
|
||||||
|
| arm | WER | CER | exact | empty | RTF |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| cw2-turbo-intended | **10.4%** | 3.4% | 65.5% | 0 | 0.065 |
|
||||||
|
| cw2-turbo-verbatim | 10.8% | **3.1%** | **66.5%** | 0 | 0.065 |
|
||||||
|
| whisper-turbo | 11.8% | 4.1% | 64.0% | 0 | 0.031 |
|
||||||
|
| cw2-large-intended | 12.3% | 3.8% | 63.5% | 0 | 0.107 |
|
||||||
|
| whisper-small | 27.5% | 9.8% | 35.0% | 0 | 0.026 |
|
||||||
|
|
||||||
|
`whisper-small` is the floor, because `ggml-small.bin` is what mavsttd loads on
|
||||||
|
homesrv today. CW2 turbo beats it by 17 points of WER and takes exact matches
|
||||||
|
from 35.0% to 65.5%.
|
||||||
|
|
||||||
|
Two results are worth naming beyond the winner. CW2 turbo beats its own base
|
||||||
|
model, whisper-large-v3-turbo, by 1.4 points. And it beats CW2 large by 1.9
|
||||||
|
points, which inverts what the card implies by calling turbo a degraded draft.
|
||||||
|
No arm returned an empty transcript.
|
||||||
|
|
||||||
|
## Intended and verbatim are closer than the mode names suggest
|
||||||
|
|
||||||
|
The two modes disagree on 70 of the 200 clips before normalization and on 29
|
||||||
|
after it. So the raw difference is mostly casing and punctuation, which
|
||||||
|
normalization removes and which Maven does not read either.
|
||||||
|
|
||||||
|
Verbatim scores worse on WER and better on CER and exact matches. The reason is
|
||||||
|
script, not disfluency:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ref: футбольный матч челси брайтон
|
||||||
|
int: Футбольный матч Chelsea-Брайтон.
|
||||||
|
ver: Футбольный матч Челси Брайтон.
|
||||||
|
```
|
||||||
|
|
||||||
|
Intended writes foreign entity names in Latin script and verbatim
|
||||||
|
transliterates them. Golos references are Cyrillic throughout, so verbatim
|
||||||
|
collects the exact matches. That is a property of this corpus rather than a
|
||||||
|
quality difference.
|
||||||
|
|
||||||
|
**This corpus cannot settle the mode choice.** Golos crowd is clean short
|
||||||
|
commands with almost no disfluency. The two modes have nothing to disagree
|
||||||
|
about here. They separate on spontaneous speech with fillers, restarts and
|
||||||
|
repairs, which is what the owner speaks. Intended stays the choice for the
|
||||||
|
reason it was always the choice. Maven wants what was meant, not every stumble
|
||||||
|
on the way there.
|
||||||
|
|
||||||
|
The Latin-script habit is the one finding here that touches routing. The
|
||||||
|
routing heads were trained on Cyrillic utterances, so an entity name arriving
|
||||||
|
in Latin script is out of distribution for them. Nothing measures that yet.
|
||||||
|
|
||||||
|
## The runtime is workpc, because whisper.cpp cannot load CW2
|
||||||
|
|
||||||
|
`num_languages()` in `deps/whisper.cpp/src/whisper.cpp` derives the language
|
||||||
|
count from the vocabulary size:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
return n_vocab - 51765 - (is_multilingual() ? 1 : 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
CW2 carries 31 extra tokens, so `n_vocab` is 51897 and this yields 131
|
||||||
|
languages. The derived `dt` offset becomes 33 and shifts seven special token
|
||||||
|
ids, including `token_beg` and `token_transcribe`. The architecture is
|
||||||
|
otherwise byte-identical to whisper-large-v3-turbo, and the new tokens sit
|
||||||
|
above every whisper special id.
|
||||||
|
|
||||||
|
So loading CW2 in whisper.cpp is a patch to a vendored dependency, not a port.
|
||||||
|
It was not taken, because STT is moving to workpc anyway under V-486. CW2 turbo
|
||||||
|
becomes the preferred remote and `ggml-small.bin` on homesrv stays the floor,
|
||||||
|
which is the shape `modelSeam` already uses for routing and replies. The 27.5%
|
||||||
|
floor is what a turn falls back to when the workstation is down, and this table
|
||||||
|
is what that costs.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Standard CW2 weights carry `nyra-health-non-commercial-research`. The Pro
|
||||||
|
variants are commercial-license only. Maven is personal and self-hosted, so the
|
||||||
|
standard weights are usable and the Pro ones are not free to take.
|
||||||
|
|
||||||
|
## What is not measured
|
||||||
|
|
||||||
|
Disfluent spontaneous speech, which is the whole reason to prefer Intended.
|
||||||
|
Long-form audio beyond 13.5s. Far-field or noisy microphones. English, which
|
||||||
|
Maven also speaks. The ONNX turbo export, which was never run, since the
|
||||||
|
transformers path already meets the latency budget at RTF 0.065.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# gemma-4-E4B on the phrasing and talk fixtures
|
||||||
|
|
||||||
|
Date: 2026-08-09. Box: workpc up, E4B loaded on 8080.
|
||||||
|
`MAVEN_LLM_URL=http://192.168.1.105:8080 make eval-phrasing`.
|
||||||
|
|
||||||
|
This was the one unmeasured risk of the 2026-08-09 model swap. Routing was
|
||||||
|
measured the same day and E4B lost four destination cases to the 12B. Phrasing
|
||||||
|
was not measured at all, and phrasing is the half the owner hears.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
| fixture | E4B | resident Qwen3-1.7B, 2026-08-05 |
|
||||||
|
|---|---|---|
|
||||||
|
| nudges | 15/15 (100%) | 15/15 (100%) |
|
||||||
|
| talk, passes every check | **29/36 (80.6%)** | 25/36 (69.4%) |
|
||||||
|
| lang | 36/36 | — |
|
||||||
|
| feminine | 36/36 | 36/36 |
|
||||||
|
| address | **36/36** | 33/36 |
|
||||||
|
| ontopic | 29/36 | 28/36 |
|
||||||
|
| p50 latency | **516ms** | 2.97s |
|
||||||
|
| p95 latency | 921ms | — |
|
||||||
|
| failed generations | 0 | 0 |
|
||||||
|
|
||||||
|
E4B beats the homesrv floor by four cases and answers about six times faster.
|
||||||
|
Persona is clean: `lang`, `feminine` and `address` are perfect, and `address`
|
||||||
|
is where the resident model still loses three. The 2026-08-05 measurement of the
|
||||||
|
resident model is the comparison, since both ran the same 36-case fixture.
|
||||||
|
|
||||||
|
Every failure is `ontopic`. Nothing failed on persona, nothing failed to parse.
|
||||||
|
|
||||||
|
## The score is at the ceiling, not below it
|
||||||
|
|
||||||
|
The 2026-08-05 temperature sweep found two cases that fail at every temperature
|
||||||
|
in every run: `reply-note-router` and `reply-fact-weight`. It named a defect in
|
||||||
|
the reply phrasing path rather than sampling noise. It put the fixture's ceiling
|
||||||
|
at 30/36 before persona is scored. Both cases are in E4B's failure list.
|
||||||
|
|
||||||
|
So 29/36 is one case off a ceiling nothing about the model can move. The swap is
|
||||||
|
safe on phrasing. Read this next to the routing result, not instead of it. There
|
||||||
|
E4B costs four destination cases and buys 50ms. Here it costs nothing.
|
||||||
|
|
||||||
|
## Two findings no check caught
|
||||||
|
|
||||||
|
**She says she wrote something down when she did not.** Asked what to do this
|
||||||
|
evening, E4B writes "Я записала несколько идей!". Asked for a joke, it writes
|
||||||
|
"Я записала одну забавную ситуацию!". Nothing was stored. No check scores it,
|
||||||
|
because `ontopic` reads the subject and `cringe` reads pet names. A claim to
|
||||||
|
have saved something is a claim about state, and it is wrong.
|
||||||
|
|
||||||
|
**Two `ontopic` failures look like check defects.** `know-dont-know` wants
|
||||||
|
"не зна" or "не мог". It got "Я не умею знать личную информацию о твоих
|
||||||
|
соседях", which declines correctly in words the check does not list.
|
||||||
|
`know-hiccups` is the same shape. Neither is a model failure and both count
|
||||||
|
against the score.
|
||||||
|
|
||||||
|
## Not measured here
|
||||||
|
|
||||||
|
A 12B control on the same fixture, which would need the card reloaded and is the
|
||||||
|
owner's call. The talk fixture through the daemon rather than through the
|
||||||
|
phraser directly. The CPT'd Qwen3-1.7B, which does not exist yet and is the
|
||||||
|
reason `address` is a check at all.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# gemma-4-E4B against gemma-4-12B on the routing fixture
|
||||||
|
|
||||||
|
*Measured 2026-08-09 on workpc. The owner asked for the swap. This is what it costs.*
|
||||||
|
|
||||||
|
Both arms ran the same 96-case fixture through `TestLLMRouterBaseline`, minutes
|
||||||
|
apart, against the same llama-server build and the same mavgpud. The 12B arm is a
|
||||||
|
control run and not the 2026-08-02 number. That one predates five fixture cases,
|
||||||
|
the destination labels and a llama.cpp upgrade.
|
||||||
|
|
||||||
|
| | full | intent-only | destination | p50 | p95 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| gemma-4-12B-it-qat-UD-Q4_K_XL, MTP draft | 81/96 (84.4%) | 91.7% | 23/33 (69.7%) | 344ms | 471ms |
|
||||||
|
| gemma-4-E4B-it-qat-UD-Q4_K_XL | 80/96 (83.3%) | 89.6% | 19/33 (57.6%) | 294ms | 562ms |
|
||||||
|
|
||||||
|
E4B costs one case of full accuracy, two of intent and **four of destination**,
|
||||||
|
and buys 50ms at p50. Read the destination column as the finding. One case is
|
||||||
|
three points on 33. So 23 against 19 is outside the noise a single case makes,
|
||||||
|
and the other two columns are not.
|
||||||
|
|
||||||
|
Both arms produce three false clarifies and one missed clarify, and neither
|
||||||
|
errored on any case.
|
||||||
|
|
||||||
|
## What E4B loses
|
||||||
|
|
||||||
|
Four of the five destination regressions are the same shape: it names nothing
|
||||||
|
where the 12B names `recall` or `calendar`. `ru-query-015` ("сколько я прошёл
|
||||||
|
шагов") goes further and names `self`. Naming nothing is the safe direction,
|
||||||
|
because `SourceUnknown` walks the whole chain, so these turns are still answered.
|
||||||
|
They cost latency and they are what a fourth head is meant to fix (V-546).
|
||||||
|
|
||||||
|
Two Russian intent cases regress, both with the interrogative off the front.
|
||||||
|
`ru-chat-003` ("расскажи анекдот про программистов") goes to `query`.
|
||||||
|
`ru-fact-003` ("поужинал") goes to `chat`.
|
||||||
|
|
||||||
|
## MTP
|
||||||
|
|
||||||
|
E4B has none, and there is no way to give it any on this box. MTP on workpc is
|
||||||
|
a separate gguf of architecture `gemma4-assistant` carrying
|
||||||
|
`nextn_predict_layers=4`, and `mtp-gemma-4-12B-it-BF16.gguf` is the only one on
|
||||||
|
disk. Its head is trained against the 12B's hidden states, so it cannot drive an
|
||||||
|
E4B target. Scanning both target ggufs finds no `nextn` tensors in either, so
|
||||||
|
neither model self-speculates.
|
||||||
|
|
||||||
|
So the 12B arm above ran with speculative decoding and E4B ran without, and E4B
|
||||||
|
was still faster.
|
||||||
|
|
||||||
|
## Cost on the card
|
||||||
|
|
||||||
|
E4B is 4.2GB against 6.7GB plus a 0.86GB draft. With CW2 resident at 1.6GB that
|
||||||
|
is 5.8GB of 16GB against 9.2GB. Nothing in Maven needs the difference, so this is
|
||||||
|
headroom for the owner's own jobs rather than a capability.
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Kiwix answered the wrong question, and the fix was not a relevance gate
|
||||||
|
|
||||||
|
Date: 2026-08-09. Task: V-668. Box: homesrv, workstation off.
|
||||||
|
Book: `wikipedia_ru_all_maxi_2026-02` on `127.0.0.1:8034`.
|
||||||
|
|
||||||
|
## What started it
|
||||||
|
|
||||||
|
Two turns on 2026-08-09 came back wrong from the offline encyclopedia.
|
||||||
|
"почему небо голубое" was answered off the song "Город золотой". "что такое
|
||||||
|
TCP?" was answered off "Перехват TCP-соединения". Both were phrased
|
||||||
|
confidently, because `queryKiwix` claims a turn whenever the search returns
|
||||||
|
anything and `len(hits) == 0` is its only gate.
|
||||||
|
|
||||||
|
The plan was a relevance gate. multilingual-e5-small is asymmetric and trained
|
||||||
|
for exactly this, `query:` against `passage:`, and the query vector is already
|
||||||
|
held on the turn. The 2026-08-05 measurement that killed a search-quality gate
|
||||||
|
killed three lexical signals. It says in its own words that it never probed
|
||||||
|
Kiwix.
|
||||||
|
|
||||||
|
## The gate does not exist
|
||||||
|
|
||||||
|
Fourteen Russian questions, eight the encyclopedia can answer and six it
|
||||||
|
cannot. Each question was searched, the top article read, and the cosine of
|
||||||
|
`EmbedQuery(question)` against `EmbedPassage(article)` recorded.
|
||||||
|
|
||||||
|
| set | n | min | mean | max |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| answerable | 8 | 0.7934 | 0.8400 | 0.9087 |
|
||||||
|
| not answerable | 6 | 0.7480 | 0.7852 | 0.8367 |
|
||||||
|
|
||||||
|
Two of the six unanswerable score above the weakest answerable one. That alone
|
||||||
|
would be a poor threshold. The log killed it outright: seven of the eight
|
||||||
|
answerable questions got a **wrong** article back, and those wrong articles
|
||||||
|
scored high. The TCP hijacking article scored 0.8653, above five of the six
|
||||||
|
unanswerable rows.
|
||||||
|
|
||||||
|
The finding is that this cosine measures topic and not answerhood. A page about
|
||||||
|
hijacking TCP sessions is about TCP. No threshold separates it from a page that
|
||||||
|
defines TCP, and one that tried would take the definition with it.
|
||||||
|
|
||||||
|
## The defect is retrieval
|
||||||
|
|
||||||
|
`internal/kiwix/client.go` has said it since it was written: ranking is keyword
|
||||||
|
based, "why is the sky blue" finds a TV episode. `queryKiwix` sends the whole
|
||||||
|
sentence. The English path has a rewriter that reduces a question to keywords
|
||||||
|
with a model call. The Russian path reads the book verbatim (V-508) and had
|
||||||
|
nothing. So the question words compete with the one word that names the article.
|
||||||
|
|
||||||
|
Dropping the question words changes the answer:
|
||||||
|
|
||||||
|
| sent | first hit |
|
||||||
|
|---|---|
|
||||||
|
| `кто написал Войну и мир` | Радуйся, мир (Доктор Кто) |
|
||||||
|
| `Война и мир` | Война и мир |
|
||||||
|
| `что такое TCP` | Перехват TCP-соединения |
|
||||||
|
| `TCP` | TCP |
|
||||||
|
|
||||||
|
A ZIM is also addressable by title, which nothing here used. `/A/Франция`,
|
||||||
|
`/A/TCP` and `/A/Небо` are 200. `/A/Трюмбальная_нидроскопия` is 404. So an
|
||||||
|
exact title is safe to try first: it either answers or costs one request that
|
||||||
|
says nothing.
|
||||||
|
|
||||||
|
The title has to carry its capital. `/A/фотосинтез` is a 404 and
|
||||||
|
`/A/Фотосинтез` is a 200. The spoken form is tried first anyway, so a title
|
||||||
|
that begins lowercase on purpose keeps its chance.
|
||||||
|
|
||||||
|
## What shipped, measured
|
||||||
|
|
||||||
|
`kiwix.Topic` drops the narrative request, the interrogative and a verb sitting
|
||||||
|
behind one. It keeps everything else, because a word it cannot classify is more
|
||||||
|
likely the topic than noise. `kiwix.TitlePath` tries the exact article before
|
||||||
|
any ranking runs. Both apply on the verbatim path only, since reducing twice
|
||||||
|
would take the topic off the rewriter's input.
|
||||||
|
|
||||||
|
| question | before | after |
|
||||||
|
|---|---|---|
|
||||||
|
| что такое TCP? | Перехват TCP-соединения | **TCP** (by title) |
|
||||||
|
| что такое фотосинтез | C4-фотосинтез | **Фотосинтез** (by title) |
|
||||||
|
| кто такой Линус Торвальдс? | Tux | **Торвальдс, Линус** (by title) |
|
||||||
|
| кто написал Войну и мир | Радуйся, мир (Доктор Кто) | **Война и мир** |
|
||||||
|
| столица Франции | Список столиц Олимпийских игр | **Париж** (by title) |
|
||||||
|
| что такое чёрная дыра | Чёрная дыра | Чёрная дыра (by title) |
|
||||||
|
| почему небо голубое | Город золотой | Под небом голубым… (фильм) |
|
||||||
|
| почему трава зелёная | Сено | Зелень |
|
||||||
|
|
||||||
|
Five questions reach the right article where they did not. One was already
|
||||||
|
right and stays right. Nothing regressed.
|
||||||
|
|
||||||
|
"столица Франции" is the surprise. The 2026-08-05 measurement named it as the
|
||||||
|
case a quality gate must not break, because the answer is Париж and that word
|
||||||
|
is not in the question. The ZIM holds a title redirect, so asking for the
|
||||||
|
article titled "Столица Франции" returns Париж. Retrieval by title reaches an
|
||||||
|
answer that retrieval by keyword cannot.
|
||||||
|
|
||||||
|
## What is still wrong
|
||||||
|
|
||||||
|
Two of the eight are still not answered, and both are the same shape. The
|
||||||
|
question names no article and no redirect covers it. "почему небо голубое" is
|
||||||
|
answered by Rayleigh scattering, and nothing in the question says so. Keyword
|
||||||
|
retrieval cannot bridge that and neither can a threshold. The candidates are a
|
||||||
|
semantic index over titles, or asking the resident model for the article title
|
||||||
|
rather than for keywords.
|
||||||
|
|
||||||
|
`Response.Empty()` is still the whole gate. A wrong article that the search
|
||||||
|
does return is still spoken. What this change buys is that the article is
|
||||||
|
usually right, not that a wrong one is caught.
|
||||||
|
|
||||||
|
## Not measured here
|
||||||
|
|
||||||
|
The English path, which still goes through the rewriter and was not touched.
|
||||||
|
SearXNG, where the same question about answerhood is open and the 2026-08-05
|
||||||
|
result stands. The cascade end to end, since the workstation is off and the
|
||||||
|
phrasing arm is the resident model.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# silero-vad against the energy threshold in mavwaked
|
||||||
|
|
||||||
|
*Measured 2026-08-09 on homesrv. V-487, stage one of two.*
|
||||||
|
|
||||||
|
mavwaked decided an utterance had started by comparing frame energy to an
|
||||||
|
adaptive floor. That answers "is this frame loud". A fan, a door and a
|
||||||
|
television are all loud, and every utterance mavwaked accepts becomes a turn.
|
||||||
|
|
||||||
|
silero-vad answers "is this frame speech". It is 2.3MB of ONNX and it replaces
|
||||||
|
the comparison and nothing else. The speech hold, the silence hold, the length
|
||||||
|
cap and the utterance buffer are the same state machine either way.
|
||||||
|
|
||||||
|
## What it declines
|
||||||
|
|
||||||
|
Speech is the four piper fixtures `mavsttd` already scores against, so nothing
|
||||||
|
of the owner's voice is committed. Non-speech is white noise at the same RMS as the clip beside it. That is the
|
||||||
|
cheapest thing that fools an energy floor.
|
||||||
|
|
||||||
|
| clip | silero, speech frames on speech | silero on noise | energy on noise |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ru_fact.wav | 59 | 0 | 68 |
|
||||||
|
| ru_query.wav | 69 | 0 | 79 |
|
||||||
|
| ru_reminder.wav | 80 | 0 | 89 |
|
||||||
|
| en_act.wav | 90 | 0 | 99 |
|
||||||
|
|
||||||
|
The energy threshold accepts every noise clip as a complete utterance. Silero
|
||||||
|
calls not one frame of any of them speech, and still hears all four spoken
|
||||||
|
clips. `TestSileroHearsSpeechAndDeclinesNoise` is that table.
|
||||||
|
|
||||||
|
White noise is a floor, not a proof. It says nothing about a television, which
|
||||||
|
is speech, or about a fan, which is narrowband. Those need room recordings and
|
||||||
|
this box has none.
|
||||||
|
|
||||||
|
## What it costs
|
||||||
|
|
||||||
|
`BenchmarkSileroFrame` on the homesrv laptop (Ryzen 5 5600U), one 30ms frame
|
||||||
|
through the model including the re-chunking:
|
||||||
|
|
||||||
|
509µs per frame
|
||||||
|
|
||||||
|
That is 1.7% of one core, on the slower of the two machines. The detector runs
|
||||||
|
on the workstation beside the microphone, never on the GPU. This number is what
|
||||||
|
says it does not need one.
|
||||||
|
|
||||||
|
## The window is 512 samples, not 480
|
||||||
|
|
||||||
|
`cmd/mavwaked/main.go` claimed the frame contract matched silero's input
|
||||||
|
exactly. That was true of silero v4. Version 5 takes exactly 512 samples at 16kHz, plus 64 samples of context from
|
||||||
|
the previous window. So `sileroVAD` buffers across capture frames, and a frame
|
||||||
|
completing no window inherits the previous probability. `TestSileroRechunksAcrossFrames` pins it.
|
||||||
|
|
||||||
|
## Still an energy gate by default
|
||||||
|
|
||||||
|
`-vad-model` is empty in the code default, so a deployment that does not pass
|
||||||
|
it runs exactly what shipped before. Barge-in is untouched and deliberately so. It reads frame energy while she is
|
||||||
|
speaking, which is a different question from whether the frame is speech.
|
||||||
|
|
||||||
|
## Not done here
|
||||||
|
|
||||||
|
The wake word. This is stage one of the two V-487 asks for. The second needs a
|
||||||
|
keyword model that does not exist yet. The pretrained openWakeWord keywords are
|
||||||
|
English, and a Russian one has to be trained. Until then anything spoken near
|
||||||
|
the microphone still becomes a turn. It is now merely required to be speech.
|
||||||
+38
-3
@@ -1,6 +1,6 @@
|
|||||||
# Offloading model work to the workstation
|
# Offloading model work to the workstation
|
||||||
|
|
||||||
*Last verified: 2026-08-05 @ b789676. Living doc: correct it in place, do not append.*
|
*Last verified: 2026-08-09 @ 50c6637. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the
|
Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the
|
||||||
work, and this file holds the shape and the rules all four must obey.
|
work, and this file holds the shape and the rules all four must obey.
|
||||||
@@ -105,6 +105,17 @@ how we find out whether the blind spot is real.
|
|||||||
untouched. The model, the context size, the layer count and the MTP flags are the
|
untouched. The model, the context size, the layer count and the MTP flags are the
|
||||||
owner's business and not this daemon's schema.
|
owner's business and not this daemon's schema.
|
||||||
|
|
||||||
|
**Every GPU service on that box belongs under this supervisor**, added to
|
||||||
|
`cmd/mavgpud` rather than to systemd beside it. The rule was learned on
|
||||||
|
2026-08-09. The CW2 transcriber ran as its own user unit and registered on the
|
||||||
|
KFD like any ROCm job. So the supervisor read its own transcriber as a
|
||||||
|
contender. It yielded the card every few seconds and the gemma-4-12b arm was
|
||||||
|
down for eight minutes before anyone looked. So the supervisor takes a `stt`
|
||||||
|
block and starts CW2 itself. Yielding is all or nothing, because a job that
|
||||||
|
wants the card wants all of it. Idle unloading is not. It applies to
|
||||||
|
llama-server, which holds 8GB. CW2 holds 1.6GB, and unloading it would cost the
|
||||||
|
next voice turn its quality for nothing.
|
||||||
|
|
||||||
## What stays on homesrv, permanently
|
## What stays on homesrv, permanently
|
||||||
|
|
||||||
The **embedder** (multilingual-e5-small, ONNX, CPU). It backs the classifier, which
|
The **embedder** (multilingual-e5-small, ONNX, CPU). It backs the classifier, which
|
||||||
@@ -149,6 +160,29 @@ flips. It is wired anyway: `PhraseReminder` is on the same transport and is on.
|
|||||||
Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`.
|
Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`.
|
||||||
`mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames.
|
`mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames.
|
||||||
|
|
||||||
|
Speech-to-text is wired as of 09-08-2026, and it takes only the silent half of the
|
||||||
|
rule. A worse transcript is still a turn, so there is nothing to name a gap about
|
||||||
|
and `stt.Pair` has no `TranscribeRemote`. `sttSeam` in `cmd/mavend/voicewire.go`
|
||||||
|
builds it, beside `modelSeam` and at the same place in `wireVoice`, so the voice
|
||||||
|
path and the meeting recorder still share one transcriber.
|
||||||
|
|
||||||
|
The remote is not a second endpoint on mavgpud. whisper.cpp cannot load
|
||||||
|
CrisperWhisper 2.0 at all. It reads its language count off the vocabulary
|
||||||
|
size, and CW2's 51897 tokens shift seven special token ids. So CW2 runs under
|
||||||
|
transformers as its own service on port 8081, and `stt.HTTPTranscriber` is the
|
||||||
|
second transport for the same seam. It posts raw PCM with the format in headers.
|
||||||
|
It carries a bearer token, because audio is the most sensitive thing that
|
||||||
|
crosses here.
|
||||||
|
|
||||||
|
It is a second endpoint on nothing, but it is a second **child** of mavgpud, and
|
||||||
|
that part is not optional. See the supervisor section above for why: a ROCm
|
||||||
|
service the supervisor does not own is a contender it yields to.
|
||||||
|
|
||||||
|
The margin is the reason: CW2 turbo scores 10.4% WER in Russian against 27.5% for
|
||||||
|
the `ggml-small.bin` mavsttd loads, over 200 Golos clips
|
||||||
|
(`docs/evals/2026-08-09-crisperwhisper2-russian-wer.md`). Text-to-speech has not
|
||||||
|
moved and piper on homesrv is still the only synthesizer.
|
||||||
|
|
||||||
Speech-to-text stays two stages when it moves. One call carrying both a clip and the router
|
Speech-to-text stays two stages when it moves. One call carrying both a clip and the router
|
||||||
prompt was measured on 05-08-2026. It scores 54.2% intent-only against 84.7% for whisper on
|
prompt was measured on 05-08-2026. It scores 54.2% intent-only against 84.7% for whisper on
|
||||||
homesrv, on the same 72 cases. The model transcribes clips it then routes wrong, so a long
|
homesrv, on the same 72 cases. The model transcribes clips it then routes wrong, so a long
|
||||||
@@ -173,8 +207,9 @@ cleaner transcripts, not accuracy. See `docs/evals/2026-08-05-audio-in-routing.m
|
|||||||
which fixes what the 1.7B gets wrong: world knowledge, and the persona the CPT
|
which fixes what the 1.7B gets wrong: world knowledge, and the persona the CPT
|
||||||
targets. The degradation path is already written and measured, since the
|
targets. The degradation path is already written and measured, since the
|
||||||
classifier scores 68.8% full accuracy at p50 16.6µs on its own.
|
classifier scores 68.8% full accuracy at p50 16.6µs on its own.
|
||||||
3. **Speech-to-text and text-to-speech** (#486). They gain a real margin, but on
|
3. **Speech-to-text and text-to-speech** (#486). Speech-to-text is wired, see
|
||||||
quality alone, and both already work.
|
above. Text-to-speech is not, and piper is good enough that nothing argues
|
||||||
|
for moving it yet.
|
||||||
4. **The wake word** (#487). Independent of all of the above.
|
4. **The wake word** (#487). Independent of all of the above.
|
||||||
|
|
||||||
## Assumptions
|
## Assumptions
|
||||||
|
|||||||
+12
-2
@@ -1,6 +1,6 @@
|
|||||||
# Start Commands
|
# Start Commands
|
||||||
|
|
||||||
*Last verified: 2026-08-02 @ 7079a24. Living doc: correct it in place, do not append.*
|
*Last verified: 2026-08-07 @ a4630b9. Living doc: correct it in place, do not append.*
|
||||||
|
|
||||||
All commands assume `ROOT=/home/kami/apps/Maven` and the local Go toolchain at `$ROOT/deps/go/go/bin/go`.
|
All commands assume `ROOT=/home/kami/apps/Maven` and the local Go toolchain at `$ROOT/deps/go/go/bin/go`.
|
||||||
|
|
||||||
@@ -44,7 +44,8 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options.
|
|||||||
"repeat_interval": "5m",
|
"repeat_interval": "5m",
|
||||||
"ntfy": {
|
"ntfy": {
|
||||||
"base_url": "https://ntfy.kvmx.ru",
|
"base_url": "https://ntfy.kvmx.ru",
|
||||||
"topic": "maven"
|
"topic": "maven",
|
||||||
|
"token": "${NTFY_TOKEN}"
|
||||||
},
|
},
|
||||||
"phraser": {
|
"phraser": {
|
||||||
"model_path": "/mnt/hdd1/llms/Qwen3-Maven-1.7B-Q8_0.gguf",
|
"model_path": "/mnt/hdd1/llms/Qwen3-Maven-1.7B-Q8_0.gguf",
|
||||||
@@ -66,6 +67,15 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options.
|
|||||||
|
|
||||||
Omit the `embedder` block entirely to use the deterministic HashEmbedder floor (no ML, no ONNX runtime dependency). Useful for testing or low-resource setups.
|
Omit the `embedder` block entirely to use the deterministic HashEmbedder floor (no ML, no ONNX runtime dependency). Useful for testing or low-resource setups.
|
||||||
|
|
||||||
|
`${NTFY_TOKEN}` and the `${TELEGRAM_*}` vars are expanded from `deploy/telegram.env`, which is gitignored. Copy `deploy/telegram.env.example` and fill it in. Mint a scoped token rather than reusing an admin one. It needs write access to the `maven` topic and nothing else:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ntfy access maven maven write-only
|
||||||
|
ntfy token add --expires=never maven
|
||||||
|
```
|
||||||
|
|
||||||
|
Deleting the `ntfy` block turns the reach off, and that is not a no-op. The routing table sends sev3-away nudges and away reminders to ntfy and nowhere else. With no sink wired they hit a nil and vanish, leaving no log line and no `delivery_attempts` row (V-649).
|
||||||
|
|
||||||
## mavsttd — STT worker (optional, remote whisper.cpp)
|
## mavsttd — STT worker (optional, remote whisper.cpp)
|
||||||
|
|
||||||
Requires `LD_LIBRARY_PATH` to include deps/lib (for libwhisper.so, libggml-vulkan.so).
|
Requires `LD_LIBRARY_PATH` to include deps/lib (for libwhisper.so, libggml-vulkan.so).
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Plan: persist the routing trace
|
||||||
|
|
||||||
|
**Owner's call, 06-08-2026. Vikunja #629, umbrella #628.**
|
||||||
|
|
||||||
|
**Verdict: the per-turn decision record now persists.** That reverses a written decision,
|
||||||
|
which is the point of this file. It is not an incidental telemetry
|
||||||
|
feature. Do not read it as one.
|
||||||
|
|
||||||
|
Last verified: 06-08-2026 @ 799cf55
|
||||||
|
|
||||||
|
## What the old decision said
|
||||||
|
|
||||||
|
`internal/decision` kept a 25-turn in-memory ring and persisted nothing. The argument was
|
||||||
|
in `CLAUDE.md` and it was a good one. A turn record is read minutes after the turn or
|
||||||
|
never, so a table that outlives the diagnosis buys nothing. His words did not belong in it.
|
||||||
|
|
||||||
|
## Why it reversed
|
||||||
|
|
||||||
|
V-546 replaces the generative router with classification heads on e5-small. Fitting
|
||||||
|
prototypes and calibrating a distance both need real utterances. V-631 measured how few
|
||||||
|
there are. Nine of the 31 modes in `internal/modes` have no seed example at all, and they
|
||||||
|
are exactly the nine with no deterministic matcher. The seed corpus cannot supply them. A
|
||||||
|
seed row is a phrase someone wrote for a matcher, not a thing he said. The 202 generated
|
||||||
|
contrast pairs were tried and cost four points of fixture accuracy.
|
||||||
|
|
||||||
|
So the choice was between no routing heads and a persisted trace. The owner chose the trace.
|
||||||
|
|
||||||
|
## Retention, and why it is two answers
|
||||||
|
|
||||||
|
**Raw trace: 14 days.** `store.RoutingTraceRetention` in `internal/store/routingtraces.go`. That
|
||||||
|
is the life of a diagnosis with room for a weekend. The bound is an age and not a row
|
||||||
|
count. The useful question is what she did this week, and a busy Tuesday must not push last
|
||||||
|
Friday out.
|
||||||
|
|
||||||
|
**A correction: indefinite.** The owner corrects a turn on `/chat` (V-630). The pair is then
|
||||||
|
promoted out of the trace into a seed-shaped row and kept, because a label is not a
|
||||||
|
transcript. What stays in `routing_traces` is the transcript. It expires on the same 14
|
||||||
|
days as every other row, corrected or not.
|
||||||
|
|
||||||
|
## What keeps it safe
|
||||||
|
|
||||||
|
The utterance is stored in clear. A 384-dimension vector of a short sentence is
|
||||||
|
substantially recoverable. Storing vectors instead would be a privacy claim we cannot
|
||||||
|
support, and making it would be worse than staying silent.
|
||||||
|
|
||||||
|
- **Nothing here leaves the box.** The rule that the owner's notes and facts are never
|
||||||
|
search input covers this table too. No query source reads it, and no upstream engine can.
|
||||||
|
- **Retention is enforced on write and again at start.** `WriteRoutingTrace` prunes every
|
||||||
|
64th row, which is hours at human rate. `pruneTracesOnStart` covers the case write alone
|
||||||
|
cannot. A box that goes quiet keeps every row until the next sixty-fourth turn. Without
|
||||||
|
the start-time prune, the bound would hold only for a box in daily use.
|
||||||
|
- **Deletion already exists.** `Store.Wipe` drops every table the database reports, so
|
||||||
|
`mavend -wipe -confirm-wipe` covers this one with no list to edit.
|
||||||
|
- **The ring did not move.** It is still what `/trace` reads and still what a test with no
|
||||||
|
store gets. The table is a second sink beside it. A failed insert is logged and swallowed,
|
||||||
|
because a trace must never change what he hears.
|
||||||
|
|
||||||
|
## What is not decided
|
||||||
|
|
||||||
|
Whether some utterances must never be promoted into a durable label, no matter how badly
|
||||||
|
they routed. That is a content rule and it belongs beside the personal boundary, not in the trace
|
||||||
|
writer. Recorded here, left to the owner.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Correcting a turn
|
||||||
|
|
||||||
|
Last verified: 06-08-2026 @ 0d5bd0a
|
||||||
|
|
||||||
|
V-630, under V-628. Reads with `21-persisting-the-routing-trace.md`.
|
||||||
|
|
||||||
|
## Why a gesture and not a form
|
||||||
|
|
||||||
|
The routing trace (V-629) stores every turn. Almost all of them routed correctly, so
|
||||||
|
almost all of them teach nothing. A correction is the only high-value supervised signal
|
||||||
|
the box produces. It is also the only one that costs the owner something to give.
|
||||||
|
|
||||||
|
So the design constraint is the cost, not the schema. One gesture beside the reply. No
|
||||||
|
form and no separate page.
|
||||||
|
|
||||||
|
It is step-up gated like the chat POST beside it, which costs nothing: he tapped to send
|
||||||
|
the turn he is correcting. It is gated because trace ids are sequential integers, and this
|
||||||
|
is the one table the routing heads will be fitted on.
|
||||||
|
|
||||||
|
## Two things to capture, and only one of them is required
|
||||||
|
|
||||||
|
A correction has two halves.
|
||||||
|
|
||||||
|
- This turn was wrong.
|
||||||
|
- It should have been *this*.
|
||||||
|
|
||||||
|
The second is worth much more. It names which boundary moved, and it is what a fitted
|
||||||
|
head trains against. But requiring it would price out the first, and a turn marked wrong
|
||||||
|
with no target is still a usable negative. So the target is optional. The trace carries
|
||||||
|
`wrong` when he did not say.
|
||||||
|
|
||||||
|
The target is one of the seven intents and never free text. V-632 fits prototypes from
|
||||||
|
that table. An unroutable label would enter it, and a label nothing can score is worse
|
||||||
|
than no label.
|
||||||
|
|
||||||
|
## Where the label lives
|
||||||
|
|
||||||
|
`routing_labels`, migration #24, keyed unique on the utterance. A second correction of
|
||||||
|
the same sentence replaces the first, because his later answer is the one he meant.
|
||||||
|
|
||||||
|
It is a separate table from `routing_traces` on purpose. The transcript expires after 14
|
||||||
|
days. The label does not. A label is a sentence, an intent and an encoder id. That is not
|
||||||
|
a transcript, and the reversal in doc 21 rests on the distinction.
|
||||||
|
|
||||||
|
`was` is stored beside `should_be`. The pair is what names the confusion. A label with no
|
||||||
|
`was` cannot say which boundary moved.
|
||||||
|
|
||||||
|
## Reach
|
||||||
|
|
||||||
|
`CorrectTurn(traceID, shouldBe)` takes no browser and no session. The trace id rides back
|
||||||
|
on `ipc.ChatReply` through the same context sink the query source badge uses. Nothing in
|
||||||
|
the seam assumes the web.
|
||||||
|
|
||||||
|
Only `/chat` offers the gesture today. That is a gap, named rather than closed. If the web
|
||||||
|
is the only place to correct a turn, the sample skews to whatever the owner types at. Voice
|
||||||
|
is where the hard cases are. Telegram has the obvious shape, an inline keyboard on the
|
||||||
|
reply. Voice does not. Inventing a spoken correction grammar would put a recogniser in
|
||||||
|
front of the one signal that exists to fix recognisers. Both are follow-on work.
|
||||||
|
|
||||||
|
## What is not decided
|
||||||
|
|
||||||
|
Whether the owner ever wants to see the labels he gave. Nothing reads the table outward
|
||||||
|
yet. `/trace` shows the ring, which is 25 turns and in memory, and a labels view is a
|
||||||
|
different page with a different question.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Inbound telegram
|
||||||
|
|
||||||
|
Last verified: 06-08-2026 @ c61b0b3
|
||||||
|
|
||||||
|
V-637, under V-628. Reads with `22-correcting-a-turn.md`.
|
||||||
|
|
||||||
|
## What was missing
|
||||||
|
|
||||||
|
Telegram was a reach and nothing else. `telegramsink` pushed an away message and the chat
|
||||||
|
had no way to answer, so the correction gesture reached the web and voice only.
|
||||||
|
|
||||||
|
That skews the labels. V-546 fits routing heads on them, and a sample drawn from wherever
|
||||||
|
the owner happens to be sitting is the wrong sample.
|
||||||
|
|
||||||
|
## Long-poll, not a webhook
|
||||||
|
|
||||||
|
The box takes no inbound connections and reaches api.telegram.org through a relay, so the
|
||||||
|
connection has to open outward. `getUpdates` with a 25 second hold, one goroutine in the
|
||||||
|
daemon's WaitGroup.
|
||||||
|
|
||||||
|
A failed poll waits 15 seconds and retries without escalating. The relay going down is the
|
||||||
|
normal cause and it comes back on its own.
|
||||||
|
|
||||||
|
## The backlog is dropped on start
|
||||||
|
|
||||||
|
Telegram keeps undelivered updates for 24 hours. A daemon that was down overnight would
|
||||||
|
otherwise wake and answer every queued message in order.
|
||||||
|
|
||||||
|
That is worse than missing them. A question asked eight hours ago has been answered
|
||||||
|
already. A reminder set from it lands at the wrong time. So the first call moves the offset
|
||||||
|
past whatever is queued and acts on none of it.
|
||||||
|
|
||||||
|
## One chat
|
||||||
|
|
||||||
|
`ChatID` is the only accepted sender, and it is the same chat the push half already sends
|
||||||
|
to. A message from anywhere else is dropped with no reply, because a reply confirms the bot
|
||||||
|
exists and whose it is.
|
||||||
|
|
||||||
|
Chat ids are not guessable. They are also not secret, since they travel in every forwarded
|
||||||
|
message. So this is the whole authorisation and it is an allowlist of one.
|
||||||
|
|
||||||
|
## The gesture
|
||||||
|
|
||||||
|
Two taps at most. The reply carries one button, `не то`. Tapping it writes nothing and opens
|
||||||
|
the seven intents plus `просто неверно`. The untargeted negative stays reachable, because he
|
||||||
|
may have opened the row without meaning to name anything.
|
||||||
|
|
||||||
|
Callback data carries the trace id and the target, under telegram's 64 byte cap. It comes
|
||||||
|
off the wire. So an id that will not parse is dropped, and so is a target that is not one of
|
||||||
|
the seven. A label nothing can score is worse than no label.
|
||||||
|
|
||||||
|
A failed write says so on the button and leaves the keyboard up. A successful one takes the
|
||||||
|
keyboard off, because a live keyboard on an answered turn invites correcting it twice.
|
||||||
|
|
||||||
|
## The seam
|
||||||
|
|
||||||
|
`NewPoller` takes two functions and no daemon type. `cmd/mavend/telegramintake.go` fills
|
||||||
|
them from `ipc.CoreAPI`: `Chat` returns the reply and the trace id it collected off the
|
||||||
|
context, and `CorrectTurn` writes the label. So a chat turn takes the path
|
||||||
|
`POST /api/chat` already takes, and nothing in `internal/delivery` knows what a handler is.
|
||||||
|
|
||||||
|
## What is not done
|
||||||
|
|
||||||
|
The turn source is still `tap:text`, which telegram shares with the web. Provenance cannot
|
||||||
|
tell a chat turn from a typed one, so a label's `source` column cannot either.
|
||||||
|
That matters the first time someone asks whether corrections given in the chat differ from
|
||||||
|
corrections given at the desk.
|
||||||
|
|
||||||
|
Voice messages are ignored. The poller reads `message.text` and nothing else, so a voice
|
||||||
|
note in the chat does not reach `mavsttd`.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# No deadline on the turn path
|
||||||
|
|
||||||
|
Last verified: 06-08-2026 @ 60e64dd
|
||||||
|
|
||||||
|
**All four steps landed on 06-08-2026.** What follows describes the defect as it was and
|
||||||
|
the work as it was planned. Two things came out differently. `Client.Close` read the conn
|
||||||
|
field with no lock while `roundtrip` re-dialed and dropped it. `-race` caught that on the
|
||||||
|
new cancellation test. So the conn field now has a mutex of its own, held only across a
|
||||||
|
read or an assignment. And `/api/ptt` needed nothing: it proxies to the voice port and never
|
||||||
|
touches the shared client, so only `/api/chat` got the extra connection. The pool inside
|
||||||
|
`ipc.Client` is still unbuilt and still waiting on a second module measured queueing.
|
||||||
|
|
||||||
|
V-638. Sibling of V-607, which is the same class of bug in `internal/worker`.
|
||||||
|
Reads with `docs/offload.md` and `docs/protocol.md`.
|
||||||
|
|
||||||
|
## What is missing
|
||||||
|
|
||||||
|
A chat turn starts in a mavweb HTTP handler and ends at llama-server. Nothing between those
|
||||||
|
two points can be cancelled, and one hop has a timeout.
|
||||||
|
|
||||||
|
Four places, all on the same path.
|
||||||
|
|
||||||
|
`voice.Replier.Reply` takes no context (`internal/voice/replier.go:41`). So `llmReplier`
|
||||||
|
calls `PhraseReply(context.Background(), d)` at `cmd/mavend/replier_llm.go:42`. The turn
|
||||||
|
cannot deadline its own reply. The only bound is `phraser.timeout`, 60s in deploy.
|
||||||
|
|
||||||
|
`ipc.Client.roundtrip` sets no connection deadline (`internal/ipc/client.go:202`). A daemon
|
||||||
|
that stops answering parks the caller for as long as the socket stays open.
|
||||||
|
|
||||||
|
`ipc.Client.call` checks the context once, before sending (`client.go:149`), then blocks in
|
||||||
|
`roundtrip`. Cancelling mid-call does nothing.
|
||||||
|
|
||||||
|
`ipc.Server.serveConn` dispatches under `context.Background()` (`internal/ipc/server.go:253`).
|
||||||
|
A client that hangs up does not cancel the turn, and neither does `Server.Close`.
|
||||||
|
|
||||||
|
## And every call queues behind the slowest one
|
||||||
|
|
||||||
|
`ipc.Client` serialises on one connection and one mutex. mavweb routes `/api/chat` and
|
||||||
|
`/api/ptt` through the shared client, so one turn blocks all 28 handlers while it runs.
|
||||||
|
Worst case is a 60s page load.
|
||||||
|
|
||||||
|
This is understood for exactly one route already. `cmd/mavweb/main.go:57` opens a second
|
||||||
|
connection for `/models`, and the comment there says why. A model swap is a multi-minute
|
||||||
|
call, and sharing the connection would freeze every other page.
|
||||||
|
|
||||||
|
## The pattern is already in the repo
|
||||||
|
|
||||||
|
`internal/voice/client.go:101` derives a connection deadline from the caller's context,
|
||||||
|
falls back to 120s, and clears it with a defer. `internal/ipc/client.go` never learned it.
|
||||||
|
Copy that rather than inventing a second convention.
|
||||||
|
|
||||||
|
## The work
|
||||||
|
|
||||||
|
One commit each.
|
||||||
|
|
||||||
|
**Context on the reply seam.** `phraser.Replier.PhraseReply` already takes a context and the
|
||||||
|
interface has two implementations, so this is small. Change `Reply` to take a context, have
|
||||||
|
`StubReplier` ignore it, and pass it through `llmReplier` to `PhraseReply`. Both call sites
|
||||||
|
already hold one: `cmd/mavend/voice.go:461` and `cmd/mavend/clarify.go:574`.
|
||||||
|
|
||||||
|
**Deadlines and cancellation on the client.** Pass the context into `roundtrip` and set
|
||||||
|
`SetDeadline` from it. For cancellation mid-call, a watchdog goroutine that calls `c.drop()`
|
||||||
|
on `ctx.Done()` is enough. `drop` exists, and the retry split already separates a lost write
|
||||||
|
from a lost read. So a cancelled call lands in `errReadLost` and is never retried for a
|
||||||
|
mutation. Check that against `internal/ipc/maperr_test.go`.
|
||||||
|
|
||||||
|
**A request context on the server.** `serveConn` should derive from a server-scoped context
|
||||||
|
so `Close` cancels a dispatch in flight. `Server` already carries `done` and a conn registry
|
||||||
|
for this class of problem. The registry comment records what the last version of it cost:
|
||||||
|
eleven days of stale ciphertext.
|
||||||
|
|
||||||
|
**Stop serialising mavweb.** Give `/api/chat` and `/api/ptt` their own connection, the way
|
||||||
|
`/models` has one. Roughly ten lines, and it changes no shared code.
|
||||||
|
|
||||||
|
A connection pool inside `ipc.Client` is the general form and is deliberately not the first
|
||||||
|
step. Each connection is already its own request and response stream. So a pool preserves
|
||||||
|
frame pairing by construction. It still has to keep re-dial on drop, the
|
||||||
|
`errWriteLost` and `errReadLost` split, and `Close`. Do the narrow fix, measure, and reach
|
||||||
|
for the pool only if a second module turns out to queue.
|
||||||
|
|
||||||
|
## How it is judged
|
||||||
|
|
||||||
|
`make test` stays green. It is green at `06c1cf2`.
|
||||||
|
|
||||||
|
Nothing here changes routing or recall, so `make eval-router` and `make eval-recall` are
|
||||||
|
unchanged rather than re-measured.
|
||||||
|
|
||||||
|
By hand: load `/dash` while a chat turn is in flight. Before the change it waits for the
|
||||||
|
length of the turn.
|
||||||
|
|
||||||
|
There is no test today that a cancelled context aborts an in-flight `ipc.Client` call. That
|
||||||
|
absence is why two of these four went unnoticed, so the test is part of the work.
|
||||||
|
|
||||||
|
## What is not done here
|
||||||
|
|
||||||
|
The store is still `SetMaxOpenConns(1)` (`internal/store/store.go:99`) under WAL. WAL is
|
||||||
|
built for concurrent readers against one writer, and the cap makes every read queue.
|
||||||
|
`Store.DB(ctx)` hands the digestion worker a read transaction on that same connection. This
|
||||||
|
plan does not touch it. It is measurable first and should be measured before it is changed.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# The two boot paths have drifted
|
||||||
|
|
||||||
|
Last verified: 06-08-2026 @ 69d0f5e
|
||||||
|
|
||||||
|
V-639. Reads with `docs/operations.md`.
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
`cmd/mavend/boot.go`. `newDaemonAPI(deps)` builds the CoreAPI with every field
|
||||||
|
set, and `startBackground(ctx, &wg, deps)` starts the voice server and every
|
||||||
|
worker through `goWorker`. `backgroundWorkers(deps)` is the pure list behind it,
|
||||||
|
so a test can compare the set without standing a daemon up. Both paths in
|
||||||
|
`run()` now read `coreAPI = newDaemonAPI(depsNow())` and one
|
||||||
|
`startBackground(...)`, where `depsNow` reads whatever the current path wired.
|
||||||
|
|
||||||
|
The shadowed `wg` is gone. Four tests in `cmd/mavend/boot_test.go`. Every
|
||||||
|
`daemonAPI` field is set on a fully wired deployment. The handler gets the API
|
||||||
|
it was built with. The worker set is asserted by name, at the full set and at
|
||||||
|
the floor.
|
||||||
|
|
||||||
|
Still by hand: unlock a locked box by passkey, ask something that needs Nexus,
|
||||||
|
and check `/tools` lists the MCP servers.
|
||||||
|
|
||||||
|
## What is wrong
|
||||||
|
|
||||||
|
`run()` in `cmd/mavend/main.go` brings the daemon up two ways. A box with a key in the
|
||||||
|
environment starts unlocked and wires everything at lines 280 to 621. A box without one
|
||||||
|
starts locked. It wires the same things again inside the unlock closure, at lines 500 to
|
||||||
|
579, after a passkey assertion.
|
||||||
|
|
||||||
|
The two lists have drifted apart. Three ways.
|
||||||
|
|
||||||
|
**Seven workers start untracked.** The unlocked path puts every one through
|
||||||
|
`goWorker(&wg, ...)`, so `waitWorkers` at line 637 can wait for them. The unlock path
|
||||||
|
starts `tl.run`, `factWorker`, `evalWorker`, `feedWkr`, `crawlWkr`, `mcp.run` and
|
||||||
|
`home.run` as bare `go func()`. Nothing waits for any of them.
|
||||||
|
|
||||||
|
That is the shutdown bug the code already documents at lines 631 to 636, reintroduced on
|
||||||
|
the other path. The comment there records what it cost the first time. `run()` never
|
||||||
|
returned, so `defer st.Close()` never sealed the database. The deployed ciphertext was
|
||||||
|
eleven days stale before anyone noticed.
|
||||||
|
|
||||||
|
**A shadowed WaitGroup hides it.** Line 529 declares `var wg sync.WaitGroup` inside the
|
||||||
|
`if voiceW != nil` block, shadowing the one from line 359. It is `Add`ed and `Done`d and
|
||||||
|
never waited. Reading the block, the voice server looks tracked. It is not.
|
||||||
|
|
||||||
|
**Two `daemonAPI` fields are never set.** The unlocked path fills `nexus` at line 295 and
|
||||||
|
`getMCPServers` at line 305. The unlock path fills neither. So after a passkey unlock,
|
||||||
|
`ResolveEntity` answers `ErrNotImplemented` with a `nexus` block configured, and
|
||||||
|
`MCPServers` answers empty with an `mcp` block configured.
|
||||||
|
|
||||||
|
The second is the worse one. Empty is not a degraded answer, it is a wrong answer, and
|
||||||
|
`/tools` renders it as "not configured".
|
||||||
|
|
||||||
|
## Why it drifted
|
||||||
|
|
||||||
|
`wireTelegramIntake` was added to both paths on 06-08-2026 (V-637) and it does use the
|
||||||
|
outer `wg`, at line 519. So the newest line on that path is correct and the older ones
|
||||||
|
around it are not. The path gets touched one line at a time and is never read whole.
|
||||||
|
|
||||||
|
The shape of `cmd/mavend` is what allows that. It is 155 files and 9,551 lines of code.
|
||||||
|
Six things live in it with no seam between them:
|
||||||
|
|
||||||
|
- the handler
|
||||||
|
- the action dispatch
|
||||||
|
- the 19 query sources
|
||||||
|
- the wiring functions
|
||||||
|
- the six background workers
|
||||||
|
- these two boot paths
|
||||||
|
|
||||||
|
Nothing in the package makes the divergence visible.
|
||||||
|
|
||||||
|
## The fix
|
||||||
|
|
||||||
|
Make the two paths call one function instead of listing the same wiring twice.
|
||||||
|
|
||||||
|
One `startBackground(ctx, &wg, deps)` that takes what it needs and starts every worker
|
||||||
|
through `goWorker`. One `newDaemonAPI(deps)` that fills every field, including `nexus` and
|
||||||
|
`getMCPServers`, so a field added later cannot reach one path and miss the other. Both
|
||||||
|
call sites then read as one call each, and a future addition has one place to go.
|
||||||
|
|
||||||
|
Delete the shadowed `wg` at line 529 as part of it.
|
||||||
|
|
||||||
|
## How it is judged
|
||||||
|
|
||||||
|
`make test` stays green.
|
||||||
|
|
||||||
|
The regression that matters is a test asserting the two paths wire the same set. Compare
|
||||||
|
the constructed `daemonAPI` field by field, and assert the worker count started under the
|
||||||
|
outer `wg` matches. Without that, this drifts again the next time a wiring line is added.
|
||||||
|
|
||||||
|
Then confirm on a locked box: unlock by passkey, ask something that needs Nexus, and check
|
||||||
|
`/tools` lists the MCP servers. Both answer wrongly today.
|
||||||
|
|
||||||
|
## Priority
|
||||||
|
|
||||||
|
Latent, not live. `deploy/mavend.json` sets `db_key_env`, so homesrv boots unlocked and
|
||||||
|
takes the correct path. This bites the locked deployment that `docs/operations.md`
|
||||||
|
describes, and it bites silently.
|
||||||
@@ -456,9 +456,30 @@ func (c *Config) validate() error {
|
|||||||
if err := c.validateCapture(); err != nil {
|
if err := c.validateCapture(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := c.validateTelegram(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validateTelegram refuses an intake half that cannot read the chat it is
|
||||||
|
// pointed at. The push half accepts an @channelusername and the intake half
|
||||||
|
// does not, so a box configured with both boots clean, keeps pushing, and
|
||||||
|
// answers nothing — the failure is invisible from the chat. Same shape as
|
||||||
|
// validateNetScan: fail the config rather than the turn.
|
||||||
|
func (c *Config) validateTelegram() error {
|
||||||
|
if c.Telegram == nil || !c.Telegram.Intake {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// An unset ${TELEGRAM_*} expands to empty, and the daemon already reads an
|
||||||
|
// empty token or chat id as telegram not being wired at all. Validating a
|
||||||
|
// block that wires nothing would fail a box that merely has no bot.
|
||||||
|
if c.Telegram.BotToken == "" || c.Telegram.ChatID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID)
|
||||||
|
}
|
||||||
|
|
||||||
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
||||||
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
||||||
// a plaintext store. A configured-but-invalid key is an error (fail closed,
|
// a plaintext store. A configured-but-invalid key is an error (fail closed,
|
||||||
|
|||||||
@@ -466,3 +466,29 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
|
|||||||
t.Errorf("Health = %q, want %q", got, want)
|
t.Errorf("Health = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTelegramIntakeRefusesNamedChat(t *testing.T) {
|
||||||
|
// The push half accepts an @channelusername and the intake half cannot use
|
||||||
|
// one, so a box with both boots clean and answers nothing. Refuse the
|
||||||
|
// config instead.
|
||||||
|
p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven","intake":true}}`)
|
||||||
|
if _, err := Load(p); err == nil {
|
||||||
|
t.Fatal("Load succeeded for intake with an @-name chat id; want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramNamedChatOKWithoutIntake(t *testing.T) {
|
||||||
|
// Push-only is what the @-name is for, so nothing changes for a box that
|
||||||
|
// never turned intake on.
|
||||||
|
p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven"}}`)
|
||||||
|
if _, err := Load(p); err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTelegramIntakeAcceptsNumericChat(t *testing.T) {
|
||||||
|
p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"-1001234567890","intake":true}}`)
|
||||||
|
if _, err := Load(p); err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,4 +45,17 @@ func TestDeployConfigLoads(t *testing.T) {
|
|||||||
if cfg.Voice.RouterThreshold <= 0 {
|
if cfg.Voice.RouterThreshold <= 0 {
|
||||||
t.Error("router threshold did not get its default")
|
t.Error("router threshold did not get its default")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The second reach (V-649). Deleting this block is how you turn ntfy off,
|
||||||
|
// so its absence has to be loud: sev3-away nudges and away reminders route
|
||||||
|
// to ntfy and to nothing else, and a nil sink drops them with no log and no
|
||||||
|
// outbox row. The token is a ${VAR} that CI cannot resolve, so this checks
|
||||||
|
// the wiring and not the credential.
|
||||||
|
if cfg.Ntfy == nil {
|
||||||
|
t.Fatal("deploy config has no ntfy block — sev3-away and away reminders " +
|
||||||
|
"would have nowhere to land, and would vanish silently rather than fail")
|
||||||
|
}
|
||||||
|
if cfg.Ntfy.BaseURL == "" || cfg.Ntfy.Topic == "" {
|
||||||
|
t.Errorf("ntfy block is incomplete: base_url=%q topic=%q", cfg.Ntfy.BaseURL, cfg.Ntfy.Topic)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,16 @@ type EmbedderConfig struct {
|
|||||||
ModelPath string `json:"model_path,omitempty"`
|
ModelPath string `json:"model_path,omitempty"`
|
||||||
TokenizerPath string `json:"tokenizer_path,omitempty"`
|
TokenizerPath string `json:"tokenizer_path,omitempty"`
|
||||||
LibPath string `json:"lib_path,omitempty"`
|
LibPath string `json:"lib_path,omitempty"`
|
||||||
|
|
||||||
|
// HeadsPath — the routing heads graph, which is a fine-tuned COPY of the
|
||||||
|
// model above with four linear heads on its pooled output (V-664). Empty
|
||||||
|
// means no heads, and the cascade runs exactly as it did before they
|
||||||
|
// existed. It shares LibPath and TokenizerPath, and router_heads.json is
|
||||||
|
// read from the same directory.
|
||||||
|
//
|
||||||
|
// It must never be pointed at ModelPath. Memory recall depends on the
|
||||||
|
// resident copy scoring what it scored, and the fine-tuned one does not.
|
||||||
|
HeadsPath string `json:"heads_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeatherConfig configures the weather provider for voice queries.
|
// WeatherConfig configures the weather provider for voice queries.
|
||||||
@@ -48,11 +58,17 @@ type WeatherConfig struct {
|
|||||||
// ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is
|
// ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is
|
||||||
// the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that
|
// the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that
|
||||||
// must not fire from the voice path (they need a confirm on an authed surface).
|
// must not fire from the voice path (they need a confirm on an authed surface).
|
||||||
|
//
|
||||||
|
// Aliases are the spoken phrases that reach this tool, Russian included. They
|
||||||
|
// are config data rather than a pattern in code, and they match as exact leading
|
||||||
|
// tokens, so an imperative reaches the tool and the past tense of the same verb
|
||||||
|
// does not.
|
||||||
type ToolConfig struct {
|
type ToolConfig struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
Cmd []string `json:"cmd"`
|
Cmd []string `json:"cmd"`
|
||||||
Destructive bool `json:"destructive,omitempty"`
|
Destructive bool `json:"destructive,omitempty"`
|
||||||
|
Aliases []string `json:"aliases,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice defaults, applied in normaliseVoice.
|
// Voice defaults, applied in normaliseVoice.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -35,12 +36,54 @@ type WorkstationConfig struct {
|
|||||||
// 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than
|
// 0 ⇒ DefaultWorkstationTimeout. A big model on a LAN host is slower than
|
||||||
// the resident one, and a request that overruns falls back to the floor.
|
// the resident one, and a request that overruns falls back to the floor.
|
||||||
Timeout Duration `json:"timeout,omitempty"`
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// Stt — CrisperWhisper 2.0 on the same machine, a separate service on its
|
||||||
|
// own port. Absent ⇒ every utterance goes to mavsttd, which is today.
|
||||||
|
Stt *WorkstationSttConfig `json:"stt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkstationSttConfig — speech-to-text on the workstation.
|
||||||
|
//
|
||||||
|
// It is a second service and not a second endpoint on mavgpud: whisper.cpp
|
||||||
|
// cannot load CrisperWhisper 2.0 at all, because it derives its language count
|
||||||
|
// from the vocabulary size and CW2's 51897 tokens shift seven special token
|
||||||
|
// ids. So CW2 runs under transformers, and this block addresses it.
|
||||||
|
//
|
||||||
|
// Worth the trouble: CW2 turbo scores 10.4% WER in Russian against 27.5% for
|
||||||
|
// the ggml-small.bin homesrv loads
|
||||||
|
// (docs/evals/2026-08-09-crisperwhisper2-russian-wer.md).
|
||||||
|
type WorkstationSttConfig struct {
|
||||||
|
// URL — the transcribe endpoint, e.g.
|
||||||
|
// "http://192.168.1.105:8081/transcribe". Empty ⇒ the block is normalised
|
||||||
|
// to nil and mavsttd takes every turn.
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
|
||||||
|
// Health — the admission endpoint. Empty ⇒ the URL's origin + "/health".
|
||||||
|
// It answers 503 while the card is held, and that is the signal.
|
||||||
|
Health string `json:"health,omitempty"`
|
||||||
|
|
||||||
|
// Token — the bearer token the service checks. Audio is the most sensitive
|
||||||
|
// thing that crosses this seam, so a LAN deployment should set one. Write
|
||||||
|
// it as ${MAVEN_STT_TOKEN} and keep the value in deploy/telegram.env, the
|
||||||
|
// way every other secret in this file is written.
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
|
||||||
|
// Probe — how often admission is re-checked. 0 ⇒ DefaultWorkstationProbe.
|
||||||
|
Probe Duration `json:"probe,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — the per-request budget for one utterance. 0 ⇒
|
||||||
|
// DefaultWorkstationSttTimeout. A request that overruns falls back to
|
||||||
|
// mavsttd, which costs a worse transcript and not the turn.
|
||||||
|
Timeout Duration `json:"timeout,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workstation defaults, applied in normaliseWorkstation.
|
// Workstation defaults, applied in normaliseWorkstation.
|
||||||
const (
|
const (
|
||||||
DefaultWorkstationProbe = 15 * time.Second
|
DefaultWorkstationProbe = 15 * time.Second
|
||||||
DefaultWorkstationTimeout = 90 * time.Second
|
DefaultWorkstationTimeout = 90 * time.Second
|
||||||
|
// One utterance, not one completion. A voice turn waits on this, so the
|
||||||
|
// budget is a few seconds and not a minute and a half.
|
||||||
|
DefaultWorkstationSttTimeout = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// normaliseWorkstation applies the block's defaults. No address, no preferred
|
// normaliseWorkstation applies the block's defaults. No address, no preferred
|
||||||
@@ -63,4 +106,36 @@ func (c *Config) normaliseWorkstation() {
|
|||||||
if w.Timeout <= 0 {
|
if w.Timeout <= 0 {
|
||||||
w.Timeout = Duration(DefaultWorkstationTimeout)
|
w.Timeout = Duration(DefaultWorkstationTimeout)
|
||||||
}
|
}
|
||||||
|
normaliseWorkstationStt(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normaliseWorkstationStt applies the speech-to-text block's defaults. No
|
||||||
|
// address, no remote: mavsttd then takes every utterance, which is today.
|
||||||
|
func normaliseWorkstationStt(w *WorkstationConfig) {
|
||||||
|
if w.Stt != nil && strings.TrimSpace(w.Stt.URL) == "" {
|
||||||
|
w.Stt = nil
|
||||||
|
}
|
||||||
|
if w.Stt == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s := w.Stt
|
||||||
|
if strings.TrimSpace(s.Health) == "" {
|
||||||
|
s.Health = healthOrigin(s.URL)
|
||||||
|
}
|
||||||
|
if s.Probe <= 0 {
|
||||||
|
s.Probe = Duration(DefaultWorkstationProbe)
|
||||||
|
}
|
||||||
|
if s.Timeout <= 0 {
|
||||||
|
s.Timeout = Duration(DefaultWorkstationSttTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthOrigin derives the admission endpoint from the transcribe endpoint.
|
||||||
|
// The URL names a path, so appending to it would ask for /transcribe/health.
|
||||||
|
func healthOrigin(raw string) string {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return strings.TrimRight(raw, "/") + "/health"
|
||||||
|
}
|
||||||
|
return u.Scheme + "://" + u.Host + "/health"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,20 +94,22 @@ func openTestStore(t *testing.T) *store.Store {
|
|||||||
|
|
||||||
// attemptStatus reads one attempt row back. Returns ok=false when the row is
|
// attemptStatus reads one attempt row back. Returns ok=false when the row is
|
||||||
// gone, which would itself be a broken promise (a dropped attempt).
|
// gone, which would itself be a broken promise (a dropped attempt).
|
||||||
|
//
|
||||||
|
// It goes through ListDeliveryAttempts rather than raw SQL. This helper used to
|
||||||
|
// reach past the store into store.DB, which was the tell that the outbox was
|
||||||
|
// write-only; the reader landed in V-390 and this caller was not moved over.
|
||||||
func attemptStatus(t *testing.T, st *store.Store, id int64) (status string, completed bool, ok bool) {
|
func attemptStatus(t *testing.T, st *store.Store, id int64) (status string, completed bool, ok bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
tx, err := st.DB(context.Background())
|
attempts, err := st.ListDeliveryAttempts(context.Background(), "", 200)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read tx: %v", err)
|
t.Fatalf("ListDeliveryAttempts: %v", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
for _, a := range attempts {
|
||||||
var completedTS *int64
|
if a.ID == id {
|
||||||
err = tx.QueryRowContext(context.Background(),
|
return a.Status, a.HasComplete, true
|
||||||
`SELECT status, completed_ts FROM delivery_attempts WHERE id = ?`, id).Scan(&status, &completedTS)
|
}
|
||||||
if err != nil {
|
|
||||||
return "", false, false
|
|
||||||
}
|
}
|
||||||
return status, completedTS != nil, true
|
return "", false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCrashBetweenBeginAndCompleteBecomesUnknown — simulate the crash window:
|
// TestCrashBetweenBeginAndCompleteBecomesUnknown — simulate the crash window:
|
||||||
|
|||||||
@@ -7,11 +7,17 @@
|
|||||||
// the relay). the dispatcher already strips detail off away sendables; the
|
// the relay). the dispatcher already strips detail off away sendables; the
|
||||||
// sink uses the same helper so it can't leak the body on its own either.
|
// sink uses the same helper so it can't leak the body on its own either.
|
||||||
//
|
//
|
||||||
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
|
// ntfy is a self-hosted server with deny-all auth — ntfy.kvmx.ru as of
|
||||||
// with a dedicated user (write-only to maven-* topics) — the credential is a
|
// 07-08-2026, reached directly, not through the socks relay telegram needs.
|
||||||
// delivery-config secret, not a db key; a popped ntfy sink can push spam to
|
// maven publishes with a write-only token scoped to its own topic; the
|
||||||
// your phone, nothing else. matches the module key-isolation invariant: the
|
// credential is a delivery-config secret, not a db key. a popped ntfy sink
|
||||||
// sink never holds the sqlcipher key.
|
// can push spam to that one topic, nothing else — it cannot read the topic
|
||||||
|
// back and it never holds the sqlcipher key.
|
||||||
|
//
|
||||||
|
// this is the second reach, and the reason there is one is that telegram was
|
||||||
|
// the only one (V-649). telegram needs api.telegram.org, a socks relay on the
|
||||||
|
// host and a matching ufw rule, three things in series that have each broken
|
||||||
|
// once. ntfy shares none of them.
|
||||||
package ntfysink
|
package ntfysink
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -31,11 +37,29 @@ import (
|
|||||||
// the credential lives in the daemon's config (or a systemd credential),
|
// the credential lives in the daemon's config (or a systemd credential),
|
||||||
// never in the binary.
|
// never in the binary.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
BaseURL string // e.g. http://127.0.0.1:8085 (no trailing path)
|
// BaseURL — the ntfy server, no trailing path. Required.
|
||||||
Topic string // e.g. maven (all maven notifications land here)
|
BaseURL string `json:"base_url"`
|
||||||
Username string // basic auth; empty = anonymous (won't work with deny-all)
|
|
||||||
Password string // basic auth
|
// Topic — where maven publishes. Required. All maven notifications land
|
||||||
Timeout time.Duration // per-request; 0 = DefaultTimeout
|
// on this one topic; severity rides the Priority header, not the topic.
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
|
||||||
|
// Token — an ntfy access token, sent as a bearer. This is the preferred
|
||||||
|
// credential: ntfy scopes a token to a topic and to write-only, so a
|
||||||
|
// popped sink can push to this one topic and cannot read it back or
|
||||||
|
// touch another. Revoking it does not disturb a password anyone else
|
||||||
|
// uses. Mutually exclusive with Username.
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
|
||||||
|
// Username, Password — basic auth, for a server that has no tokens.
|
||||||
|
// Empty username means no credential is sent at all, which a deny-all
|
||||||
|
// server rejects.
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
|
||||||
|
// Timeout — per-request; 0 = DefaultTimeout. A dead server must not hang
|
||||||
|
// the tick loop.
|
||||||
|
Timeout time.Duration `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const DefaultTimeout = 10 * time.Second
|
const DefaultTimeout = 10 * time.Second
|
||||||
@@ -59,6 +83,12 @@ func New(cfg Config) (*Sink, error) {
|
|||||||
if cfg.Topic == "" {
|
if cfg.Topic == "" {
|
||||||
return nil, fmt.Errorf("ntfysink: Topic is required")
|
return nil, fmt.Errorf("ntfysink: Topic is required")
|
||||||
}
|
}
|
||||||
|
// Refuse rather than pick. Two credentials configured means someone
|
||||||
|
// intended one of them, and guessing which would send the other nowhere
|
||||||
|
// and leave a working config that is not the one they wrote.
|
||||||
|
if cfg.Token != "" && cfg.Username != "" {
|
||||||
|
return nil, fmt.Errorf("ntfysink: set Token or Username, not both")
|
||||||
|
}
|
||||||
to := cfg.Timeout
|
to := cfg.Timeout
|
||||||
if to == 0 {
|
if to == 0 {
|
||||||
to = DefaultTimeout
|
to = DefaultTimeout
|
||||||
@@ -84,7 +114,9 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Title", "maven")
|
req.Header.Set("Title", "maven")
|
||||||
req.Header.Set("Priority", priorityFor(d).String())
|
req.Header.Set("Priority", priorityFor(d).String())
|
||||||
if s.cfg.Username != "" {
|
if s.cfg.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.cfg.Token)
|
||||||
|
} else if s.cfg.Username != "" {
|
||||||
req.SetBasicAuth(s.cfg.Username, s.cfg.Password)
|
req.SetBasicAuth(s.cfg.Username, s.cfg.Password)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -224,6 +224,37 @@ func TestSendNoAuthWhenUsernameEmpty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSendSetsBearerToken — the deployed credential (V-649) is an ntfy access
|
||||||
|
// token scoped write-only to the maven topic, not a password. A token sent as
|
||||||
|
// basic auth is rejected by ntfy, so the header shape is the whole test.
|
||||||
|
func TestSendSetsBearerToken(t *testing.T) {
|
||||||
|
rs := newRecordingServer(t, 200, "")
|
||||||
|
srv := httptest.NewServer(rs.handler())
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven", Token: "tk_secret"})
|
||||||
|
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
_, _, _, auth, _, _ := rs.snapshot()
|
||||||
|
if auth != "Bearer tk_secret" {
|
||||||
|
t.Fatalf("auth: want 'Bearer tk_secret', got %q", auth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewRejectsBothCredentials — configuring a token and a username means one
|
||||||
|
// of them was meant and the other is a leftover. Picking either would leave a
|
||||||
|
// server that authenticates against a credential nobody wrote down.
|
||||||
|
func TestNewRejectsBothCredentials(t *testing.T) {
|
||||||
|
_, err := New(Config{BaseURL: "http://x", Topic: "maven", Token: "tk_x", Username: "maven"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("New accepted both a token and a username")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "not both") {
|
||||||
|
t.Errorf("error does not say which to fix: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendTitleIsMaven(t *testing.T) {
|
func TestSendTitleIsMaven(t *testing.T) {
|
||||||
rs := newRecordingServer(t, 200, "")
|
rs := newRecordingServer(t, 200, "")
|
||||||
srv := httptest.NewServer(rs.handler())
|
srv := httptest.NewServer(rs.handler())
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user