Compare commits
208 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c15c2b7bd2 | |||
| b6eaa704a2 | |||
| 2597a7b34a | |||
| 7203cd56fd | |||
| ab3e818bb9 | |||
| b5500a5be8 | |||
| 9095ac847d | |||
| 4b5f6adbae | |||
| ecb8ba72eb | |||
| 4fdecf9a25 | |||
| 2bbd8edbf6 | |||
| beb093aebb | |||
| b1b326018f | |||
| 08889cad88 | |||
| a4630b9314 | |||
| 39d44bb384 | |||
| 65ee0f9c61 | |||
| 76938e206d | |||
| 0b3d81ecbf | |||
| 4be6852b94 | |||
| f7b76c572f | |||
| 05ddc5c92e | |||
| b55e68f98d | |||
| beaa24754c | |||
| aed8cac439 | |||
| af4eeceb6a | |||
| 7b507dec94 | |||
| 2c0334c4fe | |||
| 92cbdbfdd3 | |||
| e78b2d8992 | |||
| 9d58922462 | |||
| b5ac48c126 | |||
| 69d0f5ee78 | |||
| 661b5c1099 | |||
| ff70637a0d | |||
| 06c1cf247e | |||
| 400653810e | |||
| b3936348f5 | |||
| c61b0b3968 | |||
| 0a5211b038 | |||
| 38be702188 | |||
| d42372e996 | |||
| 45231ba69e | |||
| 42c7b8b927 | |||
| e5a1db995d | |||
| d32eae8aac | |||
| 63b645b405 | |||
| 0e82cb442f | |||
| d94ed2e630 | |||
| c8f74c39d6 | |||
| 44b8793e2f | |||
| a4b4733767 | |||
| 8f168ab811 | |||
| eb129c2fad | |||
| 0d5bd0a9f0 | |||
| 4d97280d74 | |||
| e5ec4abe04 | |||
| 7688dfde66 | |||
| e1f84a3474 | |||
| 7852aad60f | |||
| 034d4b4359 | |||
| 799cf5587d | |||
| c1b781fac0 | |||
| 7b2b9d479a | |||
| 92de4ae496 | |||
| a0293bac85 | |||
| c1d9a4547b | |||
| 6499f6365e | |||
| 97e1a44c1a | |||
| 1b3af05d0a | |||
| e7ecce2859 | |||
| 1f8e9f21ce | |||
| e7537d032e | |||
| 2b3e34c7e8 | |||
| dde556a3d3 | |||
| b86172a98d | |||
| 22edc3cdfb | |||
| 4f6dec0cf2 | |||
| 23ad5c0247 | |||
| 0445693a16 | |||
| c7d22858ba | |||
| 0b994ff1c3 | |||
| 12ecc30c57 | |||
| 190cf0c794 | |||
| 6b3749f5a2 | |||
| d156be3442 | |||
| f29bc107d4 | |||
| 10975eff07 | |||
| cc48309c7c | |||
| 4534101d10 | |||
| 5b8707e21e | |||
| 76d123edf3 | |||
| 62675e8fe4 | |||
| 46acf3cba0 | |||
| 373229ab7a | |||
| 2dbf476c45 | |||
| 13cb1903a9 | |||
| 8d20efcfbb | |||
| c661f7bd1a | |||
| e5158d8828 | |||
| 07f7550931 | |||
| 8c1e457150 | |||
| 6923a983aa | |||
| 1d10c9535c | |||
| 3b3660da9a | |||
| dd699b706f | |||
| 0b1efe4911 | |||
| 580959f856 | |||
| bf2587c7fa | |||
| 26ff646ace | |||
| 9e1958e7b0 | |||
| e6923490fd | |||
| d7a43afd90 | |||
| fabc3bc274 | |||
| b6eed20af2 | |||
| 936c6d71db | |||
| 72aa97dae8 | |||
| 1c0a1d0db0 | |||
| 37feee1eb3 | |||
| 94c273780a | |||
| 93c08f9de1 | |||
| 4f96bbd6ec | |||
| 7dba1b7935 | |||
| 8102c73f83 | |||
| 8c36e7ef84 | |||
| 95cbf82e38 | |||
| 5bca435146 | |||
| 69270f4cfb | |||
| 01230bf16b | |||
| ebce90b984 | |||
| 04584fb2da | |||
| 5447f08c06 | |||
| 650363ce67 | |||
| 85456d3833 | |||
| 901354002e | |||
| f4a021d3da | |||
| 316fb197a8 | |||
| 67decc42f0 | |||
| 201fe03d20 | |||
| fec572c997 | |||
| 5187f3bd14 | |||
| 1b5d093148 | |||
| 502327678f | |||
| 5d2fd91c06 | |||
| 33c2d782a9 | |||
| e8ece874b1 | |||
| 1fa14e95a4 | |||
| dd6da78aeb | |||
| d5d4166710 | |||
| 6ec4220668 | |||
| 59cc882265 | |||
| be18649953 | |||
| 41d3a4a903 | |||
| 188d9fc02f | |||
| 5a85d37fa5 | |||
| d70cb7e9ab | |||
| c0aee1558f | |||
| 0d49745a17 | |||
| 2063f8e770 | |||
| 173531be8c | |||
| 01c78ef369 | |||
| bac8673f05 | |||
| 5cc51c5b6e | |||
| be869c6a48 | |||
| 8559f1f450 | |||
| b908c39e45 | |||
| 8c774abe5b | |||
| e2777177b0 | |||
| b8250a8711 | |||
| 02ce730cb2 | |||
| aaf1f0236b | |||
| e488ee2285 | |||
| b6680398c3 | |||
| 0feb8d3dbd | |||
| 0886662360 | |||
| 439ceb5d8e | |||
| ecaf3407fa | |||
| b60264701c | |||
| 60540fa934 | |||
| db8cbdc20a | |||
| 91f6ea84a0 | |||
| b726658692 | |||
| 5f5f14eba7 | |||
| 59b98d5c4d | |||
| 59210cf07d | |||
| 828e034c96 | |||
| c699c139b4 | |||
| b705a786ef | |||
| 20fe909f76 | |||
| ceba69c4bb | |||
| 95eeef13dd | |||
| cfbef45feb | |||
| 3bb82a90db | |||
| abbf0fe60d | |||
| 594bfc2bc3 | |||
| 795ecf67a5 | |||
| 69eda3ceee | |||
| 69a6eb0fb9 | |||
| d457c97355 | |||
| 1b6d51dc71 | |||
| 40152e3688 | |||
| 7f411656c2 | |||
| 9ea178be99 | |||
| 9ef6f286a9 | |||
| 7414ef4c39 | |||
| 231a4e00f6 | |||
| 2acfeb4453 | |||
| 1c20df70f8 |
@@ -53,21 +53,32 @@ CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored to
|
||||
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
|
||||
|
||||
```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 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
|
||||
CGO_CFLAGS="-I$(pwd)/deps/include -I$(pwd)/deps/whisper.cpp/ggml/include" \
|
||||
CGO_LDFLAGS="-L$(pwd)/deps/lib -Wl,-rpath,$(pwd)/deps/lib" \
|
||||
LD_LIBRARY_PATH="$(pwd)/deps/lib" \
|
||||
deps/go/go/bin/go test -run TestName ./internal/router/
|
||||
make t PKG=./internal/router/
|
||||
make t PKG=./cmd/mavend/ RUN=TestSimulator
|
||||
make t PKG=./internal/router/eval/ RUN='TestONNX' V=1 # V=1 for -v, RACE=0 to drop -race
|
||||
```
|
||||
|
||||
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/`)
|
||||
|
||||
@@ -79,16 +90,40 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
||||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||||
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
|
||||
| `mavpoll` | Telegram long-poll reach. |
|
||||
| `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. |
|
||||
| `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
|
||||
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.
|
||||
|
||||
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
|
||||
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
||||
**`docker-compose.yml` runs five: `mavend`, `mavsttd`, `mavttsd`, `mavweb`, `mavpoll`.**
|
||||
Count against compose, not against the table. Four of the nine daemons are absent, and each
|
||||
absence has a different reason.
|
||||
|
||||
`mavmaild` and `mavcaldav` are commented out in compose, each with the reason written
|
||||
beside it: the first needs a mail account, the second a CalDAV account, and this box has
|
||||
neither. `mavcaldav` used to appear nowhere at all, which was an oversight; it became a
|
||||
recorded decision on 07-08-2026 (V-644). Two things ride on that absence and the block
|
||||
names them. Agenda questions route to `IntentQuery` at stage 0 (V-498) and the `calendar`
|
||||
query source then reads a table nobody writes. And `loop.State.CalendarBusy` is fed by the
|
||||
same facts, so the gate's "do not nag mid-meeting" is permanently false. Its password is
|
||||
read from a file (`-pass-file`, and `-render-pass-file` for the render collection), never
|
||||
taken as a flag value, which is the rule `mavpoll` and `mavmaild` follow too.
|
||||
|
||||
**`mavwaked` and `mavenclient` are absent by decision, not oversight** (Vikunja #463,
|
||||
`docs/plans/17-where-the-voice-loop-runs.md`).
|
||||
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
|
||||
daemon there listens to nobody. They belong on a client machine where the owner is standing.
|
||||
|
||||
@@ -285,11 +320,123 @@ site cannot change a route and a context with no record costs nothing. It is
|
||||
installed in `runTurn`, so the mic, telegram and the web all leave the same
|
||||
trail. Storage is a 25-turn in-memory ring on the handler (`decision.Ring`),
|
||||
read over `ipc.TurnDecisions` and rendered as the second table on `/trace`.
|
||||
Nothing persists: a turn record is read minutes later or never, and his words do
|
||||
not belong in a table that outlives the diagnosis. Adding a rung to the ladder
|
||||
**It also persists, since 06-08-2026, and that reverses what this section used to
|
||||
say** (V-629, `docs/plans/21-persisting-the-routing-trace.md`). The old rule was
|
||||
that nothing persists, because a turn record is read minutes later or never. The
|
||||
owner reversed it: the routing heads (V-546) cannot be fitted or calibrated
|
||||
without real utterances, and 9 of the 31 modes in `internal/modes` have no seed
|
||||
example at all. The ring did not move. It is still what `/trace` reads and still
|
||||
what a test with no store gets. `cmd/mavend/routingtrace.go` is a second sink
|
||||
beside it, writing `routing_traces` (migration #23). The utterance is stored in
|
||||
clear, because a 384-dimension vector of a short sentence is substantially
|
||||
recoverable and storing vectors instead would be a privacy claim we cannot
|
||||
support. What makes it safe is the same thing that makes the fact store safe.
|
||||
Retention is 14 days, enforced on write and again on start, so a box that goes
|
||||
quiet does not keep every row. Nothing reads it outward, and the rule
|
||||
that his notes and facts are never search input covers this table. `Store.Wipe`
|
||||
deletes it with everything else. A correction (V-630) is promoted out into a
|
||||
seed-shaped row in `routing_labels` (migration #24) and kept, because a label is
|
||||
not a transcript. The transcript still expires. The gesture that writes one is
|
||||
two buttons beside the reply on `/chat`, reached over `ipc.CorrectTurn` and the
|
||||
trace id that now rides back on `ipc.ChatReply`. A turn marked wrong with no
|
||||
target is a usable negative, so naming the intent is never required. The target
|
||||
is one of the seven intents and never free text. **All three reaches offer it as
|
||||
of 06-08-2026**, and this section used to say only `/chat` did. Voice is the
|
||||
`repair` rung, which has read spoken corrections since V-455 and now writes the
|
||||
durable label beside the classifier seed it always wrote; a spoken negative with
|
||||
no target is its own rung, `repair-negative` (V-636, `docs/plans/22-correcting-a-turn.md`).
|
||||
Telegram is an inline keyboard under the reply, and it needed the chat to become
|
||||
readable first — **telegram is no longer outbound only** (V-637,
|
||||
`docs/plans/23-inbound-telegram.md`). The poller is dark unless the `telegram`
|
||||
block says `intake`, it long-polls because the box takes no inbound connections,
|
||||
it accepts `chat_id` and no other sender, and it drops whatever queued while the
|
||||
daemon was down. It reaches the daemon through `ipc.CoreAPI` alone, so a chat
|
||||
turn takes the path `POST /api/chat` takes. Note that the turn source is still
|
||||
`tap:text` for both, so provenance cannot tell a chat turn from a typed one.
|
||||
Adding a rung to the ladder
|
||||
in `runTurn` means adding its name to `preRouteLadder` in
|
||||
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
|
||||
|
||||
**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. His notes, his facts and the personal boundary still run first.
|
||||
|
||||
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 six of them are homelab operations. They
|
||||
cluster because `SourceRecall`, `SourceNetwork` and `SourceAttention` overlap on
|
||||
every question about the box. `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.
|
||||
|
||||
`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 is still the follow-up. It lands on 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.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when
|
||||
@@ -404,8 +551,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")
|
||||
```
|
||||
|
||||
`update_task` carrying a `description` resets `done` to false, so closing a task with a
|
||||
write-up takes two calls: the description, then `done: true`.
|
||||
**Close a finished task with `done: true` and nothing else** (owner's call, 07-08-2026).
|
||||
Do not write a completion summary into the description on the way out. It is lost anyway,
|
||||
and the durable record is the commit messages and the merged PR. Note that `update_task`
|
||||
carrying a `description` resets `done` to false, which is why a write-up ever took two
|
||||
calls.
|
||||
|
||||
## Session workflow
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
|
||||
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
|
||||
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
|
||||
|
||||
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go deps-sentinel tidy eval-router eval-reach eval-recall eval-phrasing eval-models build-gpud
|
||||
.PHONY: t audit simulate stt-fixtures test-stt-golden 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
|
||||
|
||||
@@ -128,6 +128,35 @@ test: fmt-check vet
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
|
||||
|
||||
# t — run ONE package or ONE test with the toolchain env already wired. This is
|
||||
# the iteration target; `test` is the gate. Reach for it instead of pasting the
|
||||
# CGO_CFLAGS/CGO_LDFLAGS/LD_LIBRARY_PATH preamble by hand, which is how it was
|
||||
# done ~390 times across past sessions and is where the shell-quoting failures
|
||||
# came from -- the interactive shell here is zsh, and an unquoted `-run Test*`
|
||||
# or `--include=*.go` dies on "no matches found" before go ever starts.
|
||||
#
|
||||
# make t # whole tree (same scope as `test`)
|
||||
# make t PKG=./internal/router/
|
||||
# make t PKG=./cmd/mavend/ RUN=TestSimulator
|
||||
# make t PKG=./internal/router/eval/ RUN='TestONNX' V=1
|
||||
# make t PKG=./internal/store/ RACE=0 # drop -race when iterating hot
|
||||
#
|
||||
# -race is on by default so a green `make t` cannot turn red under `make test`.
|
||||
# -count=1 because a cached PASS from before your edit is worse than no answer.
|
||||
# MAVEN_ONNX_LIB is set for the same reason: the four TestONNX* measurements
|
||||
# self-skip when it is unset, so a targeted eval run would otherwise report the
|
||||
# deterministic hash ratchet and look like it scored the real embedder.
|
||||
PKG ?= ./internal/... ./cmd/...
|
||||
RUN ?=
|
||||
V ?=
|
||||
RACE ?= 1
|
||||
|
||||
t:
|
||||
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
|
||||
MAVEN_ONNX_LIB="$(MAVEN_ONNX_LIB)" \
|
||||
$(GO) test $(if $(V),-v,) $(if $(filter-out 0,$(RACE)),-race,) -count=1 \
|
||||
$(if $(RUN),-run '$(RUN)',) $(PKG)
|
||||
|
||||
# eval-router — score the held-out RU routing fixture (internal/router/eval).
|
||||
# Verbose so the report tables land in the terminal. MAVEN_ONNX_LIB points the
|
||||
# prod-representative baseline at the vendored runtime; override it or set it
|
||||
@@ -189,6 +218,16 @@ eval-models:
|
||||
# scores the fixtures against ggml-small and self-skips when the model is
|
||||
# absent, and TestGoldenFixturesAreCanonical, which checks the committed audio
|
||||
# and the manifest with no model at all.
|
||||
# audit — the repo inventory: LOC per package, open TODOs, real stubs, living-doc
|
||||
# staleness, test shape, packages with no test. Read-only, prints, writes nothing.
|
||||
# Run it instead of rebuilding the same greps by hand; past sessions spent 93 of
|
||||
# them on this before their first edit. SECTION=loc|todo|stubs|docs|tests|gaps
|
||||
# narrows it.
|
||||
SECTION ?= all
|
||||
|
||||
audit:
|
||||
@SECTION="$(SECTION)" ./scripts/audit.sh
|
||||
|
||||
stt-fixtures:
|
||||
./scripts/gen-stt-fixtures.sh
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Command labelgen labels utterances with the stage 0 grammars and prints JSONL.
|
||||
//
|
||||
// docs/plans/18-routing-heads-on-e5-small.md calls the labeled set the whole
|
||||
// project, and it names the stage 0 grammars as the high-precision label
|
||||
// functions to start from. This runs them — the real ones, in the real
|
||||
// buildRouter order — rather than a reimplementation, so a rule change moves
|
||||
// the training data with it.
|
||||
//
|
||||
// A grammar that declines leaves the line unlabeled. Those go to the model, and
|
||||
// keeping them is the point: a set labeled only by the rules teaches only the
|
||||
// rules.
|
||||
//
|
||||
// go run ./cmd/labelgen < utterances.txt > labeled.jsonl
|
||||
//
|
||||
// The wakeword-act grammar is absent, because its allowlist is the deployment's
|
||||
// enabled tool names and this tool has no deployment. Every other rule is here.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// label is one output row. The grammar name rides along so a reviewer can see
|
||||
// which rule made the claim, and so a rule that turns out to be wrong can have
|
||||
// its rows pulled without re-running everything.
|
||||
type label struct {
|
||||
Utterance string `json:"utterance"`
|
||||
Intent string `json:"intent,omitempty"`
|
||||
Grammar string `json:"grammar,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
Fn string `json:"fn,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Labeled bool `json:"labeled"`
|
||||
}
|
||||
|
||||
// grammars mirrors buildRouter's order in cmd/mavend/voicewire.go. Order is
|
||||
// load-bearing there and so it is here: the agenda rules must sit after the
|
||||
// clock rules, Praxis before the capture marker, the narrative rules last.
|
||||
func grammars() []router.Grammar {
|
||||
var g []router.Grammar
|
||||
g = append(g, router.SystemTimeDateGrammars()...)
|
||||
g = append(g, router.AgendaQueryGrammars()...)
|
||||
g = append(g, router.FeedQueryGrammar())
|
||||
g = append(g, router.TaskListGrammar())
|
||||
g = append(g, router.ListGrammars()...)
|
||||
g = append(g, router.ReminderGrammar())
|
||||
g = append(g, router.PraxisGrammars()...)
|
||||
g = append(g, router.TaskCaptureGrammar())
|
||||
g = append(g, router.NarrativeQueryGrammars()...)
|
||||
return g
|
||||
}
|
||||
|
||||
func match(gs []router.Grammar, utterance string) label {
|
||||
out := label{Utterance: utterance}
|
||||
for _, g := range gs {
|
||||
m := g.Pattern.FindStringSubmatch(utterance)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
d, ok := g.Build(m)
|
||||
if !ok {
|
||||
continue // the rule saw its shape and declined it
|
||||
}
|
||||
out.Intent = string(d.Intent)
|
||||
out.Grammar = g.Name
|
||||
out.Key = d.Slots.Key
|
||||
out.Value = d.Slots.Value
|
||||
out.Fn = d.Slots.Fn
|
||||
out.Text = d.Slots.Text
|
||||
out.Labeled = true
|
||||
return out
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
gs := grammars()
|
||||
in := bufio.NewScanner(os.Stdin)
|
||||
in.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
out := bufio.NewWriter(os.Stdout)
|
||||
defer out.Flush()
|
||||
|
||||
enc := json.NewEncoder(out)
|
||||
var seen, labeled int
|
||||
for in.Scan() {
|
||||
line := strings.TrimSpace(in.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
l := match(gs, line)
|
||||
if l.Labeled {
|
||||
labeled++
|
||||
}
|
||||
if err := enc.Encode(l); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
if err := in.Err(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "labelgen:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Coverage on stderr, so the count is visible without polluting the JSONL.
|
||||
fmt.Fprintf(os.Stderr, "labelgen: %d/%d labeled by %d grammars\n", labeled, seen, len(gs))
|
||||
}
|
||||
+41
-9
@@ -50,10 +50,10 @@ func run(args []string) error {
|
||||
socket := fs.String("socket", "", "core IPC socket path (required)")
|
||||
url := fs.String("url", "", "CalDAV calendar URL, e.g. http://localhost:5232/kami/personal (required)")
|
||||
user := fs.String("user", "", "CalDAV basic-auth username (required)")
|
||||
pass := fs.String("pass", "", "CalDAV basic-auth password (required)")
|
||||
passFile := fs.String("pass-file", "", "file holding the CalDAV basic-auth password (required — never passed as a flag value)")
|
||||
renderURL := fs.String("render-url", "", "CalDAV collection maven publishes her own reminders to; empty disables rendering")
|
||||
renderUser := fs.String("render-user", "", "basic-auth username for -render-url (defaults to -user)")
|
||||
renderPass := fs.String("render-pass", "", "basic-auth password for -render-url (defaults to -pass)")
|
||||
renderPassFile := fs.String("render-pass-file", "", "file holding the password for -render-url (defaults to -pass-file)")
|
||||
renderDur := fs.Duration("render-duration", calendar.DefaultReminderDuration, "how long a rendered reminder occupies")
|
||||
interval := fs.Duration("interval", 5*time.Minute, "poll cadence")
|
||||
timeout := fs.Duration("timeout", 10*time.Second, "per-request HTTP timeout")
|
||||
@@ -63,13 +63,22 @@ func run(args []string) error {
|
||||
if *socket == "" {
|
||||
return fmt.Errorf("-socket is required")
|
||||
}
|
||||
if *url == "" || *user == "" || *pass == "" {
|
||||
return fmt.Errorf("-url, -user, -pass are required")
|
||||
if *url == "" || *user == "" || *passFile == "" {
|
||||
return fmt.Errorf("-url, -user, -pass-file are required")
|
||||
}
|
||||
if err := checkRenderTarget([]string{*url}, *renderURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// The password is read from a file, never taken as a flag value: an argv
|
||||
// secret is visible in `ps` to every user on the box and lands in the compose
|
||||
// file and the shell history. Same rule mavmaild and mavpoll follow. Read
|
||||
// once at start, so a rotated password means a restart.
|
||||
pass, err := readSecret(*passFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
@@ -85,17 +94,20 @@ func run(args []string) error {
|
||||
http: hc,
|
||||
url: strings.TrimRight(*url, "/"),
|
||||
user: *user,
|
||||
pass: *pass,
|
||||
pass: pass,
|
||||
}
|
||||
|
||||
var rend *renderer
|
||||
if *renderURL != "" {
|
||||
ru, rp := *renderUser, *renderPass
|
||||
ru, rp := *renderUser, pass
|
||||
if ru == "" {
|
||||
ru = *user
|
||||
}
|
||||
if rp == "" {
|
||||
rp = *pass
|
||||
if *renderPassFile != "" {
|
||||
rp, err = readSecret(*renderPassFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
rend = newRenderer(core, hc, *renderURL, ru, rp, *renderDur)
|
||||
log.Printf("mavcaldav: rendering reminders to %s", *renderURL)
|
||||
@@ -131,6 +143,21 @@ func run(args []string) error {
|
||||
// It takes the whole read set, not one URL. The guarantee in the package
|
||||
// comment is about every calendar maven reads, and a second read target added
|
||||
// later must not quietly fall outside the check.
|
||||
// readSecret reads one credential from a file and refuses an empty one. An
|
||||
// empty file is a deployment mistake, not a password, and CalDAV basic auth
|
||||
// would send it and get a 401 every poll.
|
||||
func readSecret(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read password file: %w", err)
|
||||
}
|
||||
secret := strings.TrimSpace(string(raw))
|
||||
if secret == "" {
|
||||
return "", fmt.Errorf("password file %s is empty", path)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
func checkRenderTarget(readURLs []string, renderURL string) error {
|
||||
if renderURL == "" {
|
||||
return nil
|
||||
@@ -188,6 +215,11 @@ func (p *poller) pollOnce(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// maxResponseBody bounds every CalDAV response this daemon reads (the poller's
|
||||
// GET and the renderer's PROPFIND) — a misbehaving or malicious server gets a
|
||||
// truncated read, not an unbounded one.
|
||||
const maxResponseBody = 4 << 20
|
||||
|
||||
// fetchEvents GETs the calendar URL and parses VEVENTs from the iCal response.
|
||||
func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Event, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.url, nil)
|
||||
@@ -203,7 +235,7 @@ func (p *poller) fetchEvents(ctx context.Context, now time.Time) ([]calendar.Eve
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,12 +5,38 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// The password comes from a file so it never reaches argv. An empty or missing
|
||||
// file must fail at start rather than authenticate as "" against his calendar.
|
||||
func TestReadSecret(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
good := filepath.Join(dir, "ok")
|
||||
if err := os.WriteFile(good, []byte(" hunter2\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := readSecret(good); err != nil || got != "hunter2" {
|
||||
t.Fatalf("readSecret(good) = %q, %v; want \"hunter2\", nil", got, err)
|
||||
}
|
||||
|
||||
empty := filepath.Join(dir, "empty")
|
||||
if err := os.WriteFile(empty, []byte("\n \n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := readSecret(empty); err == nil {
|
||||
t.Fatal("readSecret(empty) = nil error, want refusal")
|
||||
}
|
||||
if _, err := readSecret(filepath.Join(dir, "absent")); err == nil {
|
||||
t.Fatal("readSecret(absent) = nil error, want refusal")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
facts map[string]ipc.Fact // composite key "key|source" → Fact
|
||||
|
||||
@@ -142,7 +142,7 @@ func (r *renderer) listPublished(ctx context.Context) ([]int64, error) {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -31,15 +31,11 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/audio"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
@@ -155,9 +151,3 @@ func writeWAV(path string, a audio.Audio) error {
|
||||
// jsonUnmarshal — kept local rather than pulling encoding/json into main.go
|
||||
// top-level space.
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// keep strconv + io + net + time alive for future duration/size helpers.
|
||||
var _ = strconv.Atoi
|
||||
var _ io.Reader = (io.Reader)(nil)
|
||||
var _ = net.IPv4
|
||||
var _ = time.Second
|
||||
|
||||
@@ -60,6 +60,17 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
|
||||
phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args)
|
||||
h.park(dec.Slots.Fn, dec.Slots.Args, phrase)
|
||||
return phraser.A(phraser.ActConfirm, map[string]string{"name": phrase})
|
||||
case errors.Is(err, tool.ErrUnknownTarget):
|
||||
// The verb reached a tool and the tail did not reach a target, so
|
||||
// nothing ran. Saying which word she could not place is the whole
|
||||
// answer: he either renames it or gives the row an alias that
|
||||
// carries the target, and both are one turn away (V-634).
|
||||
word := ""
|
||||
var unknown *tool.UnknownTargetError
|
||||
if errors.As(err, &unknown) {
|
||||
word = unknown.Target
|
||||
}
|
||||
return phraser.A(phraser.ActUnknownTarget, map[string]string{"name": word})
|
||||
case errors.Is(err, tool.ErrNeedsAuthedSurface):
|
||||
// Irreversible (internal/tool/risk.go). A confirm turn would not
|
||||
// help: everything that proposed this act — the STT, the router,
|
||||
|
||||
@@ -9,9 +9,24 @@ import (
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// nothingToCorrectReply — what she says to a correction that points at
|
||||
// nothing. Filing it would put a sentence in his memory that reads as a fact.
|
||||
const nothingToCorrectReply = "не поняла, что поправить. скажи целиком, и я запишу."
|
||||
|
||||
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||
// index it for recall.
|
||||
//
|
||||
// The stored body is dec.Utterance and nothing else (V-576). It is not
|
||||
// Slots.Text, not phraser output and not any other model string: a note is
|
||||
// durable, the embedder indexes it, and it comes back later as recall in his
|
||||
// own words. Phrasing belongs in the spoken confirmation.
|
||||
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||
// A correction with no referent. Everything that could own one has already
|
||||
// run by here: clarify, confirm and repair are all resolved before routing,
|
||||
// so a fragment reaching the note path has nothing behind it (V-576).
|
||||
if correctionFragment(dec.Utterance) {
|
||||
return nothingToCorrectReply
|
||||
}
|
||||
// An utterance that explicitly files a task is work, not recall, and
|
||||
// belongs in the task store (Vikunja #130). Checked before the embedding
|
||||
// is paid for. Everything else is a note, exactly as before.
|
||||
|
||||
+165
-49
@@ -59,6 +59,26 @@ type querySource struct {
|
||||
// sources search text with no notion of a day. When one of them grows a
|
||||
// date parameter, flip its flag here.
|
||||
dateAware bool
|
||||
|
||||
// dest — the destination this source serves, when the cascade named one
|
||||
// (V-655). Several sources share a destination: the three recall passes and
|
||||
// the fact-by-key lookup are all SourceRecall, because which of them lands
|
||||
// the hit is an ordering detail no utterance can name. A source with no
|
||||
// dest is reachable only by walking the chain.
|
||||
dest router.Source
|
||||
|
||||
// guesses — this source decides whether the turn is its own by scoring the
|
||||
// utterance against frozen seeds, rather than by looking something up and
|
||||
// coming back empty.
|
||||
//
|
||||
// The distinction is the whole point of the field. A source that looks can
|
||||
// be wrong about relevance and still harmless, because the miss shows up as
|
||||
// no rows. A source that guesses answers whatever it claims: weather has no
|
||||
// local table to miss against, so "что такое TCP?" became "для какого
|
||||
// города?". So when the cascade names a destination, the guessers that were
|
||||
// not named do not get to try. The lookups still run, because a named
|
||||
// destination is evidence and not a promise.
|
||||
guesses bool
|
||||
}
|
||||
|
||||
// querySources is the ordered chain actionQuery walks; first source to claim
|
||||
@@ -67,85 +87,85 @@ type querySource struct {
|
||||
// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one
|
||||
// line here plus its method; where you put the line is the whole decision.
|
||||
var querySources = []querySource{
|
||||
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey},
|
||||
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey, dest: router.SourceRecall},
|
||||
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
||||
// the more specific ask (its matcher requires a plan word), so the calendar
|
||||
// listing would otherwise swallow it.
|
||||
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan},
|
||||
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan, dest: router.SourceCalendar},
|
||||
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||||
// and the habit question is the more specific one. Its matcher requires a
|
||||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||||
// Wednesday still reaches the calendar.
|
||||
{name: "habits", answer: (*reactiveHandler).queryHabits},
|
||||
{name: "habits", answer: (*reactiveHandler).queryHabits, dest: router.SourceCalendar},
|
||||
// Before "calendar" and before the recall sources: "что мне нужно
|
||||
// сделать?" is a question about the task list, and the notes pass would
|
||||
// otherwise answer it with whatever note happens to be nearest. Its
|
||||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||||
// date-bearing question still reaches the calendar.
|
||||
{name: "tasks", answer: (*reactiveHandler).queryTasks},
|
||||
{name: "tasks", answer: (*reactiveHandler).queryTasks, dest: router.SourceTasks},
|
||||
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
||||
// question about the operational state Praxis holds, and it used to fall
|
||||
// through every source to the web search (Vikunja #475). Its matcher needs
|
||||
// an attention marker, and it falls through when Praxis is not configured.
|
||||
{name: "attention", answer: (*reactiveHandler).queryAttention},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{name: "attention", answer: (*reactiveHandler).queryAttention, dest: router.SourceAttention, guesses: true},
|
||||
// Next to "tasks" and for the same reason: "что мне купить?" is a question
|
||||
// about the shopping list, and the recall pass would otherwise answer it
|
||||
// from an old note about the shop. Its matcher needs an explicit list
|
||||
// marker, so "надо бы съездить в магазин" is untouched.
|
||||
{name: "list", answer: (*reactiveHandler).queryList},
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney},
|
||||
{name: "list", answer: (*reactiveHandler).queryList, dest: router.SourceList, guesses: true},
|
||||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||||
// the money facts the poller wrote, and the notes pass would otherwise
|
||||
// answer it from whatever he once said about spending. Its matcher needs a
|
||||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||||
{name: "money", answer: (*reactiveHandler).queryMoney, dest: router.SourceMoney},
|
||||
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||||
// the facts he tapped in, and the notes pass would answer it with whatever
|
||||
// note is nearest (Vikunja #456). Its matcher needs both halves of a
|
||||
// history phrase and bails out when he names a topic, so "что я говорил
|
||||
// про сервер" is still recall.
|
||||
{name: "history", answer: (*reactiveHandler).queryHistory},
|
||||
{name: "history", answer: (*reactiveHandler).queryHistory, dest: router.SourceRecall},
|
||||
// Before the recall sources and before general knowledge: "что нового?" is
|
||||
// a question about the feeds she reads, and general knowledge would answer
|
||||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||||
// "у меня новая лента в инстаграме" is untouched.
|
||||
{name: "feeds", answer: (*reactiveHandler).queryFeeds},
|
||||
{name: "feeds", answer: (*reactiveHandler).queryFeeds, dest: router.SourceFeeds, guesses: true},
|
||||
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||||
// a question about the house, and the notes pass would otherwise answer it
|
||||
// from whatever he once said about the lights. Its matcher needs a house
|
||||
// marker plus an ask plus a device word, and it bails out on weather
|
||||
// wording, so "какая температура на улице?" still reaches the weather
|
||||
// source.
|
||||
{name: "home", answer: (*reactiveHandler).queryHome},
|
||||
{name: "home", answer: (*reactiveHandler).queryHome, dest: router.SourceHome, guesses: true},
|
||||
// Next to "home" and for the same reason: "какие устройства в сети?" is a
|
||||
// question about the LAN, and the recall pass would otherwise answer it
|
||||
// from an old note about the router. Its matcher needs a network word plus
|
||||
// an ask plus a device noun, so "интернет не работает" is untouched.
|
||||
{name: "network", answer: (*reactiveHandler).queryNetwork},
|
||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
||||
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
||||
{name: "network", answer: (*reactiveHandler).queryNetwork, dest: router.SourceNetwork, guesses: true},
|
||||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true, dest: router.SourceCalendar},
|
||||
{name: "weather", answer: (*reactiveHandler).queryWeather, dest: router.SourceWeather, guesses: true},
|
||||
// A question about her, above the three sources that search his own data
|
||||
// (Vikunja #555). It has no answer anywhere else: below the boundary
|
||||
// SearXNG answers about somebody else's assistant, and above it his notes
|
||||
// answer by proximity — "кто ты" came back from a note of his, measured on
|
||||
// the box, because the recall index has no idea the subject is her.
|
||||
{name: "self", answer: (*reactiveHandler).querySelf},
|
||||
{name: "embed", answer: (*reactiveHandler).queryEmbed},
|
||||
{name: "memory", answer: (*reactiveHandler).queryMemory},
|
||||
{name: "notes", answer: (*reactiveHandler).queryNotes},
|
||||
{name: "self", answer: (*reactiveHandler).querySelf, dest: router.SourceSelf, guesses: true},
|
||||
{name: "embed", answer: (*reactiveHandler).queryEmbed, dest: router.SourceRecall},
|
||||
{name: "memory", answer: (*reactiveHandler).queryMemory, dest: router.SourceRecall},
|
||||
{name: "notes", answer: (*reactiveHandler).queryNotes, dest: router.SourceRecall},
|
||||
// THE BOUNDARY. Everything above answers from his own data; everything
|
||||
// below answers from the world's. A question about him that got this far
|
||||
// has no answer in his data, and no outside source can supply one, so this
|
||||
// stops the walk rather than let the encyclopedia and the model guess.
|
||||
{name: "personal", answer: (*reactiveHandler).queryPersonal},
|
||||
{name: "personal", answer: (*reactiveHandler).queryPersonal, dest: router.SourceRecall, guesses: true},
|
||||
// The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats
|
||||
// a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at
|
||||
// stake by this point — the boundary above already stopped every question
|
||||
// about him, and only the query string leaves the box.
|
||||
{name: "search", answer: (*reactiveHandler).querySearch},
|
||||
{name: "search", answer: (*reactiveHandler).querySearch, dest: router.SourceWorld},
|
||||
// The offline encyclopedia, now the fallback for when the line is down or
|
||||
// the search comes back empty. It reads the way it always did; what changed
|
||||
// is that it no longer gets first refusal on a world question.
|
||||
{name: "kiwix", answer: (*reactiveHandler).queryKiwix},
|
||||
{name: "kiwix", answer: (*reactiveHandler).queryKiwix, dest: router.SourceWorld},
|
||||
// LAST before the model answers from memory, and that position is the whole
|
||||
// design (Vikunja #259): everything of his, then the search, then the ZIMs,
|
||||
// and only then a page he named. The model does NOT come first: it
|
||||
@@ -153,8 +173,43 @@ var querySources = []querySource{
|
||||
// a 1.7B guessing at a page it cannot read is how contents get invented.
|
||||
// This source only claims a turn where he named a URL, so it never competes
|
||||
// with a local answer.
|
||||
{name: "web", answer: (*reactiveHandler).queryWeb},
|
||||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral},
|
||||
{name: "web", answer: (*reactiveHandler).queryWeb, dest: router.SourceWorld},
|
||||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral, dest: router.SourceWorld},
|
||||
}
|
||||
|
||||
// queryWalk narrows the chain for one turn against the destination the cascade
|
||||
// named, and says which sources were left out (V-655).
|
||||
//
|
||||
// It takes sources OUT and never moves one, which is the whole safety argument.
|
||||
// The table's order is load-bearing and every comment on it argues a reason
|
||||
// between two sources; none of those reasons is about this. Above all, the
|
||||
// order carries "his data first, then the world", and a destination named by a
|
||||
// model must not be able to reverse that. Naming SourceWorld does not send the
|
||||
// turn outside — it stops the guessers from claiming it on the way.
|
||||
//
|
||||
// What comes out is exactly the sources that guess. Those decide whether a turn
|
||||
// is theirs by scoring it against frozen seeds, and then answer whatever they
|
||||
// claimed, because they have no lookup that can come back empty. That is the
|
||||
// whole of the 2026-08-07 defect: weather claiming "что такое TCP?", the feed
|
||||
// claiming "какой у меня любимый язык?", the personal boundary claiming "кто
|
||||
// такой Линус Торвальдс?". The sources that look are all still asked, so a
|
||||
// wrong destination costs nothing but the guess it prevented.
|
||||
//
|
||||
// No destination named ⇒ the table exactly as written, which is what shipped
|
||||
// before the field existed. That is the floor. The classifier arm names
|
||||
// nothing, so a box whose model is down routes queries the way it always did.
|
||||
func queryWalk(dest router.Source) (walk, skipped []querySource) {
|
||||
if dest == router.SourceUnknown {
|
||||
return querySources, nil
|
||||
}
|
||||
for _, s := range querySources {
|
||||
if s.guesses && s.dest != dest {
|
||||
skipped = append(skipped, s)
|
||||
continue
|
||||
}
|
||||
walk = append(walk, s)
|
||||
}
|
||||
return walk, skipped
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||||
@@ -164,7 +219,14 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// (V-564). Finish names everyone below the winner.
|
||||
decision.Expect(ctx, decision.StageQuery, querySourceNames())
|
||||
rec := decision.From(ctx)
|
||||
for _, src := range querySources {
|
||||
walk, skipped := queryWalk(dec.Source)
|
||||
for _, src := range skipped {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||
Reason: "it decides by similarity and the cascade named " + string(dec.Source),
|
||||
})
|
||||
}
|
||||
for _, src := range walk {
|
||||
if dec.Continued && !src.dateAware {
|
||||
rec.Note(decision.Claim{
|
||||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||||
@@ -235,7 +297,13 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
//
|
||||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||||
// day, so that phrasing trims what has already passed.
|
||||
// day, so that phrasing trims what has already passed and reads only the next
|
||||
// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts
|
||||
// nothing, and she read 43 entries aloud in one sentence (V-618).
|
||||
//
|
||||
// "что у меня сегодня?" is a different question and is not narrowed here — it
|
||||
// carries no plan word, so IsDayPlanQuery declines it and the calendar source
|
||||
// answers the whole day.
|
||||
//
|
||||
// What surface this belongs on is still open, tracked as Vikunja #431 ("Board
|
||||
// surface: Maven holds the work board, runs the intake form, never argues").
|
||||
@@ -254,16 +322,23 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
}
|
||||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||||
// rendered the whole day — one formatter, one persona.
|
||||
p := morning.Plan{Date: plan.Date}
|
||||
//
|
||||
// The instants are put back in the asking clock's zone on the way in. They
|
||||
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
|
||||
// hours in the plan's own frame, so setting that frame here is what makes
|
||||
// the recital name his clock rather than the store's (V-614).
|
||||
zone := h.now().Location()
|
||||
p := morning.Plan{Date: plan.Date.In(zone)}
|
||||
for _, it := range plan.Items {
|
||||
p.Items = append(p.Items, morning.PlanEntry{
|
||||
At: it.At,
|
||||
At: it.At.In(zone),
|
||||
Text: it.Text,
|
||||
Kind: morning.PlanKind(it.Kind),
|
||||
Uncertain: it.Uncertain,
|
||||
})
|
||||
}
|
||||
return p.After(h.now()).FormatRU(), true
|
||||
return p.Next(h.now(), morning.NextSpoken).FormatRU(), true
|
||||
}
|
||||
|
||||
// habitFactWindow — how many recent SELF facts the behaviour profile is counted
|
||||
@@ -428,6 +503,10 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
return f.FormatEntries(entries, date), true
|
||||
}
|
||||
|
||||
// homeTimeout — the whole house read. Longer than the weather call because the
|
||||
// hub is polled over the LAN and answers for every device at once.
|
||||
const homeTimeout = 10 * time.Second
|
||||
|
||||
// queryHome answers a question about the house. Read-only by construction: it
|
||||
// calls States and nothing else, so there is no confirm turn here — the only
|
||||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||||
@@ -444,7 +523,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string,
|
||||
// unreachable case is different and homeSummary covers it.
|
||||
return "", false
|
||||
}
|
||||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
ctxH, cancel := context.WithTimeout(ctx, homeTimeout)
|
||||
defer cancel()
|
||||
return h.home.homeSummary(ctxH)
|
||||
}
|
||||
@@ -468,6 +547,10 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin
|
||||
return h.netscan.scanSummary(ctx)
|
||||
}
|
||||
|
||||
// weatherTimeout — one geocode plus one forecast read. He asked a question with
|
||||
// a one-line answer, so a provider that is slower than this is a failure.
|
||||
const weatherTimeout = 5 * time.Second
|
||||
|
||||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) {
|
||||
return "", false
|
||||
@@ -478,7 +561,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
// so is the only honest answer; picking a city would be inventing one.
|
||||
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
ctxWT, cancel := context.WithTimeout(ctx, weatherTimeout)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
if errors.Is(err, weather.ErrNotConfigured) {
|
||||
@@ -529,6 +612,27 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
||||
return "", false
|
||||
}
|
||||
|
||||
// memoryRecallWidth and noteRecallWidth — how many candidates each recall pass
|
||||
// pulls before the gate reads them. Both are small on purpose: the gate wants a
|
||||
// best hit and its runner-up, and every further row is a margin the top match
|
||||
// has to beat.
|
||||
const (
|
||||
memoryRecallWidth = 3
|
||||
noteRecallWidth = 5
|
||||
)
|
||||
|
||||
// recallOnTopic — the topic veto both recall sources apply after the score gate
|
||||
// (#470). A memory about his slow network scored high enough to answer "почему
|
||||
// небо синее?", because the right-note and must-be-silent score ranges overlap
|
||||
// and no threshold sits between them.
|
||||
func recallOnTopic(utterance, text string) bool {
|
||||
if memory.RecallAllowed(utterance, text) {
|
||||
return true
|
||||
}
|
||||
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, utterance)
|
||||
return false
|
||||
}
|
||||
|
||||
// queryMemory — long-term memory first: ONE search over everything Maven
|
||||
// remembers (notes and facts share this index) and ONE confidence gate, so
|
||||
// the memory that is clearly the best match answers — a note just as much as
|
||||
@@ -549,7 +653,7 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
// a "there is nothing" answer — pass rather than gate the chain.
|
||||
return "", false
|
||||
}
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
|
||||
hits, herr := h.recall.memStore.Search(ctx, t.vec, memoryRecallWidth)
|
||||
if herr != nil {
|
||||
log.Printf("voice: memory search: %v", herr)
|
||||
return "", false
|
||||
@@ -559,12 +663,8 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
||||
return "", false
|
||||
}
|
||||
text := hit.Meta["text"]
|
||||
// The score cleared the gate and the topic still has to match (#470). A
|
||||
// note about his slow network scored high enough to answer "почему небо
|
||||
// синее?", because the right-note and must-be-silent score ranges overlap
|
||||
// and no threshold sits between them.
|
||||
if !memory.RecallAllowed(t.dec.Utterance, text) {
|
||||
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance)
|
||||
// The score cleared the gate and the topic still has to match.
|
||||
if !recallOnTopic(t.dec.Utterance, text) {
|
||||
return "", false
|
||||
}
|
||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||
@@ -600,7 +700,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
// source could not look, and could-not-look passes.
|
||||
return "", false
|
||||
}
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, noteRecallWidth)
|
||||
if err != nil {
|
||||
// The store failed, so this source could not look either. It used to
|
||||
// claim here, which stopped the search, the ZIMs and the model from
|
||||
@@ -616,10 +716,9 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||||
return "", false
|
||||
}
|
||||
// Same topic veto as queryMemory above: the best note must be about what
|
||||
// he asked, not merely the nearest vector in the index.
|
||||
if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) {
|
||||
log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance)
|
||||
// The best note must be about what he asked, not merely the nearest vector
|
||||
// in the index.
|
||||
if !recallOnTopic(t.dec.Utterance, notes[0].Text) {
|
||||
return "", false
|
||||
}
|
||||
texts := make([]string, len(notes))
|
||||
@@ -641,6 +740,23 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
// prompt, the persona block and the reply.
|
||||
const webPageContextRunes = 1500
|
||||
|
||||
// webFetchTimeout — the whole named-page source. Longer than the other outside
|
||||
// sources because he named this page himself, so waiting for it is what he asked
|
||||
// for, and there is nothing below that can answer instead.
|
||||
const webFetchTimeout = 30 * time.Second
|
||||
|
||||
// readBackRunes — how much of the evidence is read out when the phraser gave
|
||||
// nothing back. It is spoken aloud, so it is a couple of sentences and not a
|
||||
// page.
|
||||
const readBackRunes = 300
|
||||
|
||||
// readBack — what an outside source says when the phraser gave nothing back.
|
||||
// The evidence is read out plainly rather than dropped, because the fetch did
|
||||
// happen and its result is a better answer than silence.
|
||||
func readBack(evidence string) string {
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(evidence, readBackRunes)})
|
||||
}
|
||||
|
||||
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||||
//
|
||||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||||
@@ -659,7 +775,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
// guess dressed as an answer (Vikunja #479).
|
||||
return phraser.Q(phraser.QueryPageOff, nil), true
|
||||
}
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
ctxFetch, cancel := context.WithTimeout(ctx, webFetchTimeout)
|
||||
defer cancel()
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
@@ -680,7 +796,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
|
||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, readBackRunes)}), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -746,7 +862,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best evidence rather than
|
||||
// pretend the search did not happen.
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
|
||||
return readBack(resp.Snippets()[0]), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -835,7 +951,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||
// the search did not happen.
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
||||
return readBack(top.Title + " — " + page.Text), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
// time wasn't parsed. Run the parser as a fallback.
|
||||
if dec.Stage == 0 && h.timeParser != nil {
|
||||
t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now())
|
||||
if err == nil && ok {
|
||||
// Same gate as the extractor (V-577, V-579, V-610): a request whose
|
||||
// hour was not spoken, or was spoken and not read, gets asked about
|
||||
// and is never completed from the clock.
|
||||
if err == nil && ok && router.ResolvedTheHour(dec.Utterance, t) {
|
||||
dec.Slots.Time = t
|
||||
dec.Slots.HasTime = true
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+115
-40
@@ -6,7 +6,6 @@ import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"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
|
||||
// its own answer would read as ignoring what he asked.
|
||||
//
|
||||
// A statement's full stop is folded into a comma, so the two acts read as one
|
||||
// sentence — that is the owner's own punctuation, "в Риме сейчас ..., на какое
|
||||
// время поставить напоминание?". An answer that is ITSELF a question keeps its
|
||||
// mark and the resume starts a new sentence: she sometimes answers a side query
|
||||
// by asking him to say it again, and "переформулировать?, на какое время" folds
|
||||
// two questions into one unreadable line.
|
||||
// Two sentences, not one (V-654). This used to fold the answer's full stop into
|
||||
// a comma, on the strength of the owner having written it that way once. Spliced
|
||||
// onto a real answer it reads as one run-on thought — "вот что я нашла: вайфай
|
||||
// пароль лежит в ящике стола, на какое время поставить напоминание?" — and the
|
||||
// question disappears into the tail of a sentence about something else. A reply
|
||||
// with no terminator of its own is given one, so the join never depends on how
|
||||
// the phraser chose to end.
|
||||
//
|
||||
// A resume with no answer in front of it is just the question.
|
||||
func withResumed(reply, resumed string) string {
|
||||
@@ -178,29 +178,23 @@ func withResumed(reply, resumed string) string {
|
||||
if reply == "" {
|
||||
return resumed
|
||||
}
|
||||
if strings.HasSuffix(reply, "?") {
|
||||
return reply + " " + resumed
|
||||
if !endsSentence(reply) {
|
||||
reply += "."
|
||||
}
|
||||
if trimmed := strings.TrimRight(reply, ".!"); trimmed != "" {
|
||||
reply = trimmed
|
||||
}
|
||||
return reply + ", " + lowerFirst(resumed)
|
||||
return reply + " " + resumed
|
||||
}
|
||||
|
||||
// lowerFirst lowercases the opening rune, so a deck line written as a standalone
|
||||
// sentence reads as the second half of one. Only the first rune: "На какое
|
||||
// время" must become "на какое время" and nothing else in it may move.
|
||||
func lowerFirst(s string) string {
|
||||
for i, r := range s {
|
||||
return string(unicode.ToLower(r)) + s[i+utf8.RuneLen(r):]
|
||||
}
|
||||
return s
|
||||
// endsSentence reports whether s already closes itself. The ellipsis counts: a
|
||||
// trailing "…" is a deliberate end, and a full stop after it reads as a typo.
|
||||
func endsSentence(s string) bool {
|
||||
r, _ := utf8.DecodeLastRuneInString(s)
|
||||
return strings.ContainsRune(".!?…", r)
|
||||
}
|
||||
|
||||
// missingFor returns the slots a decision still needs, most important first.
|
||||
// Empty ⇒ there is nothing identifiable to ask about.
|
||||
func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
return dialogue.StillMissing(wantedSlots[dec.Intent], toDialogueSlots(dec.Slots))
|
||||
return stillMissingFor(dec.Intent, dec.Utterance, toDialogueSlots(dec.Slots))
|
||||
}
|
||||
|
||||
// clarifyQuestion picks the one question to ask for a clarify decision. Returns
|
||||
@@ -209,18 +203,32 @@ func missingFor(dec router.Decision) []dialogue.Slot {
|
||||
// One question about one thing: if two slots are missing she asks about the
|
||||
// first only. Two questions in one breath is an interrogation. The second gap
|
||||
// is picked up on the turn after the first one is answered (askRemainingGap).
|
||||
func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) {
|
||||
missing := missingFor(dec)
|
||||
if len(missing) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
q, ok := clarifyQuestionFor(missing[0], 1)
|
||||
q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "")
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
return missing[0], q, true
|
||||
}
|
||||
|
||||
// questionFor picks the wording for one gap. Every slot but the reminder's time
|
||||
// reads its deck by attempt; the time asks about whichever of the hour, the half
|
||||
// of the day and the day he has not said, and states the clock while it does
|
||||
// (V-579).
|
||||
//
|
||||
// taken is the acknowledgement of what his last turn added, empty when it added
|
||||
// nothing and empty for a first ask, which has no turn behind it (V-593).
|
||||
func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots, taken string) (string, bool) {
|
||||
if slot != dialogue.SlotTime {
|
||||
return clarifyQuestionFor(slot, attempt)
|
||||
}
|
||||
return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now(), taken)
|
||||
}
|
||||
|
||||
// askClarify parks the request and returns the question to ask instead of the
|
||||
// canned "не поняла". Returns ("", false) when there is nothing to ask about, so
|
||||
// the caller falls back to the canned reply.
|
||||
@@ -228,7 +236,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
|
||||
if h.clarifyStore == nil {
|
||||
return "", false
|
||||
}
|
||||
slot, question, ok := clarifyQuestion(dec)
|
||||
slot, question, ok := h.clarifyQuestion(dec)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
@@ -348,6 +356,15 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
// the V-554 shape.
|
||||
h.noteSuspended(ctx, q)
|
||||
return "", false
|
||||
case roleAside:
|
||||
// He stated something in the middle of the flow. Same machinery as a
|
||||
// side query and for the same reason: the words are answered as
|
||||
// themselves, so the note or the fact is stored, and the question comes
|
||||
// back on the end of the same reply (V-577 shape 2). Storing it in
|
||||
// silence and dropping it in silence are both wrong, and dropping it is
|
||||
// what she did.
|
||||
h.noteSuspended(ctx, q)
|
||||
return "", false
|
||||
case roleNewRequest:
|
||||
// He moved on. A parked question used to swallow whatever came next, so
|
||||
// one act she could not fulfil ate the following three turns (Vikunja
|
||||
@@ -358,14 +375,49 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
|
||||
return "", false
|
||||
}
|
||||
|
||||
// He is answering, so the run of step-asides is over (V-654). Reset here
|
||||
// rather than where a gap is FILLED: "позвонить маме" against a question
|
||||
// about the time gives her nothing she asked for and still means he is in
|
||||
// the exchange, and the retry it costs is bound enough on its own. The
|
||||
// counter is for the case the bounds miss — he asked for other things and
|
||||
// never came back.
|
||||
q.Suspends = 0
|
||||
|
||||
merged := q.Answer(text, toDialogueSlots(answer))
|
||||
// Fold a newly answered subject into the raw utterance. Downstream actions
|
||||
// phrase from Utterance, not from the text slot — actionReminder stores it
|
||||
// as the reminder payload — so a reminder clarified out of a bare "напомни"
|
||||
// would fire at 11:00 saying "напомни" and nothing else.
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text)
|
||||
if len(dialogue.StillMissing(q.Missing, merged)) > 0 {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text), true
|
||||
// A fact answers with a key and a value and fills no Text slot at all, so
|
||||
// the fold above leaves the utterance at the bare "запиши" — and that is
|
||||
// what the confirmation now reads back to him (V-592). His raw words are the
|
||||
// only record of what he said, so they are what is folded. Never for a time
|
||||
// question: what he says about when is kept apart in WhenText on purpose,
|
||||
// or the reminder would read the day back at him when it fires.
|
||||
if merged.Text == "" && !asksAboutTime(q.Missing) {
|
||||
q.Utterance = foldAnswerIntoUtterance(q.Utterance, text)
|
||||
}
|
||||
// An answer about the time joins everything else he has said about the time,
|
||||
// and the whole of it is re-read as one request (V-579). "завтра" names the
|
||||
// day of an hour she is already holding, and read alone it names no hour at
|
||||
// all, so the parser would have nothing and she would ask for ever.
|
||||
// What he had already said about the time, read BEFORE this answer joins it.
|
||||
// A re-ask that cannot tell the two apart is the one that repeats itself
|
||||
// byte for byte (V-593).
|
||||
var taken string
|
||||
if asksAboutTime(q.Missing) {
|
||||
before := whenKnownOf(whenTextOf(q), q.Slots.HasTime)
|
||||
q.WhenText = strings.TrimSpace(q.WhenText + " " + text)
|
||||
if t, ok := h.readWhen(ctx, intent, q, text); ok {
|
||||
merged.Time, merged.HasTime = t, true
|
||||
}
|
||||
if before.movedForward(whenKnownOf(whenTextOf(q), merged.HasTime)) {
|
||||
taken = whenTakenLine(text)
|
||||
}
|
||||
}
|
||||
if stillOpen(q.Missing, whenTextOf(q), merged) {
|
||||
return h.reaskOrGiveUp(ctx, q, merged, text, taken), true
|
||||
}
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
|
||||
@@ -417,6 +469,14 @@ func (h *reactiveHandler) noteDropped(ctx context.Context) {
|
||||
//
|
||||
// A slot with no resumed wording (clarifyResumedFor says so) resumes nothing and
|
||||
// says nothing. She must not claim to be holding a question she cannot re-ask.
|
||||
//
|
||||
// Suspension is bounded, since V-654. Neither of the two things above is a
|
||||
// limit: no attempt is spent, and restarting the clock means the TTL cannot
|
||||
// arrive while he keeps talking. So the count is the only thing that ends it,
|
||||
// and past MaxSuspends she lets the request go and says so with the same line
|
||||
// every other drop uses. The rule is unchanged — a question ends by being
|
||||
// answered or by being let go out loud — this only recognises three unrelated
|
||||
// requests in a row as the second of those.
|
||||
func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.PendingQuestion) {
|
||||
rt := turnRouteFrom(ctx)
|
||||
if rt == nil || len(q.Missing) == 0 {
|
||||
@@ -426,11 +486,18 @@ func (h *reactiveHandler) noteSuspended(ctx context.Context, q *dialogue.Pending
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !q.CanResume() {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
h.noteDropped(ctx)
|
||||
log.Printf("voice: clarify — the question about %s stepped aside %d times; letting the request go", q.Missing[0], q.Suspends)
|
||||
return
|
||||
}
|
||||
q.Suspends++
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
rt.resume = question
|
||||
rt.suspended = true
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply", q.Missing[0])
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends)
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
@@ -460,21 +527,25 @@ func foldAnswerIntoUtterance(utterance, subject string) string {
|
||||
// costs a question exactly like a second try at the first one does, so the cap
|
||||
// still bounds how many times she can speak before acting or letting go.
|
||||
func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) {
|
||||
remaining := dialogue.StillMissing(wantedSlots[intent], merged)
|
||||
remaining := stillMissingFor(intent, whenTextOf(q), merged)
|
||||
if len(remaining) == 0 {
|
||||
return "", false
|
||||
}
|
||||
// Attempts+1 is the question she is about to ask, and the budget is shared
|
||||
// with the re-ask path, so the second gap is worded like a second try.
|
||||
question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1)
|
||||
question, ok := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged, "")
|
||||
if !ok || !q.CanAsk() {
|
||||
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.
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
Missing: []dialogue.Slot{remaining[0]},
|
||||
Utterance: q.Utterance,
|
||||
WhenText: q.WhenText,
|
||||
Asked: h.now(),
|
||||
TTL: clarifyTTL,
|
||||
Attempts: q.Attempts + 1,
|
||||
@@ -487,10 +558,12 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
// reaskOrGiveUp handles an answer that left the gap open: ask the same question
|
||||
// again while she has attempts left, otherwise say she did not understand and
|
||||
// let the request go. Never returns "" — a mute give-up reads as "done".
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string {
|
||||
// taken is the acknowledgement of what this answer DID give, empty when it gave
|
||||
// nothing (V-593). The give-up line never carries it: it is not another ask.
|
||||
func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text, taken string) string {
|
||||
question := ""
|
||||
if len(q.Missing) > 0 {
|
||||
question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1)
|
||||
question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken)
|
||||
}
|
||||
if question == "" || !q.CanAsk() {
|
||||
h.clarifyStore.Delete(dialogueIDOf(ctx))
|
||||
@@ -518,7 +591,7 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
||||
}
|
||||
reply := h.applyAction(ctx, dec)
|
||||
if reply == "" {
|
||||
reply = h.replier.Reply(dec)
|
||||
reply = h.replier.Reply(ctx, dec)
|
||||
}
|
||||
if reply == "" {
|
||||
// Belt: an empty reply here would be a silent drop.
|
||||
@@ -527,20 +600,22 @@ func (h *reactiveHandler) finishClarified(ctx context.Context, dec router.Decisi
|
||||
return reply
|
||||
}
|
||||
|
||||
// maxCarriedHistory — how many turns of PRIOR history (beyond the immediate
|
||||
// last turn) rememberTurn carries forward. The session ends up holding this
|
||||
// many plus the one just-finished turn, so callers describing the total
|
||||
// depth (chatHistory's doc comment, this one) say "up to 4".
|
||||
const maxCarriedHistory = 3
|
||||
|
||||
// rememberTurn stores this turn as the dialogue session the next follow-up
|
||||
// inherits from, carrying up to 4 prior turns of history for anaphora. Capped so
|
||||
// one long conversation can't grow the session unboundedly.
|
||||
func (h *reactiveHandler) rememberTurn(ctx context.Context, prev *dialogue.Session, dec router.Decision, now time.Time) {
|
||||
var history []dialogue.Turn
|
||||
if prev != nil {
|
||||
history = append(history, dialogue.Turn{
|
||||
Intent: prev.Intent,
|
||||
Slots: prev.Slots,
|
||||
Text: prev.Slots.Text,
|
||||
})
|
||||
history = append(history, sessionAsTurn(prev))
|
||||
maxHist := len(prev.History)
|
||||
if maxHist > 3 {
|
||||
maxHist = 3
|
||||
if maxHist > maxCarriedHistory {
|
||||
maxHist = maxCarriedHistory
|
||||
}
|
||||
history = append(history, prev.History[:maxHist]...)
|
||||
}
|
||||
|
||||
+78
-19
@@ -58,18 +58,26 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) {
|
||||
want string
|
||||
asked bool
|
||||
}{
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true},
|
||||
{"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Сейчас 09:00. Когда?", true},
|
||||
{"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true},
|
||||
{"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true},
|
||||
// A time with nothing to say at that time is still half a reminder, so
|
||||
// the subject is what she asks about — not silence.
|
||||
{"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true},
|
||||
{"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false},
|
||||
// A bare hour is half of a day away from being an answer, and she asks
|
||||
// which half rather than picking one (V-579).
|
||||
{"reminder whose hour could be either half of the day", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "Сейчас 09:00. Это утра или вечера?", true},
|
||||
{"reminder that has all three", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни завтра в 15:00 позвонить маме"), "", false},
|
||||
// The owner's own two, confirmed 2026-08-06: an unambiguous time and a
|
||||
// relative one are both complete and are never asked about.
|
||||
{"an interval names the instant by itself", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни через час позвонить маме"), "", false},
|
||||
{"half an hour is an interval too", clarifyDec(router.IntentReminder, router.Slots{Text: "выключить духовку", HasTime: true}, "напомни через полчаса выключить духовку"), "", false},
|
||||
{"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false},
|
||||
{"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false},
|
||||
}
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
for _, tc := range cases {
|
||||
_, got, asked := clarifyQuestion(tc.dec)
|
||||
_, got, asked := h.clarifyQuestion(tc.dec)
|
||||
if asked != tc.asked || got != tc.want {
|
||||
t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked)
|
||||
}
|
||||
@@ -83,11 +91,13 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) {
|
||||
h, st, _ := newClarifyHandler(t)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"))
|
||||
if !asked || question != "Когда?" {
|
||||
if !asked || question != "Сейчас 09:00. Когда?" {
|
||||
t.Fatalf("expected the time question, got %q asked=%v", question, asked)
|
||||
}
|
||||
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00")
|
||||
// The answer names the day as well as the hour. A reminder commits on what,
|
||||
// what time and what day, and a dayless hour is asked about (V-579).
|
||||
reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00")
|
||||
if !handled {
|
||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
||||
}
|
||||
@@ -159,11 +169,11 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) {
|
||||
}
|
||||
// The wording changes with the attempt (Vikunja #457): repeating a
|
||||
// question he already failed to answer is the worst way to ask it.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, i)
|
||||
want, _ := whenQuestion(whenNoHour, i, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply)
|
||||
}
|
||||
if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first {
|
||||
if first, _ := whenQuestion(whenNoHour, 1, h.now(), ""); reply == first {
|
||||
t.Fatalf("attempt %d repeated the first wording: %q", i, reply)
|
||||
}
|
||||
if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil {
|
||||
@@ -216,10 +226,11 @@ func TestClarifyRestatedAnswerWins(t *testing.T) {
|
||||
if q == nil {
|
||||
t.Fatal("expected an armed question")
|
||||
}
|
||||
first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now())
|
||||
q.Slots = q.Answer("в 11:00", toDialogueSlots(first))
|
||||
first := h.extractor.Extract(ctx, router.IntentReminder, "сегодня в 11:00", h.now())
|
||||
q.Slots = q.Answer("сегодня в 11:00", toDialogueSlots(first))
|
||||
q.WhenText = "сегодня в 11:00"
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "нет, сегодня в 15:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
@@ -357,7 +368,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
}
|
||||
// Second gap, second attempt, so it is the second wording of the time
|
||||
// question — the attempt budget is shared between the two paths.
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, 2)
|
||||
want, _ := whenQuestion(whenNoHour, 2, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply)
|
||||
}
|
||||
@@ -369,7 +380,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) {
|
||||
t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots)
|
||||
}
|
||||
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
||||
@@ -487,7 +498,7 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
||||
at := h.now().Add(2 * time.Hour)
|
||||
|
||||
question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder,
|
||||
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
||||
router.Slots{Time: at, HasTime: true}, "напомни сегодня в 11 утра"))
|
||||
if !asked || question != "О чём напомнить?" {
|
||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
||||
}
|
||||
@@ -623,7 +634,7 @@ func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) {
|
||||
if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked {
|
||||
t.Fatal("expected the time question")
|
||||
}
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
if reply, handled := h.resolveClarifyAnswer(ctx, "а что если сегодня в 11:00"); !handled || reply == clarifyGaveUp {
|
||||
t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||||
@@ -654,8 +665,6 @@ func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// newRoutingClarifyHandler wires the real cascade (hash embedder, no model) onto
|
||||
// the clarify handler, so a test can drive handleText end to end and see which
|
||||
// gate claimed the turn.
|
||||
@@ -676,14 +685,14 @@ func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) {
|
||||
h, st := newRoutingClarifyHandler(t)
|
||||
|
||||
reply := h.handleText(ctx, "web", "напомни позвонить маме")
|
||||
want, _ := clarifyQuestionFor(dialogue.SlotTime, 1)
|
||||
want, _ := whenQuestion(whenNoHour, 1, h.now(), "")
|
||||
if reply != want {
|
||||
t.Fatalf("reply = %q, want the time question %q", reply, want)
|
||||
}
|
||||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
|
||||
t.Fatal("the request must be parked, or the answer has nowhere to land")
|
||||
}
|
||||
if reply := h.handleText(ctx, "web", "в семь вечера"); strings.Contains(reply, "нашла") {
|
||||
if reply := h.handleText(ctx, "web", "сегодня в семь вечера"); strings.Contains(reply, "нашла") {
|
||||
t.Fatalf("the answer to her own question must not be looked up: %q", reply)
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
|
||||
@@ -717,7 +726,7 @@ func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
|
||||
h, _, _ := newClarifyHandler(t)
|
||||
|
||||
complete := []router.Decision{
|
||||
{Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни в 11 позвонить маме"},
|
||||
{Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни завтра в 11 утра позвонить маме"},
|
||||
{Intent: router.IntentFact, Slots: router.Slots{Key: "water", Value: "выпил", HasKey: true}, Utterance: "я выпил воды"},
|
||||
{Intent: router.IntentNote, Slots: router.Slots{Text: "купить хлеб"}, Utterance: "запиши купить хлеб"},
|
||||
{Intent: router.IntentQuery, Slots: router.Slots{Text: "что у меня сегодня"}, Utterance: "что у меня сегодня"},
|
||||
@@ -731,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)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-1
@@ -24,6 +24,14 @@ type pendingHexisExec struct {
|
||||
entityID string
|
||||
displayName string
|
||||
expiry time.Time
|
||||
|
||||
// correlationID — the id the proposing turn minted for this action. A
|
||||
// confirm arrives on a later turn with a context of its own, so without
|
||||
// carrying it here the execution recorded a fresh id and no causation at
|
||||
// all, and the resolve, the discovery and the thing they authorised sat in
|
||||
// the trace as unrelated calls. The contract mints one id per action, and
|
||||
// the action began when she asked.
|
||||
correlationID string
|
||||
}
|
||||
|
||||
// pendingRoutineConfirm — a proposed routine awaiting a spoken y/n to become
|
||||
@@ -144,7 +152,11 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
||||
return hx != nil && !h.now().After(hx.expiry)
|
||||
},
|
||||
yes: func() string {
|
||||
return h.execHexis(ctx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
execCtx := ctx
|
||||
if hx.correlationID != "" {
|
||||
execCtx = withCorrelationID(execCtx, hx.correlationID)
|
||||
}
|
||||
return h.execHexis(execCtx, hx.capabilityID, hx.capName, hx.entityID, hx.displayName)
|
||||
},
|
||||
no: func() string { return phraser.C(phraser.ConfirmCancelled, nil) },
|
||||
},
|
||||
|
||||
@@ -173,6 +173,13 @@ func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, erro
|
||||
case errors.Is(err, webfetch.ErrBlocked), errors.Is(err, webfetch.ErrPrivate), errors.Is(err, webfetch.ErrScheme):
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchRefused, err)
|
||||
case errors.Is(err, webfetch.ErrStatus):
|
||||
// Carry the code across the seam. The crawler needs to tell a 5xx
|
||||
// from a 404 to decide what a failed robots.txt means, and it must
|
||||
// not learn that by reading this sentence.
|
||||
var se *webfetch.StatusError
|
||||
if errors.As(err, &se) {
|
||||
return nil, &crawl.StatusError{Code: se.Code}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %v", crawl.ErrFetchStatus, err)
|
||||
}
|
||||
return nil, err
|
||||
|
||||
@@ -4,12 +4,14 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -111,6 +113,88 @@ func TestQueryDayPlanRestOfDayWhenNothingIsLeft(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// "что дальше?" rebuilds the plan off the wire and renders it here, and the
|
||||
// instants on it carry the zone the core read them in — a calendar fact's Ts
|
||||
// and a reminder's FireTs are UTC out of the store. Read raw, the recital named
|
||||
// the store's clock instead of his (V-614). The asking clock is three hours off
|
||||
// whatever this machine runs in, so the assertion holds under TZ=UTC too.
|
||||
func TestQueryDayPlanRestOfDayReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Date(2026, 8, 3, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
h := &reactiveHandler{
|
||||
api: &planAPI{plan: ipc.DayPlan{
|
||||
Date: time.Date(2026, 8, 3, 0, 0, 0, 0, time.UTC),
|
||||
Items: []ipc.DayPlanItem{{At: stored, Text: "позвонить маме", Kind: "reminder"}},
|
||||
}},
|
||||
now: func() time.Time { return time.Date(2026, 8, 3, 9, 0, 0, 0, away) },
|
||||
}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if want := stored.In(away).Format("15:04"); !strings.Contains(reply, want) {
|
||||
t.Errorf("the reminder is not read in his clock (%s): %q", want, reply)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(reply, bad) {
|
||||
t.Errorf("the reminder is read in the store's zone (%s): %q", bad, reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The defect V-618 fixes, at the handler: asked at 04:45 the trim removes
|
||||
// nothing, because the whole day is still ahead. She read 43 entries aloud as
|
||||
// one sentence. The zone is three hours off UTC so the test also fails under
|
||||
// TZ=UTC if the rendering ever slips zones.
|
||||
func TestQueryDayPlanCapsWhatItReadsAloud(t *testing.T) {
|
||||
zone := time.FixedZone("MSK", 3*60*60)
|
||||
mid := time.Date(2026, 8, 3, 0, 0, 0, 0, zone)
|
||||
plan := ipc.DayPlan{Date: mid, Spoken: "план на 03.08.2026: …"}
|
||||
for i := 0; i < 43; i++ {
|
||||
plan.Items = append(plan.Items, ipc.DayPlanItem{
|
||||
At: mid.Add(time.Duration(345+i*20) * time.Minute), // 05:45 onward
|
||||
Text: fmt.Sprintf("пункт %d", i),
|
||||
Kind: "event",
|
||||
})
|
||||
}
|
||||
h := &reactiveHandler{api: &planAPI{plan: plan}, now: func() time.Time {
|
||||
return time.Date(2026, 8, 3, 4, 45, 0, 0, zone)
|
||||
}}
|
||||
reply, ok := h.queryDayPlan(context.Background(), &queryTurn{
|
||||
dec: router.Decision{Intent: router.IntentQuery, Utterance: "что дальше?"},
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected the plan source to claim it")
|
||||
}
|
||||
if n := strings.Count(reply, "пункт "); n != morning.NextSpoken {
|
||||
t.Errorf("read %d entries aloud, want %d: %q", n, morning.NextSpoken, reply)
|
||||
}
|
||||
if !strings.HasPrefix(reply, "дальше: 05:45 — пункт 0;") {
|
||||
t.Errorf("the next thing is not first: %q", reply)
|
||||
}
|
||||
// The rest is counted, not silently dropped.
|
||||
if !strings.Contains(reply, "и ещё 40 дел до конца дня.") {
|
||||
t.Errorf("the sentence hides that the day goes on: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// "что у меня сегодня?" is the whole day and is not narrowed. It carries no
|
||||
// plan word, so the plan source declines it and the calendar listing answers —
|
||||
// asserted here beside the cap so the two questions cannot drift together.
|
||||
func TestWholeDayQuestionIsNotTheRestOfTheDay(t *testing.T) {
|
||||
if router.IsDayPlanQuery("что у меня сегодня?") {
|
||||
t.Error("the plan source claims the whole-day question")
|
||||
}
|
||||
if !router.IsDayPlanQuery("что дальше?") {
|
||||
t.Error("the plan source stopped claiming the rest-of-day question")
|
||||
}
|
||||
if router.IsRestOfDayQuery("какие планы на сегодня?") {
|
||||
t.Error("the whole-day plan question got narrowed to the rest of the day")
|
||||
}
|
||||
}
|
||||
|
||||
// A question that is not about the plan must fall through, or the plan buries
|
||||
// the calendar listing and the weather behind it.
|
||||
func TestQueryDayPlanPassesOnEverythingElse(t *testing.T) {
|
||||
|
||||
@@ -31,7 +31,8 @@ import (
|
||||
// and nothing should: a missing name costs one line of the record, while a
|
||||
// check that walks the ladder would have to run the ladder.
|
||||
var preRouteLadder = []string{
|
||||
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", "ordinal",
|
||||
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair",
|
||||
"repair-negative", "ordinal",
|
||||
}
|
||||
|
||||
// notePreRoute records one rung of that ladder and passes its verdict through
|
||||
|
||||
@@ -114,8 +114,18 @@ type turn struct {
|
||||
wait time.Duration
|
||||
// question — the reply must be exactly this clarify question, worded for
|
||||
// this attempt. Zero slot ⇒ not checked.
|
||||
question dialogue.Slot
|
||||
attempt int
|
||||
question dialogue.Slot
|
||||
attempt int
|
||||
// gap — which part of the time she is asking about, for a SlotTime question
|
||||
// (V-579). Zero value is the missing hour, which is what she asks first.
|
||||
gap whenGap
|
||||
// took — the words of the PREVIOUS turn that this ask must acknowledge
|
||||
// before asking again (V-593). Empty ⇒ the ask carries no acknowledgement,
|
||||
// which is right for a first ask and for an answer that moved nothing.
|
||||
took string
|
||||
// differs — this reply must not be byte-identical to the one before it. Set
|
||||
// on a re-ask whose turn moved the request forward (V-593).
|
||||
differs bool
|
||||
contains []string
|
||||
notContain []string
|
||||
// noQuestion — the reply must not be any clarify question. Used where the
|
||||
@@ -157,11 +167,29 @@ type trace struct {
|
||||
func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
|
||||
t.Helper()
|
||||
h, st, now := newClarifyHandler(t)
|
||||
// A minute no trace ever says, so "fires at the current clock" is a defect
|
||||
// and never a coincidence (V-577, V-579). checkEnd refuses any reminder
|
||||
// landing on it, and at 09:00 the row that answers "на 9" would trip that.
|
||||
*now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC)
|
||||
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
|
||||
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
|
||||
return h, st, now
|
||||
}
|
||||
|
||||
// wantedQuestion builds the question a turn must be answered with, from the
|
||||
// same code the daemon asks through. A time question is built from the gap,
|
||||
// because she names the clock and asks about the part he left out (V-579).
|
||||
func wantedQuestion(tn turn, now time.Time) (string, bool) {
|
||||
if tn.question == dialogue.SlotTime {
|
||||
gap := tn.gap
|
||||
if gap == whenComplete {
|
||||
gap = whenNoHour
|
||||
}
|
||||
return whenQuestion(gap, tn.attempt, now, whenTakenLine(tn.took))
|
||||
}
|
||||
return clarifyQuestionFor(tn.question, tn.attempt)
|
||||
}
|
||||
|
||||
// runTrace drives one trace through handleText and checks every turn, then the
|
||||
// end state. Every failure carries the decision trace so far, so a wrong
|
||||
// claimant reads differently from wrong copy.
|
||||
@@ -180,6 +208,7 @@ func runTrace(t *testing.T, tr trace) {
|
||||
id := dialogueIDFor(sourceText, conversation)
|
||||
|
||||
var claims []claim
|
||||
var previous string
|
||||
fail := func(turnIdx int, format string, args ...any) {
|
||||
t.Helper()
|
||||
lines := make([]string, 0, len(claims))
|
||||
@@ -217,7 +246,7 @@ func runTrace(t *testing.T, tr trace) {
|
||||
fail(i, "reply %q announced an expiry nothing asked for", reply)
|
||||
}
|
||||
if tn.question != "" {
|
||||
want, ok := clarifyQuestionFor(tn.question, tn.attempt)
|
||||
want, ok := wantedQuestion(tn, h.now())
|
||||
if !ok {
|
||||
fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt)
|
||||
}
|
||||
@@ -238,6 +267,10 @@ func runTrace(t *testing.T, tr trace) {
|
||||
fail(i, "reply %q carries %q and must not", body, unwanted)
|
||||
}
|
||||
}
|
||||
if tn.differs && reply == previous {
|
||||
fail(i, "reply %q is byte-identical to the one before it, and his turn between them answered part of the gap", reply)
|
||||
}
|
||||
previous = reply
|
||||
checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked)
|
||||
}
|
||||
checkEnd(t, ctx, st, h, tr.end, claims)
|
||||
@@ -248,12 +281,16 @@ func runTrace(t *testing.T, tr trace) {
|
||||
func isAnyClarifyQuestion(reply string) bool {
|
||||
for _, variants := range clarifyQuestionVariants {
|
||||
for _, v := range variants {
|
||||
if reply == v {
|
||||
// HasSuffix, not equality: a question about the time opens with the
|
||||
// clock she is reasoning from (V-579).
|
||||
if strings.HasSuffix(reply, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
// The two questions with no deck behind them, asked when the hour is said
|
||||
// and its half of the day or its day is not.
|
||||
return strings.HasSuffix(reply, "утра или вечера?") || strings.HasSuffix(reply, "В какой день?")
|
||||
}
|
||||
|
||||
func checkParked(t *testing.T, fail func(int, string, ...any), i int, got *dialogue.PendingQuestion, want *parkedWant) {
|
||||
@@ -294,6 +331,17 @@ func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHan
|
||||
if len(reminders) != len(want.reminders) {
|
||||
t.Fatalf("end state: %d reminder(s), want %d: %+v%s", len(reminders), len(want.reminders), reminders, trace)
|
||||
}
|
||||
// No trace may leave a reminder at the current clock, whatever else it
|
||||
// asserts (V-577, V-579). Twice on the box a sentence naming a day and no
|
||||
// hour was completed from time.Now(): "что у меня сегодня?" became 01:28 and
|
||||
// "на завтра" became 01:38. Neither minute was ever spoken, and a row that
|
||||
// only checked the payload would have passed both.
|
||||
for _, r := range reminders {
|
||||
if r.FireTs.In(h.now().Location()).Format("15:04") == h.now().Format("15:04") {
|
||||
t.Fatalf("end state: reminder %q fires at %s, which is the clock — a time slot naming no hour is asked about, never filled from now()%s",
|
||||
r.Payload, r.FireTs.Format("15:04"), trace)
|
||||
}
|
||||
}
|
||||
for i, w := range want.reminders {
|
||||
if !strings.Contains(reminders[i].Payload, w.payload) {
|
||||
t.Fatalf("end state: reminder %d payload %q does not carry %q%s", i, reminders[i].Payload, w.payload, trace)
|
||||
@@ -355,11 +403,18 @@ func dialogueTraces() []trace {
|
||||
// from: she asks for the time, he gives it, the reminder lands with the
|
||||
// subject he said in the FIRST turn.
|
||||
{
|
||||
name: "reminder completed over two turns",
|
||||
// Three turns since V-579, not two. An hour with no day named is
|
||||
// not an answer she can act on: 11:00 today has passed as often as
|
||||
// not, and picking one for him is the invention the whole rule is
|
||||
// against. So she says the clock she is reasoning from and asks
|
||||
// which day.
|
||||
name: "reminder completed over three turns",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", contains: []string{"11:00"}, notContain: []string{"?"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||
},
|
||||
@@ -370,7 +425,9 @@ func dialogueTraces() []trace {
|
||||
turns: []turn{
|
||||
{say: "запиши", question: dialogue.SlotKey, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}},
|
||||
{say: "пил воду", contains: []string{"water"}},
|
||||
// His words back, not the key the parser filed them under
|
||||
// (V-592). "water" is machine vocabulary and he never said it.
|
||||
{say: "пил воду", contains: []string{"пил воду"}},
|
||||
},
|
||||
end: endState{factKeys: []string{"water"}},
|
||||
},
|
||||
@@ -438,8 +495,181 @@ func dialogueTraces() []trace {
|
||||
end: endState{tasks: []string{"купить молоко"}},
|
||||
},
|
||||
|
||||
// V-577 shape 1, the worst of the nine claimants measured on 2026-08-06.
|
||||
// Every token of "что у меня сегодня?" is frame — an interrogative, a
|
||||
// preposition, a particle and a day word — so the role classifier never
|
||||
// looked at the route, the parked reminder read "сегодня" as its time,
|
||||
// and the hour came from the clock. He got a reminder he never asked for
|
||||
// at a minute he never said, and his question was answered nowhere.
|
||||
//
|
||||
// Two claims: the calendar answers, and nothing is written. The flow
|
||||
// survives underneath, because a question of his own is not a request to
|
||||
// abandon the one he was making.
|
||||
{
|
||||
name: "an agenda question mid-flow is answered, not eaten",
|
||||
turns: []turn{
|
||||
{say: "напомни забрать посылку", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
|
||||
{say: "что у меня сегодня?", contains: []string{"31.07.2026"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-577 shape 2. He states something in the middle of the flow. It is
|
||||
// neither a slot value nor a cancel, and it was scored as a failed
|
||||
// answer and dropped in silence: alone the same sentence is stored.
|
||||
// Silence is the one option that is wrong, so it is stored, no retry is
|
||||
// spent, and the question comes back on the end of the same reply.
|
||||
//
|
||||
// The words are a fact and not the owner's note, because the fact parser
|
||||
// is deterministic and the offline floor marks every classifier route
|
||||
// Clarify. The row below carries his own sentence and needs the model.
|
||||
//
|
||||
// What this floor can prove is the arbitration: no retry is spent, the
|
||||
// flow survives on the same attempt, and the words are answered as
|
||||
// themselves with the question coming back after them. Whether the fact
|
||||
// is then WRITTEN is the routing engine's business — the hash embedder
|
||||
// is unsure of every sentence it sees, and an unsure fact has never been
|
||||
// stored.
|
||||
{
|
||||
name: "a fact stated mid-flow steps aside without spending a retry",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
// Nothing she says about it may be a word he did not say
|
||||
// (V-592). On the box this sentence came back as "Проверила, что
|
||||
// ты выпел стакан воды": a non-word for the verb, a glass copied
|
||||
// out of the example in ReplySystemPrompt, and a claim to have
|
||||
// checked something. The store held key=water value="drank"
|
||||
// throughout, so all of it was generated from two tokens.
|
||||
//
|
||||
// The positive half of the contract — the confirmation IS his
|
||||
// sentence — is asserted by "fact completed over two turns"
|
||||
// above. It cannot be asserted here: the hash embedder marks
|
||||
// this route Clarify, and an unsure fact is answered with the
|
||||
// canned line rather than a confirmation of anything.
|
||||
{say: "я выпил воды", contains: []string{"напоминание?"},
|
||||
notContain: []string{"стакан", "выпел", "Проверила", "water"},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-593: two asks about the same half of the day, with a turn between
|
||||
// them that answered the DAY. Asking again is right and asking in the
|
||||
// same bytes is not — from his side it is indistinguishable from not
|
||||
// having been heard, which is what the whole V-558 family is about.
|
||||
//
|
||||
// The clock still opens every ask (the owner's rule, V-579); the
|
||||
// acknowledgement goes after it and before the question.
|
||||
{
|
||||
name: "a re-ask names what the answer before it gave her",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
contains: []string{"Сейчас "},
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра",
|
||||
contains: []string{"Сейчас ", "завтра"}, differs: true,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
// V-579 turn 3: the preposition decided whether the hour was read. "в 9"
|
||||
// set the reminder and "на 9" was not read at all, on the same build and
|
||||
// with the same cardinal.
|
||||
{
|
||||
// It is read, and being read is not the same as being enough: nine is
|
||||
// either half of the day, so she asks which and then which day
|
||||
// (V-579). Both answers are frame words and neither carries an hour
|
||||
// of its own, so this row is also the proof that an answer is read
|
||||
// against the whole request rather than alone.
|
||||
name: "на 9 answers the time question like в 9",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, took: "утра",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
{say: "завтра", contains: []string{"09:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
|
||||
},
|
||||
// The owner's own four, ruled 2026-08-06 (V-579). A reminder commits
|
||||
// when what, what time and what day are all answered, and every ask
|
||||
// states the clock she is reasoning from.
|
||||
{
|
||||
name: "his first example: a bare 3 is asked about",
|
||||
turns: []turn{
|
||||
{say: "напомни завтра в 3 заказать цветы",
|
||||
question: dialogue.SlotTime, attempt: 1, gap: whenAmbiguousHour,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "цветы"}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
{
|
||||
// The hour is unambiguous and the day is still missing, so she asks.
|
||||
// Today being a valid reading is not the same as him saying it.
|
||||
name: "his second example: nine in the evening of which day",
|
||||
turns: []turn{
|
||||
{say: "напомни в 9 вечера разгрузить стиралку",
|
||||
question: dialogue.SlotTime, attempt: 1, gap: whenNoDay,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "стиралку"}},
|
||||
{say: "завтра", contains: []string{"21:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "стиралку", fireAt: "2026-08-01 21:00"}}},
|
||||
},
|
||||
{
|
||||
// All three answered in one breath, so she does not ask at all.
|
||||
name: "his third example: a full time commits",
|
||||
turns: []turn{
|
||||
{say: "напомни завтра в 15:00 заказать цветы", notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "цветы", fireAt: "2026-08-01 15:00"}}},
|
||||
},
|
||||
{
|
||||
// An interval is one instant, so it answers the hour and the day
|
||||
// together. Confirmed by the owner: "через час is fine as is".
|
||||
name: "an interval commits without a question",
|
||||
turns: []turn{
|
||||
{say: "напомни через час позвонить маме", notContain: []string{"?"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "маме", fireAt: "2026-07-31 10:17"}}},
|
||||
},
|
||||
// V-579 turn 4: he named a day and no hour, and got the day at the
|
||||
// current minute. She has to ask instead, and the global check in
|
||||
// checkEnd refuses the invented minute for every row at once.
|
||||
{
|
||||
name: "a day with no hour is asked about, not taken from the clock",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "на завтра", question: dialogue.SlotTime, attempt: 2,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
},
|
||||
end: endState{},
|
||||
},
|
||||
|
||||
// ---- rows below carry the CORRECT expectation and fail today ----
|
||||
|
||||
// The owner's own sentence from V-577 shape 2, in his words. It needs
|
||||
// an engine that can route it: the hash embedder marks it note with
|
||||
// Clarify set, and a route she is not sure of is not evidence that he
|
||||
// stated anything. The row above is the same contract in words the
|
||||
// floor's deterministic fact parser reads.
|
||||
{
|
||||
name: "a note stated mid-flow is stored, not dropped",
|
||||
skip: "the offline floor cannot route «у меня новый ноутбук» confidently; needs the resident model",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
{say: "у меня новый ноутбук",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}},
|
||||
},
|
||||
end: endState{notes: 1},
|
||||
},
|
||||
|
||||
// The owner's target transcript, V-561. He asks for a reminder, she asks
|
||||
// when, he asks something else entirely, and then comes back to her
|
||||
// question. On the box this created a reminder at 00:12 and never
|
||||
@@ -452,26 +682,39 @@ func dialogueTraces() []trace {
|
||||
// still standing, on the same attempt — a side query is not a failed
|
||||
// answer and must not spend a retry.
|
||||
//
|
||||
// Unskipping this needs more than V-561, and V-561 landing did not change
|
||||
// that. The suspend and resume it asked for is done — the row below is
|
||||
// the same shape in words the floor can parse and is green. What is left
|
||||
// here is the parser: StubDateTimeParser does not read "на 9" or "на
|
||||
// завтра", so turn 3 lands as an answer that filled nothing and spends a
|
||||
// retry, which is what this row now fails on. V-562 and V-543 own the
|
||||
// ambiguous hour and the day correction behind those two words.
|
||||
// The skip came off with V-579. What held it was the parser, not the
|
||||
// arbitration: neither the stub nor the production one read "на 9",
|
||||
// because only "в" framed a spoken hour, and "на завтра" was completed
|
||||
// from the clock.
|
||||
//
|
||||
// Turn 3 now closes the flow, where the transcript has one more exchange
|
||||
// in it. That is the 12-hour question — the owner's turn 4 answers "на
|
||||
// 9" with "сейчас 15:23, на 9 сегодня вечером?" — and it is a decision of
|
||||
// its own, not one to invent here. Nine o'clock is read as nine and, at
|
||||
// 09:17, as tomorrow's, which is where the transcript ends up anyway.
|
||||
// Turn 4 then has nothing to answer and must not write anything.
|
||||
{
|
||||
name: "the owner's transcript from V-561",
|
||||
skip: "V-543/V-562: the floor's date parser reads neither «на 9» nor «на завтра»",
|
||||
turns: []turn{
|
||||
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "какая сейчас погода в Риме?",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "а, да, прости - на 9.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "на завтра."},
|
||||
// His words, unchanged. What changed under V-579 is that "на 9"
|
||||
// is a question and not a commit: nine could be either half of
|
||||
// the day, so she says the clock she is reading from and asks.
|
||||
// "на завтра." then answers the day and leaves the half open, so
|
||||
// she asks that one again.
|
||||
// Each ask names what the turn before it gave her (V-593). The
|
||||
// two asks about the half of the day are the same question and
|
||||
// must not be the same sentence: he answered between them, and a
|
||||
// reply with no trace of that reads as not having been heard.
|
||||
{say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "а, да, прости - на 9.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
|
||||
{say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра.",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
|
||||
end: endState{},
|
||||
},
|
||||
// The same shape said in words StubDateTimeParser reads. GREEN since
|
||||
// V-561. Same three claims: Rome is answered, the question survives the
|
||||
@@ -488,7 +731,9 @@ func dialogueTraces() []trace {
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
|
||||
{say: "какая сейчас погода в Риме?",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
|
||||
{say: "в 11:00", contains: []string{"11:00"}},
|
||||
{say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00",
|
||||
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}},
|
||||
{say: "сегодня", contains: []string{"11:00"}},
|
||||
},
|
||||
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
|
||||
},
|
||||
|
||||
+151
-108
@@ -12,6 +12,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
hexisclient "github.com/kami/hexis/pkg/client"
|
||||
@@ -42,28 +43,100 @@ const ecosystemAPIVersion = "v1"
|
||||
// anonymous HTTP client.
|
||||
const mavenRequester = "maven"
|
||||
|
||||
// setEcosystemHeaders stamps the version, requester, auth and correlation
|
||||
// headers common to every outgoing ecosystem request. token may be empty,
|
||||
// which means the transport itself is trusted (loopback or unix socket).
|
||||
// ecosystemHTTP is the JSON transport every ecosystem client shares: one base
|
||||
// URL, one bearer token, and the header set the contract requires on each
|
||||
// request. Nexus and Praxis differ only in the service name and the version
|
||||
// header, so both embed this rather than repeating build, send and classify.
|
||||
type ecosystemHTTP struct {
|
||||
service string // "nexus", "praxis" — the name errors and traces carry
|
||||
versionHeader string
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newEcosystemHTTP(service, versionHeader, baseURL string) ecosystemHTTP {
|
||||
return ecosystemHTTP{
|
||||
service: service,
|
||||
versionHeader: versionHeader,
|
||||
baseURL: baseURL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// setHeaders stamps the version, requester, auth and correlation headers common
|
||||
// to every outgoing ecosystem request. The token may be empty, which means the
|
||||
// transport itself is trusted (loopback or unix socket).
|
||||
//
|
||||
// The correlation ID is read from the context and never minted here. Minting
|
||||
// one per request sent the far side an ID that existed nowhere on this side,
|
||||
// and gave a single multi-hop action as many unrelated IDs as it made calls.
|
||||
// Callers that start an action assign the ID once (handleHexisAct,
|
||||
// The correlation ID is read from the request's own context and never minted
|
||||
// here. Minting one per request sent the far side an ID that existed nowhere on
|
||||
// this side, and gave a single multi-hop action as many unrelated IDs as it
|
||||
// made calls. Callers that start an action assign the ID once (handleHexisAct,
|
||||
// handlePraxisAct, resolveEntityReference) and every hop inherits it.
|
||||
func setEcosystemHeaders(req *http.Request, ctx context.Context, versionHeader, token string) {
|
||||
func (t *ecosystemHTTP) setHeaders(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set(t.versionHeader, ecosystemAPIVersion)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-Requested-By", mavenRequester)
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if t.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+t.token)
|
||||
}
|
||||
if id := correlationIDFromCtx(ctx); id != "" {
|
||||
if id := correlationIDFromCtx(req.Context()); id != "" {
|
||||
req.Header.Set("X-Correlation-ID", id)
|
||||
}
|
||||
}
|
||||
|
||||
// call sends one request and decodes the JSON answer into out, which may be nil
|
||||
// when the body carries nothing worth reading. op is the logical operation name
|
||||
// for errors and traces: the path carries the query string, and after entity
|
||||
// scoping that means an entity id in every log line built from the error, next
|
||||
// to a trace that redacts far less than that.
|
||||
//
|
||||
// Every failure is an *ecosystemError, including the transport and decode ones.
|
||||
// Some of these paths mutate remote state, and the question worth answering
|
||||
// afterwards is whether the call never left or was refused.
|
||||
func (t *ecosystemHTTP) call(ctx context.Context, method, op, path string, payload, out any) error {
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, t.baseURL+path, body)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
t.setHeaders(req)
|
||||
|
||||
resp, err := t.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError(t.service, op, resp.StatusCode)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: t.service, Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out.
|
||||
func (t *ecosystemHTTP) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
return t.call(ctx, http.MethodGet, op, path, nil, out)
|
||||
}
|
||||
|
||||
// postJSON posts a JSON payload and decodes the JSON answer into out.
|
||||
func (t *ecosystemHTTP) postJSON(ctx context.Context, op, path string, payload, out any) error {
|
||||
return t.call(ctx, http.MethodPost, op, path, payload, out)
|
||||
}
|
||||
|
||||
// ecosystemError is the typed failure every ecosystem client returns, so
|
||||
// callers can tell a transport failure from a refusal from a contract
|
||||
// mismatch without matching on message text. The distinction matters:
|
||||
@@ -107,17 +180,60 @@ func httpError(service, op string, status int) *ecosystemError {
|
||||
}
|
||||
}
|
||||
|
||||
// hexisStatusTexts maps the http.StatusText spelling back to its code, for the
|
||||
// failure statuses a Hexis call can plausibly answer with. It is the inverse of
|
||||
// what the vendored client threw away.
|
||||
var hexisStatusTexts = func() map[string]int {
|
||||
codes := []int{
|
||||
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
|
||||
http.StatusNotFound, http.StatusMethodNotAllowed, http.StatusNotAcceptable,
|
||||
http.StatusRequestTimeout, http.StatusConflict, http.StatusGone,
|
||||
http.StatusUnprocessableEntity, http.StatusUpgradeRequired,
|
||||
http.StatusTooManyRequests, http.StatusInternalServerError,
|
||||
http.StatusNotImplemented, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout,
|
||||
}
|
||||
m := make(map[string]int, len(codes))
|
||||
for _, c := range codes {
|
||||
m[http.StatusText(c)] = c
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// hexisError re-wraps an error from the vendored Hexis client as an
|
||||
// *ecosystemError, so a Hexis failure classifies the same way a Nexus or Praxis
|
||||
// one does and ecosystemGap can tell a refused credential from an outage.
|
||||
//
|
||||
// This is a boundary adapter and it is not the fix anyone would choose. The
|
||||
// Hexis client lives in another repository and returns
|
||||
// fmt.Errorf("%s: %s", http.StatusText(status), body) for every status at or
|
||||
// above 400, so the status text is the only signal that survives — the correct
|
||||
// fix is a typed error carrying the code, and Maven cannot land it unilaterally
|
||||
// (Vikunja #587, docs/plans/20-two-artifacts-and-neither-is-spring.md). Parsing
|
||||
// here is bounded: the message's first colon-delimited segment is the status
|
||||
// text verbatim, no status text contains a colon, and anything unrecognised —
|
||||
// "do request: ...", "create request: ..." — is a transport failure and is left
|
||||
// at status 0, which is exactly what Unreachable() means.
|
||||
func hexisError(op string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
return err
|
||||
}
|
||||
head, _, _ := strings.Cut(err.Error(), ": ")
|
||||
return &ecosystemError{
|
||||
Service: "hexis", Op: op, Status: hexisStatusTexts[head], Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
type nexusClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
ecosystemHTTP
|
||||
}
|
||||
|
||||
func newNexusClient(url string) *nexusClient {
|
||||
return &nexusClient{
|
||||
baseURL: url,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
return &nexusClient{newEcosystemHTTP("nexus", "X-Nexus-Version", url)}
|
||||
}
|
||||
|
||||
// withToken sets the bearer token sent on every request. Returns the client so
|
||||
@@ -176,62 +292,26 @@ func (c *nexusClient) Resolve(ctx context.Context, query string, types []string)
|
||||
body["types"] = types
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/resolve", bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, httpError("nexus", "resolve", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result nexusResolveResult
|
||||
if err := json.Unmarshal(bodyBytes, &result); err != nil {
|
||||
return nil, &ecosystemError{Service: "nexus", Op: "resolve", Status: resp.StatusCode, Err: err}
|
||||
if err := c.postJSON(ctx, "resolve", "/api/v1/resolve", body, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (c *nexusClient) Health(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/health", nil)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Nexus-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "nexus", Op: "health", Err: err}
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError("nexus", "health", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
return c.getJSON(ctx, "health", "/health", nil)
|
||||
}
|
||||
|
||||
// praxisClient talks to the Praxis HTTP tools API. Maven must not open Praxis's
|
||||
// SQLite store directly (ecosystem invariant: no component reads another's DB),
|
||||
// so attention/changes/lifecycle all go over this HTTP contract against praxisd.
|
||||
type praxisClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
ecosystemHTTP
|
||||
}
|
||||
|
||||
func newPraxisClient(url string) *praxisClient {
|
||||
return &praxisClient{
|
||||
baseURL: url,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
return &praxisClient{newEcosystemHTTP("praxis", "X-Praxis-Version", url)}
|
||||
}
|
||||
|
||||
func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
@@ -239,30 +319,6 @@ func (c *praxisClient) withToken(token string) *praxisClient {
|
||||
return c
|
||||
}
|
||||
|
||||
// getJSON performs a GET and decodes the JSON body into out. op is the logical
|
||||
// operation name for errors and traces: the path carries the query string, and
|
||||
// after entity scoping that means an entity id in every log line built from the
|
||||
// error, next to a trace that redacts far less than that.
|
||||
func (c *praxisClient) getJSON(ctx context.Context, op, path string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// praxisAttention — an attention response in either of the two shapes Praxis
|
||||
// may send (Vikunja #540).
|
||||
//
|
||||
@@ -376,31 +432,14 @@ type praxisItem struct {
|
||||
// postItemAction posts {"item_id": id} to a Praxis tools lifecycle endpoint
|
||||
// and decodes the resulting item. Shared by Surface/Acknowledge/Resolve/Ignore.
|
||||
func (c *praxisClient) postItemAction(ctx context.Context, op, path, itemID string) (*praxisItem, error) {
|
||||
return c.postJSON(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
return c.postItem(ctx, op, path, map[string]any{"item_id": itemID})
|
||||
}
|
||||
|
||||
// postJSON posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
// Every failure is a *ecosystemError, including the transport and decode ones:
|
||||
// these are the paths that mutate remote state, and the question worth
|
||||
// answering afterwards is whether the call never left or was refused.
|
||||
func (c *praxisClient) postJSON(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
body, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
setEcosystemHeaders(req, ctx, "X-Praxis-Version", c.token)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, httpError("praxis", op, resp.StatusCode)
|
||||
}
|
||||
// postItem posts a body to a Praxis lifecycle endpoint and decodes the item.
|
||||
func (c *praxisClient) postItem(ctx context.Context, op, path string, payload map[string]any) (*praxisItem, error) {
|
||||
var out praxisItem
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, &ecosystemError{Service: "praxis", Op: op, Status: resp.StatusCode, Err: err}
|
||||
if err := c.postJSON(ctx, op, path, payload, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
@@ -425,7 +464,7 @@ func (c *praxisClient) Ignore(ctx context.Context, itemID string) (*praxisItem,
|
||||
}
|
||||
|
||||
func (c *praxisClient) Pin(ctx context.Context, itemID string, pinned bool) (*praxisItem, error) {
|
||||
return c.postJSON(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
return c.postItem(ctx, "pin", "/api/v1/tools/pin", map[string]any{"item_id": itemID, "pinned": pinned})
|
||||
}
|
||||
|
||||
func (c *praxisClient) GetItem(ctx context.Context, itemID string) (*praxisItem, error) {
|
||||
@@ -540,6 +579,7 @@ func (w *ecosystemWiring) discoverCapabilities(ctx context.Context, entityID str
|
||||
}
|
||||
caps, err := w.hexis.Capabilities(ctx, entityID)
|
||||
if err != nil {
|
||||
err = hexisError("capabilities", err)
|
||||
log.Printf("ecosystem: hexis capabilities error: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -567,7 +607,10 @@ func (w *ecosystemWiring) executeCapability(ctx context.Context, capabilityID, t
|
||||
|
||||
exec, err := w.hexis.Execute(ctx, req)
|
||||
if err != nil {
|
||||
return correlationID, fmt.Errorf("execute: %w", err)
|
||||
// A classified dependency failure. The two returns below are NOT: an
|
||||
// execution that ran and failed is the command failing, not Hexis
|
||||
// degrading, and it keeps its plain error so the caller says so.
|
||||
return correlationID, hexisError("execute", err)
|
||||
}
|
||||
if exec.Status == "succeeded" {
|
||||
return correlationID, nil
|
||||
|
||||
@@ -22,13 +22,25 @@ import (
|
||||
// already knows which one it was talking to — it records the same name in the
|
||||
// trace (Vikunja #521).
|
||||
const (
|
||||
serviceNexus = "Nexus"
|
||||
serviceHexis = "Hexis"
|
||||
serviceNexus = "Nexus"
|
||||
servicePraxis = "Praxis"
|
||||
serviceHexis = "Hexis"
|
||||
)
|
||||
|
||||
// serviceVars — the one-key map the eco_down and eco_denied lines take.
|
||||
func serviceVars(name string) map[string]string { return map[string]string{"name": name} }
|
||||
|
||||
// ecosystemGap names the service that failed. A rejected credential gets its
|
||||
// own line, because a wrong token looks exactly like an outage to him and
|
||||
// "try again" is advice that will never work. Every degrade path reads through
|
||||
// here, so all of them name the service and none of them guesses instead.
|
||||
func ecosystemGap(service string, err error) string {
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(service))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(service))
|
||||
}
|
||||
|
||||
// praxisCapability is one arm of the Praxis act dispatch. This is an interface
|
||||
// rather than a map[string]func because each arm carries its own state: the
|
||||
// verb aliases it answers to, the trace name it records, and its own reply
|
||||
@@ -127,10 +139,16 @@ func (h *reactiveHandler) handlePraxisAct(ctx context.Context, dec router.Decisi
|
||||
// praxisItemAction is the shared shape of the item-lifecycle capabilities: take
|
||||
// an item id from the value slot, call one Praxis endpoint, trace the result.
|
||||
type praxisItemAction struct {
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
failure string // reply when the Praxis call errors
|
||||
verbs []string
|
||||
ask string // reply when no item id was given
|
||||
op string // trace + log name of the operation
|
||||
// failure is the first half of the reply when the Praxis call errors: which
|
||||
// operation did not happen. ecosystemGap supplies the second half, which
|
||||
// names Praxis and splits a refused token from an outage — those two used to
|
||||
// produce the identical sentence and neither said "Praxis" (Vikunja #588).
|
||||
// The verb is kept alongside the service name because the trace is the only
|
||||
// other place it exists, and he is not reading the trace.
|
||||
failure string
|
||||
success string
|
||||
call func(ctx context.Context, px *praxisClient, id string) error
|
||||
}
|
||||
@@ -147,7 +165,7 @@ func (a praxisItemAction) handle(ctx context.Context, h *reactiveHandler, px *pr
|
||||
log.Printf("ecosystem: praxis %s %s: %v", a.op, id, err)
|
||||
h.recordEcosystemTrace(ctx, "praxis", a.op, traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"item_id": id}))
|
||||
return a.failure
|
||||
return a.failure + " " + ecosystemGap(servicePraxis, err)
|
||||
}
|
||||
h.recordPraxisTrace(ctx, a.op, started, map[string]any{"item_id": id})
|
||||
return a.success
|
||||
@@ -199,16 +217,10 @@ func (listAttentionCapability) handle(ctx context.Context, h *reactiveHandler, p
|
||||
}
|
||||
parts = append(parts, s)
|
||||
|
||||
// Speaking an item surfaces it, it does not acknowledge it
|
||||
// (ECOSYSTEM-SPEC.md §2.3: surfaced != acknowledged). Best-effort:
|
||||
// a failed surface call must not block delivering the digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
// Recorded in the order she says them, and only for items she could
|
||||
// say: an item skipped above has no position in what he heard (#516).
|
||||
// Recorded in the order she says them, and only for items she could
|
||||
// say: an item skipped above has no position in what he heard (#516).
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.rememberSurfaced(spoken)
|
||||
@@ -298,12 +310,7 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
// trace gets. A trace that stores a rune count next to a log line
|
||||
// storing the runes is not redacted at all.
|
||||
log.Printf("ecosystem: entity attention resolve %s: %v", redactSubject(subject), err)
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
return phraser.A(phraser.EcoAmbiguous, map[string]string{"items": strings.Join(ambiguous, ", ")})
|
||||
@@ -339,19 +346,24 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
})
|
||||
|
||||
var parts []string
|
||||
var spoken []string
|
||||
for _, item := range items {
|
||||
title, _ := item["title"].(string)
|
||||
if title == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, title)
|
||||
// Same surfaced != acknowledged rule as the unscoped digest.
|
||||
if id, ok := item["id"].(string); ok && id != "" {
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
if id := surfaceSpoken(ctx, px, item); id != "" {
|
||||
spoken = append(spoken, id)
|
||||
}
|
||||
}
|
||||
// The scoped digest is a list she read out, so it replaces the positional
|
||||
// memory exactly as the unscoped one does. It used to surface these items
|
||||
// and remember none of them, which left the previous digest live: "отметь
|
||||
// второй как сделанное" then indexed into a list he had not just heard and
|
||||
// transitioned somebody else's item (docs/ecosystem.md — a wrong guess here
|
||||
// transitions the wrong item).
|
||||
h.rememberSurfaced(spoken)
|
||||
if known := h.localFactsForEntity(ctx, entityID); known != "" {
|
||||
parts = append(parts, known)
|
||||
}
|
||||
@@ -366,6 +378,22 @@ func (entityAttentionCapability) handle(ctx context.Context, h *reactiveHandler,
|
||||
return phraser.A(phraser.AttentionListEntity, map[string]string{"name": displayName, "items": strings.Join(parts, "; ")})
|
||||
}
|
||||
|
||||
// surfaceSpoken marks an item she just read out as surfaced. Speaking an item
|
||||
// surfaces it, it does not acknowledge it (ECOSYSTEM-SPEC.md §2.3: surfaced !=
|
||||
// acknowledged), so this calls Surface and nothing else. Best effort: a failed
|
||||
// surface call must not block delivering the digest. Returns the item id, or ""
|
||||
// when the item carried none.
|
||||
func surfaceSpoken(ctx context.Context, px *praxisClient, item map[string]any) string {
|
||||
id, _ := item["id"].(string)
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
if _, err := px.Surface(ctx, id); err != nil {
|
||||
log.Printf("ecosystem: praxis surface %s: %v", id, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// scopedToEntity drops items that carry an entity_id other than the one asked
|
||||
// about, and reports whether the response can be trusted as scoped at all. An
|
||||
// item without an entity_id is kept only when at least one sibling carries the
|
||||
@@ -474,6 +502,14 @@ func traceStatusForError(err error) string {
|
||||
return traceFailed
|
||||
}
|
||||
|
||||
// nexusResolveFailed records a resolve that failed and returns the named gap.
|
||||
// The subject is his words, so the trace keeps a rune count and not the runes.
|
||||
func (h *reactiveHandler) nexusResolveFailed(ctx context.Context, subject string, started time.Time, err error) string {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
return ecosystemGap(serviceNexus, err)
|
||||
}
|
||||
|
||||
// redactSubject reduces a user utterance to something safe to persist in a
|
||||
// trace: its length only. Traces are diagnostics, and his words are not
|
||||
// diagnostics — the correlation ID is what ties a trace to the turn.
|
||||
@@ -532,26 +568,31 @@ func unauthorizedEcosystemError(err error) bool {
|
||||
return errors.As(err, &ee) && ee.Unauthorized()
|
||||
}
|
||||
|
||||
// isEcosystemError reports a failure that belongs to the service rather than to
|
||||
// what was asked of it: a call that never landed, or one the far side refused.
|
||||
// It separates "Hexis is down" from "the restart failed".
|
||||
func isEcosystemError(err error) bool {
|
||||
var ee *ecosystemError
|
||||
return errors.As(err, &ee)
|
||||
}
|
||||
|
||||
// traceErrorFields describes an ecosystemError for a trace without leaking the
|
||||
// payload: the HTTP status and the failure class, nothing else.
|
||||
func traceErrorFields(err error) map[string]any {
|
||||
fields := map[string]any{}
|
||||
fields := map[string]any{"class": "error"}
|
||||
var ee *ecosystemError
|
||||
if errors.As(err, &ee) {
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
default:
|
||||
fields["class"] = "error"
|
||||
}
|
||||
if !errors.As(err, &ee) {
|
||||
return fields
|
||||
}
|
||||
fields["class"] = "error"
|
||||
fields["http_status"] = ee.Status
|
||||
switch {
|
||||
case ee.Unauthorized():
|
||||
fields["class"] = "unauthorized"
|
||||
case ee.ContractMismatch():
|
||||
fields["class"] = "contract_mismatch"
|
||||
case ee.Unreachable():
|
||||
fields["class"] = "unreachable"
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -636,16 +677,11 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
res := h.resolveEntityCandidates(ctx, entityReferences(dec))
|
||||
subject, entityID, displayName, ambiguous, err := res.subject, res.entityID, res.displayName, res.ambiguous, res.err
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceStatusForError(err), started,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"subject": redactSubject(subject)}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceNexus))
|
||||
}
|
||||
// A genuine Nexus dependency failure, not "no such entity" — stop here
|
||||
// and report degradation rather than silently falling through to the
|
||||
// local command executor (ECOSYSTEM-SPEC.md: services degrade
|
||||
// independently, never a silent all-clear).
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceNexus))
|
||||
return h.nexusResolveFailed(ctx, subject, started, err)
|
||||
}
|
||||
if len(ambiguous) > 0 {
|
||||
h.recordEcosystemTrace(ctx, "nexus", "resolve", traceAmbig, started,
|
||||
@@ -668,10 +704,7 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
if err != nil {
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceStatusForError(err), discovered,
|
||||
mergeFields(traceErrorFields(err), map[string]any{"entity_id": entityID}))
|
||||
if unauthorizedEcosystemError(err) {
|
||||
return phraser.A(phraser.EcoDenied, serviceVars(serviceHexis))
|
||||
}
|
||||
return phraser.A(phraser.EcoDown, serviceVars(serviceHexis))
|
||||
return ecosystemGap(serviceHexis, err)
|
||||
}
|
||||
h.recordEcosystemTrace(ctx, "hexis", "capabilities", traceOK, discovered,
|
||||
map[string]any{"entity_id": entityID, "count": len(caps)})
|
||||
@@ -745,6 +778,9 @@ func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decisio
|
||||
entityID: entityID,
|
||||
displayName: displayName,
|
||||
expiry: h.now().Add(confirmTTL),
|
||||
// This action's id, so the execution the confirm authorises is
|
||||
// joined to the resolve and the discovery that proposed it.
|
||||
correlationID: correlationIDFromCtx(ctx),
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.recordEcosystemTrace(ctx, "hexis", "confirmation", tracePending, started,
|
||||
@@ -769,6 +805,14 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI
|
||||
mergeFields(traceErrorFields(err), map[string]any{
|
||||
"entity_id": entityID, "capability": capName, "causation_id": causationID,
|
||||
}))
|
||||
// Hexis never answering, or answering "no", is a gap in Hexis and is
|
||||
// named as one — a refused token said "не получилось выполнить команду"
|
||||
// here and sent him to debug a capability that was never reached
|
||||
// (Vikunja #587). An execution that genuinely ran and failed is not an
|
||||
// ecosystemError and keeps the command-level line.
|
||||
if isEcosystemError(err) {
|
||||
return ecosystemGap(serviceHexis, err)
|
||||
}
|
||||
return phraser.A(phraser.ActFailEntity, map[string]string{"name": displayName})
|
||||
}
|
||||
// One record per hop: the second write this used to make said the same
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A refused credential and an outage are different answers, and on the Hexis
|
||||
// path only one of them used to be said. These tests pin the difference at both
|
||||
// Hexis sites: the discovery hop and the execute hop (Vikunja #587). The Praxis
|
||||
// half of the same defect is in praxis_gap_test.go.
|
||||
//
|
||||
// unreachableURL is a port nothing listens on, which is what "the service is
|
||||
// down" looks like from inside a call: the connection is refused, no HTTP
|
||||
// answer is ever produced, and ecosystemError.Unreachable() is true.
|
||||
const unreachableURL = "http://127.0.0.1:1"
|
||||
|
||||
func denied(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDenied, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
func down(service, reply string) bool {
|
||||
return phraser.IsA(phraser.EcoDown, serviceVars(service), reply)
|
||||
}
|
||||
|
||||
// hexisGapHandler wires a handler whose Nexus resolves cleanly and whose Hexis
|
||||
// is the caller's to break. hexisURL is taken separately so a test can point it
|
||||
// at a dead port.
|
||||
func hexisGapHandler(t *testing.T, nexusURL, hexisURL string) *reactiveHandler {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
now := time.Now()
|
||||
return &reactiveHandler{
|
||||
api: ipc.NewStoreAPI(st),
|
||||
dataStore: st,
|
||||
now: func() time.Time { return now },
|
||||
ecosystem: stubEcosystem(nexusURL, hexisURL),
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscovery401IsDeniedNotDown — the discovery hop.
|
||||
//
|
||||
// The vendored Hexis client returns a plain fmt.Errorf for every status at or
|
||||
// above 400, so errors.As for *ecosystemError never matched and every failure
|
||||
// fell through to the outage line. "Hexis is down" for a rejected token sends
|
||||
// him to inspect a service that is running fine.
|
||||
func TestHexisDiscovery401IsDeniedNotDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
hexis.SetFault(401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis discovery: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
if !strings.Contains(reply, serviceHexis) {
|
||||
t.Errorf("reply does not name Hexis: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisDiscoveryOutageIsDownNotDenied — the other half of the same fork.
|
||||
// Without this the fix could pass by calling everything a refused credential.
|
||||
func TestHexisDiscoveryOutageIsDownNotDenied(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
h := hexisGapHandler(t, nexus.URL, unreachableURL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("connection refused from hexis: got %q, want the outage line naming Hexis", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecute401IsDeniedNotCommandFailure — the execute hop, which did not
|
||||
// consult ecosystemGap at all and named neither the service nor the cause.
|
||||
func TestHexisExecute401IsDeniedNotCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
// Discovery stays healthy; only the execute endpoint refuses. A blanket
|
||||
// fault would never reach the site under test.
|
||||
hexis.SetRouteFault("/api/v1/execute", 401)
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !denied(serviceHexis, reply) {
|
||||
t.Fatalf("401 from hexis execute: got %q, want the denied line naming Hexis", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecuteOutageIsDown — same site, the other classification.
|
||||
//
|
||||
// Discovery and execution share one base URL, so the outage has to be scoped to
|
||||
// the execute endpoint rather than to the server: it answers capabilities
|
||||
// normally and drops the connection on execute, which is what the client sees
|
||||
// when the far side dies mid-call. That produces no HTTP status at all, which is
|
||||
// what Unreachable() means.
|
||||
func TestHexisExecuteOutageIsDown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/v1/execute" {
|
||||
conn, _, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Errorf("hijack: %v", err)
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(caps))
|
||||
}))
|
||||
t.Cleanup(hexis.Close)
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if !down(serviceHexis, reply) {
|
||||
t.Fatalf("dropped connection on hexis execute: got %q, want the outage line", reply)
|
||||
}
|
||||
if denied(serviceHexis, reply) {
|
||||
t.Error("an outage must not be reported as a refused credential")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisExecutionFailedStaysCommandFailure — the boundary of the fix. Hexis
|
||||
// answering 200 with a failed execution is the command failing, not Hexis
|
||||
// degrading, and it must keep the command-level line rather than accusing a
|
||||
// healthy service of being down.
|
||||
func TestHexisExecutionFailedStaysCommandFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", muzickIndexer, "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": true})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecutionFailed("exec_1", "unit refused to start"))
|
||||
h := hexisGapHandler(t, nexus.URL, hexis.URL)
|
||||
|
||||
reply := h.handleHexisAct(ctx, actDec("muzick indexer"))
|
||||
if down(serviceHexis, reply) || denied(serviceHexis, reply) {
|
||||
t.Fatalf("a failed execution must not be reported as an ecosystem gap, got %q", reply)
|
||||
}
|
||||
if !phraser.IsA(phraser.ActFailEntity, map[string]string{"name": muzickIndexer}, reply) {
|
||||
t.Fatalf("want the command-failure line, got %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -115,3 +115,83 @@ func TestFakeNexus_FaultInjectionThenRecovery(t *testing.T) {
|
||||
t.Fatalf("expected success once nexus recovers, got %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisEntityAttention_RemembersWhatItReadOut: the scoped digest is a list
|
||||
// she read out, so a positional follow-up must land on one of ITS items. It
|
||||
// surfaced them and remembered none, which left the previous digest live and
|
||||
// sent "отметь второй" at somebody else's item.
|
||||
func TestPraxisEntityAttention_RemembersWhatItReadOut(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
scoped := fixturePraxisAttentionScoped("ent_muzick",
|
||||
map[string]any{"id": "item_scoped_1", "title": "indexer wedged"})
|
||||
praxis := newFakePraxis(t, scoped)
|
||||
h := ecoHandler(t, nexus, praxis, nil)
|
||||
|
||||
// A digest from an earlier turn, still the positional memory.
|
||||
h.rememberSurfaced([]string{"item_stale"})
|
||||
|
||||
reply := h.handlePraxisAct(ctx, router.Decision{
|
||||
Intent: router.IntentAct,
|
||||
Slots: router.Slots{Fn: "entity_attention", HasFn: true, Value: "muzick indexer"},
|
||||
})
|
||||
if !strings.Contains(reply, "indexer wedged") {
|
||||
t.Fatalf("expected the scoped item to be read out, got %q", reply)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
surfaced := append([]string(nil), h.surfacedItems...)
|
||||
h.mu.Unlock()
|
||||
if len(surfaced) != 1 || surfaced[0] != "item_scoped_1" {
|
||||
t.Fatalf("scoped digest must replace the positional memory, got %v", surfaced)
|
||||
}
|
||||
|
||||
// The follow-up resolves against what he just heard, not the stale list.
|
||||
if reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "last")); reply == "" {
|
||||
t.Fatal("positional follow-up should have been claimed by praxis")
|
||||
}
|
||||
var body string
|
||||
for _, r := range praxis.Requests() {
|
||||
if r.Method == "POST" && r.Path == "/api/v1/tools/resolve" {
|
||||
body = string(r.Body)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, "item_scoped_1") {
|
||||
t.Fatalf("resolve must transition the item she read out, posted %q", body)
|
||||
}
|
||||
if strings.Contains(body, "item_stale") {
|
||||
t.Fatal("resolve transitioned an item from a previous digest")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexisConfirm_KeepsOneCorrelationIDPerAction: the confirm arrives on a
|
||||
// later turn with a context of its own. The contract mints one id per action,
|
||||
// so the execution it authorises must still be joinable to the resolve and the
|
||||
// discovery that proposed it — it recorded a fresh id and no causation at all.
|
||||
func TestHexisConfirm_KeepsOneCorrelationIDPerAction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"))
|
||||
caps := fixtureHexisCapabilities(map[string]any{"id": "cap_restart", "name": "restart", "read_only": false})
|
||||
hexis := newFakeHexis(t, caps, fixtureHexisExecuted("exec_1", "succeeded"))
|
||||
h := ecoHandler(t, nexus, nil, hexis)
|
||||
|
||||
if reply := h.handleHexisAct(ctx, actDec("restart")); !strings.Contains(reply, "да") {
|
||||
t.Fatalf("mutating capability must ask for confirmation, got %q", reply)
|
||||
}
|
||||
resolve := findTrace(t, h, "nexus", "resolve")
|
||||
if resolve == nil || resolve.CorrelationID == "" {
|
||||
t.Fatalf("expected a nexus resolve trace carrying a correlation id, got %+v", resolve)
|
||||
}
|
||||
|
||||
if _, handled := h.resolveConfirm(ctx, "да"); !handled {
|
||||
t.Fatal("confirm should have been claimed")
|
||||
}
|
||||
exec := findTrace(t, h, "hexis", "execute")
|
||||
if exec == nil {
|
||||
t.Fatal("expected a hexis execute trace")
|
||||
}
|
||||
if exec.CausationID != resolve.CorrelationID {
|
||||
t.Fatalf("confirmed execution must cite the action that proposed it: causation %q, action %q",
|
||||
exec.CausationID, resolve.CorrelationID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,8 @@ type factEnrichmentWorker struct {
|
||||
nextTry map[int64]time.Time // fact id → earliest retry
|
||||
}
|
||||
|
||||
// enrichmentScanLimit bounds how deep a single tick (or status report) walks
|
||||
// the pending queue looking for facts whose backoff has elapsed. The queue is
|
||||
// enrichmentScanLimit bounds how deep a single tick walks the pending queue
|
||||
// looking for facts whose backoff has elapsed. The queue is
|
||||
// ordered by id, so without a scan the oldest facts hold every batch slot
|
||||
// whether or not they are eligible, and one permanently failing fact stalls
|
||||
// every younger one behind it.
|
||||
@@ -75,8 +75,8 @@ func newFactEnrichmentWorker(st *store.Store, eco *ecosystemWiring, interval tim
|
||||
// has been down all day must be visible as a backlog, not as facts that
|
||||
// silently never got tagged.
|
||||
//
|
||||
// All three numbers describe the same set of rows, the first
|
||||
// enrichmentScanLimit pending facts. Counting Pending over a thousand rows
|
||||
// All three numbers describe the same set of rows, whatever is still pending
|
||||
// out of the first enrichmentScanLimit facts. Counting Pending over a thousand rows
|
||||
// while counting InBackoff over the twenty that reached the head of a batch
|
||||
// described two different populations under one struct.
|
||||
type enrichmentStatus struct {
|
||||
@@ -86,13 +86,22 @@ type enrichmentStatus struct {
|
||||
Scanned int // rows the other three counts were taken over
|
||||
}
|
||||
|
||||
// status reads the queue and counts over it. For a caller with no batch in
|
||||
// hand — anything asking the worker how it is doing from outside the tick.
|
||||
func (w *factEnrichmentWorker) status(ctx context.Context) enrichmentStatus {
|
||||
var st enrichmentStatus
|
||||
pending, err := w.store.PendingFactResolutions(ctx, enrichmentScanLimit)
|
||||
if err != nil {
|
||||
log.Printf("factenrichment: status: %v", err)
|
||||
return st
|
||||
return enrichmentStatus{}
|
||||
}
|
||||
return w.statusOf(pending)
|
||||
}
|
||||
|
||||
// statusOf counts over a batch the caller already has. The batch is the query
|
||||
// the tick already ran, so reporting the backlog costs no second read of the
|
||||
// scan limit — up to a thousand rows, on a database that serialises them.
|
||||
func (w *factEnrichmentWorker) statusOf(pending []store.Fact) enrichmentStatus {
|
||||
var st enrichmentStatus
|
||||
st.Pending = len(pending)
|
||||
st.Scanned = len(pending)
|
||||
w.mu.Lock()
|
||||
@@ -144,17 +153,24 @@ func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
||||
}
|
||||
w.forgetDeparted(pending)
|
||||
skipped, failed, attempted := 0, 0, 0
|
||||
// A resolved fact leaves the pending queue, so the batch in hand overstates
|
||||
// the backlog by however many succeeded. Drop them here rather than
|
||||
// re-reading the queue to find out.
|
||||
remaining := make([]store.Fact, 0, len(pending))
|
||||
for _, f := range pending {
|
||||
if attempted >= w.batch {
|
||||
break
|
||||
remaining = append(remaining, f)
|
||||
continue
|
||||
}
|
||||
if !w.due(f.ID) {
|
||||
skipped++
|
||||
remaining = append(remaining, f)
|
||||
continue
|
||||
}
|
||||
attempted++
|
||||
if !w.resolveOne(ctx, f) {
|
||||
failed++
|
||||
remaining = append(remaining, f)
|
||||
}
|
||||
}
|
||||
if failed > 0 {
|
||||
@@ -164,7 +180,7 @@ func (w *factEnrichmentWorker) tick(ctx context.Context) {
|
||||
// Report the backlog every tick, not only when something failed: the
|
||||
// stalled state worth seeing is the one where nothing failed because
|
||||
// nothing was attempted.
|
||||
if st := w.status(ctx); st.Pending > 0 {
|
||||
if st := w.statusOf(remaining); st.Pending > 0 {
|
||||
log.Printf("factenrichment: %d facts pending entity resolution, %d in backoff, worst attempt %d (scanned %d)",
|
||||
st.Pending, st.InBackoff, st.MaxAttempts, st.Scanned)
|
||||
}
|
||||
|
||||
+30
-112
@@ -252,6 +252,23 @@ func run(args []string) error {
|
||||
// envelope per successful intake write.
|
||||
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
|
||||
|
||||
// depsNow reads whatever the current path has wired. Both boot paths build
|
||||
// the CoreAPI and start the workers from this one value, so neither can
|
||||
// hold a field the other misses. See cmd/mavend/boot.go.
|
||||
depsNow := func() bootDeps {
|
||||
return bootDeps{
|
||||
coreFor: coreFor,
|
||||
tl: tl,
|
||||
evBus: evBus,
|
||||
voiceW: voiceW,
|
||||
st: st,
|
||||
factWorker: factWorker,
|
||||
evalWorker: evalWorker,
|
||||
feedWkr: feedWkr,
|
||||
crawlWkr: crawlWkr,
|
||||
}
|
||||
}
|
||||
|
||||
if !locked {
|
||||
rules = wireRules(cfg)
|
||||
gatherer = wireGatherer(st, cfg, rules)
|
||||
@@ -284,26 +301,7 @@ func run(args []string) error {
|
||||
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
coreAPI = &daemonAPI{
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
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
|
||||
}
|
||||
coreAPI = newDaemonAPI(depsNow())
|
||||
} else {
|
||||
// locked mode: no real store yet, so there's no meaningful CoreAPI to
|
||||
// serve. srv.Check below is the actual guard — every CoreAPI call is
|
||||
@@ -364,6 +362,9 @@ func run(args []string) error {
|
||||
if !locked {
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
// Inbound telegram (V-637). Dark unless the telegram block says intake,
|
||||
// and it reads one chat.
|
||||
wireTelegramIntake(ctx, &wg, coreAPI, cfg)
|
||||
// Vision + the media blob store (Vikunja #252). Both stay dark without a
|
||||
// media block; MethodDescribeImage answers ErrUnknownMethod then.
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
@@ -494,23 +495,14 @@ func run(args []string) error {
|
||||
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
|
||||
|
||||
// Swap the CoreAPI from the locked placeholder to the real store adapter.
|
||||
newAPI := &daemonAPI{
|
||||
CoreAPI: coreFor(),
|
||||
getTrace: tl.trace,
|
||||
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
|
||||
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
|
||||
getEvents: intakeEventsFn(evBus),
|
||||
getDecisions: turnDecisionsFn(voiceW),
|
||||
seedStore: seedStoreIfAllowed(st),
|
||||
}
|
||||
if voiceW != nil && voiceW.handler != nil {
|
||||
newAPI.chatFn = voiceW.handler.handleText
|
||||
voiceW.handler.upgradeAPI(newAPI)
|
||||
}
|
||||
newAPI := newDaemonAPI(depsNow())
|
||||
srv.SetAPI(newAPI)
|
||||
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
|
||||
wireMailIntake(srv, st, phr, cfg, evBus)
|
||||
wireModelSwap(srv, phr, cfg)
|
||||
// Same on the unlock path, with the API that has just replaced the
|
||||
// locked placeholder (V-637).
|
||||
wireTelegramIntake(ctx, &wg, newAPI, cfg)
|
||||
keeper := wireVision(ctx, &wg, srv, st, embedderOf(voiceW), cfg)
|
||||
wireCapture(ctx, &wg, srv, keeper, st, voiceW, phr, cfg)
|
||||
// Voice identification (Vikunja #255). Enrolment plumbing only until a
|
||||
@@ -518,59 +510,10 @@ func run(args []string) error {
|
||||
// block, so no wire path takes a voiceprint on a default box.
|
||||
wireSpeaker(srv, st, cfg)
|
||||
|
||||
// Start voice server.
|
||||
if voiceW != nil {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if err := voiceW.server.Serve(); err != nil && !errors.Is(err, net.ErrClosed) {
|
||||
log.Printf("voice serve: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("mavend: voice listening on %s", voiceW.server.Addr())
|
||||
}
|
||||
|
||||
// Start tick loop.
|
||||
go func() {
|
||||
tl.run(ctx)
|
||||
}()
|
||||
|
||||
// Start fact-entity enrichment worker.
|
||||
go func() {
|
||||
factWorker.run(ctx)
|
||||
}()
|
||||
|
||||
// Start background memory evaluation (nil unless configured).
|
||||
if evalWorker != nil {
|
||||
go func() {
|
||||
evalWorker.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Start feed reading (nil unless configured).
|
||||
if feedWkr != nil {
|
||||
go func() {
|
||||
feedWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Start the watched-page crawls (nil unless configured).
|
||||
if crawlWkr != nil {
|
||||
go func() {
|
||||
crawlWkr.run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// Keep MCP connections alive (nil unless configured).
|
||||
if voiceW != nil && voiceW.mcp != nil {
|
||||
go voiceW.mcp.run(ctx)
|
||||
}
|
||||
|
||||
// Re-enumerate the house for new devices (nil unless configured).
|
||||
if voiceW != nil && voiceW.home != nil {
|
||||
go voiceW.home.run(ctx)
|
||||
}
|
||||
// The voice server and every background worker, on the outer wg
|
||||
// so shutdown waits for them. This used to be nine bare
|
||||
// `go func()` calls and a shadowed WaitGroup (V-639).
|
||||
startBackground(ctx, &wg, depsNow())
|
||||
|
||||
dl.unlock(st)
|
||||
log.Printf("mavend: unlocked via passkey assertion")
|
||||
@@ -585,33 +528,8 @@ func run(args []string) error {
|
||||
})
|
||||
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 {
|
||||
goWorker(&wg, func() { tl.run(ctx) })
|
||||
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) })
|
||||
}
|
||||
startBackground(ctx, &wg, depsNow())
|
||||
}
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
)
|
||||
|
||||
// correctionFragment reports that an utterance replaces a referent and states
|
||||
// nothing of its own: "нет, не маме, а папе" (V-576).
|
||||
//
|
||||
// Measured on the box 2026-08-06, that fragment routed to note and was filed.
|
||||
// It is not a repair either, because it names no intent, so parseRepair
|
||||
// declines it and repair.go is the wrong place to catch it. This is the note
|
||||
// path saying it has nothing to store.
|
||||
//
|
||||
// Three offline tests, all of them narrow on purpose. The sentence opens with a
|
||||
// refusal word from the lexicon, it carries the contrastive "а" that names the
|
||||
// replacement, and no token in it is a verb form. The verb test is what keeps
|
||||
// the rule off real notes: "нет, я не поеду, а останусь" says something, and a
|
||||
// Russian verb carries its own subject and tense.
|
||||
func correctionFragment(utterance string) bool {
|
||||
toks := repairTokens(strings.ToLower(strings.TrimSpace(utterance)))
|
||||
if len(toks) < 3 {
|
||||
return false
|
||||
}
|
||||
if !refusalWord(toks[0]) {
|
||||
return false
|
||||
}
|
||||
var negated, contrasted bool
|
||||
for _, tok := range toks[1:] {
|
||||
switch tok {
|
||||
case "не", "not":
|
||||
negated = true
|
||||
case "а", "but":
|
||||
contrasted = true
|
||||
}
|
||||
if morph.IsVerbForm(tok) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return negated && contrasted
|
||||
}
|
||||
|
||||
// refusalWord reports that a token is a one-word refusal. The lexicon set holds
|
||||
// phrases too ("не надо"), and those are not what opens a correction.
|
||||
func refusalWord(tok string) bool {
|
||||
for _, w := range lexicon.ConfirmNo() {
|
||||
if strings.ContainsRune(w, ' ') {
|
||||
continue
|
||||
}
|
||||
if w == tok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
func TestCorrectionFragment(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
want bool
|
||||
}{
|
||||
{"нет, не маме, а папе", true},
|
||||
{"Нет, не маме — а папе", true},
|
||||
{"no, not mom, but dad", true},
|
||||
// States something of its own, so it is his to keep.
|
||||
{"нет, я не поеду, а останусь дома", false},
|
||||
{"нет", false},
|
||||
{"не маме, а папе", false}, // no refusal word opening it
|
||||
{"нет, маме и папе", false}, // nothing negated
|
||||
{"нет, не маме", false}, // nothing put in its place
|
||||
{"запомни что кофе закончился", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := correctionFragment(c.utterance); got != c.want {
|
||||
t.Errorf("correctionFragment(%q) = %v, want %v", c.utterance, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newNoteHandler(t *testing.T) (*reactiveHandler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
now := time.Now()
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
dataStore: st,
|
||||
}
|
||||
return h, st
|
||||
}
|
||||
|
||||
// TestNoteBodyIsTheUtterance — the stored body comes from the utterance, never
|
||||
// from Slots.Text, which the LLM router is free to write anything into (V-576).
|
||||
func TestNoteBodyIsTheUtterance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{
|
||||
Intent: router.IntentNote,
|
||||
Utterance: "купил хлеб и молоко",
|
||||
Slots: router.Slots{Text: "ты поедешь на дачу"},
|
||||
}
|
||||
if reply := h.applyAction(ctx, dec); reply != "" {
|
||||
t.Fatalf("applyAction = %q, want empty", reply)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 1 || notes[0].Text != dec.Utterance {
|
||||
t.Fatalf("stored note = %+v, want body %q", notes, dec.Utterance)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoteBodyIsStable — the same utterance twice stores the same text.
|
||||
func TestNoteBodyIsStable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{Intent: router.IntentNote, Utterance: "кофе закончился"}
|
||||
h.applyAction(ctx, dec)
|
||||
h.applyAction(ctx, dec)
|
||||
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 2 {
|
||||
t.Fatalf("notes = %d, want 2", len(notes))
|
||||
}
|
||||
if notes[0].Text != notes[1].Text || notes[0].Text != dec.Utterance {
|
||||
t.Fatalf("bodies differ: %q vs %q", notes[0].Text, notes[1].Text)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCorrectionFragmentWritesNoNote — a correction with nothing behind it is
|
||||
// not a note, and she says so instead of filing it (V-576).
|
||||
func TestCorrectionFragmentWritesNoNote(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newNoteHandler(t)
|
||||
|
||||
dec := router.Decision{Intent: router.IntentNote, Utterance: "нет, не маме, а папе"}
|
||||
if reply := h.applyAction(ctx, dec); reply != nothingToCorrectReply {
|
||||
t.Fatalf("reply = %q, want %q", reply, nothingToCorrectReply)
|
||||
}
|
||||
notes, err := st.RecentNotes(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("RecentNotes: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %+v, want none", notes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// A Praxis lifecycle failure used to return a hardcoded constant that named the
|
||||
// verb and never the service, so an outage, a refused token and a contract
|
||||
// mismatch all produced the identical sentence (Vikunja #588). The helpers and
|
||||
// the Hexis half of the same defect are in ecosystem_gap_test.go.
|
||||
|
||||
func TestPraxisLifecycle401NamesPraxis(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
reply := h.handlePraxisAct(ctx, praxisItemDec("resolve_item", "item_1"))
|
||||
if !strings.Contains(reply, servicePraxis) {
|
||||
t.Fatalf("praxis failure does not name Praxis: %q", reply)
|
||||
}
|
||||
if !strings.Contains(reply, phraser.A(phraser.EcoDenied, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("401 from praxis: got %q, want the denied line", reply)
|
||||
}
|
||||
// The verb that did not happen is still said: the trace is the only other
|
||||
// place it exists and he is not reading the trace.
|
||||
if !strings.Contains(reply, "не получилось отметить сделанным.") {
|
||||
t.Errorf("reply dropped the operation that failed: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPraxisLifecycleOutageDiffersFrom401 — the identity that was the bug.
|
||||
func TestPraxisLifecycleOutageDiffersFrom401(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
praxis := newFakePraxis(t, fixturePraxisAttentionItems())
|
||||
h := newPraxisTestHandler(t, praxis)
|
||||
|
||||
praxis.SetFault(401)
|
||||
refused := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
|
||||
h.ecosystem = &ecosystemWiring{praxis: newPraxisClient(unreachableURL)}
|
||||
outage := h.handlePraxisAct(ctx, praxisItemDec("acknowledge_item", "item_1"))
|
||||
|
||||
if refused == outage {
|
||||
t.Fatalf("a refused token and an outage still say the same thing: %q", refused)
|
||||
}
|
||||
if !strings.Contains(outage, phraser.A(phraser.EcoDown, serviceVars(servicePraxis))) {
|
||||
t.Fatalf("praxis outage: got %q, want the outage line naming Praxis", outage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
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
|
||||
}{
|
||||
{router.SourceWorld, "что такое TCP?", "weather"},
|
||||
{router.SourceWorld, "сколько будет 17 на 23?", "weather"},
|
||||
{router.SourceWorld, "кто такой Линус Торвальдс?", "personal"},
|
||||
{router.SourceRecall, "какой у меня любимый язык?", "feeds"},
|
||||
{router.SourceCalendar, "что в календаре на завтра?", "weather"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
walk, skipped := queryWalk(c.dest)
|
||||
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)
|
||||
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)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// A reminder commits only when three things are answered: what to say, what
|
||||
// time to say it, and what day (owner's rule, 2026-08-06, V-579). Anything
|
||||
// missing is asked about, and nothing missing is filled from the clock.
|
||||
//
|
||||
// "напомни завтра в 3 заказать цветы" has the what and the day and an hour that
|
||||
// could be either half of the day, so she asks which 3. "напомни в 9 вечера
|
||||
// разгрузить стиралку" has the what and an unambiguous hour and no day, so she
|
||||
// asks which day. Today being a valid reading is not the same as him saying it.
|
||||
//
|
||||
// Two things are already whole and are not asked about. A time that admits one
|
||||
// reading is not queried for its half of the day, so "завтра в 15:00" commits.
|
||||
// And an interval is an instant, so "через час" carries all three by itself.
|
||||
type whenGap string
|
||||
|
||||
const (
|
||||
whenComplete whenGap = ""
|
||||
whenNoHour whenGap = "hour"
|
||||
whenAmbiguousHour whenGap = "part_of_day"
|
||||
whenNoDay whenGap = "day"
|
||||
)
|
||||
|
||||
// whenGapOf reads the request and names the first thing about its time that he
|
||||
// has not said. hasTime is whether a parser could read an instant out of it,
|
||||
// which is necessary and not sufficient: the parser answers a dayless "в 9"
|
||||
// with a day it picked.
|
||||
func whenGapOf(text string, hasTime bool) whenGap {
|
||||
if !router.NamesAnHour(text) {
|
||||
return whenNoHour
|
||||
}
|
||||
if router.NamesAnInterval(text) {
|
||||
return whenComplete
|
||||
}
|
||||
if !hasTime {
|
||||
return whenNoHour
|
||||
}
|
||||
if router.HourIsAmbiguous(text) {
|
||||
return whenAmbiguousHour
|
||||
}
|
||||
if !router.NamesADay(text) {
|
||||
return whenNoDay
|
||||
}
|
||||
return whenComplete
|
||||
}
|
||||
|
||||
// whenQuestion is what she asks for each gap. Every one of them opens with the
|
||||
// current time, because she is reasoning from it and he cannot check that
|
||||
// reasoning unless he hears it. The hour deck varies with the attempt, like
|
||||
// every other slot; the other two say one thing and there is only one way to
|
||||
// say it.
|
||||
//
|
||||
// taken is what his last turn added, in his own words, and it goes between the
|
||||
// clock and the question (V-593). It is empty whenever his turn moved nothing
|
||||
// forward, which is the case where repeating the question verbatim is honest.
|
||||
func whenQuestion(gap whenGap, attempt int, now time.Time, taken string) (string, bool) {
|
||||
clock := fmt.Sprintf("Сейчас %s.", now.Format("15:04"))
|
||||
if taken != "" {
|
||||
clock += " " + taken
|
||||
}
|
||||
switch gap {
|
||||
case whenNoHour:
|
||||
q, ok := clarifyQuestionFor(dialogue.SlotTime, attempt)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return clock + " " + q, true
|
||||
case whenAmbiguousHour:
|
||||
return clock + " Это утра или вечера?", true
|
||||
case whenNoDay:
|
||||
return clock + " В какой день?", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// whenKnown — the three things he has to say about the time, and whether the
|
||||
// words so far say them. Read off the same predicates whenGapOf reads, so the
|
||||
// two cannot disagree about what is still open.
|
||||
type whenKnown struct{ hour, part, day bool }
|
||||
|
||||
func whenKnownOf(text string, hasTime bool) whenKnown {
|
||||
if !router.NamesAnHour(text) {
|
||||
return whenKnown{}
|
||||
}
|
||||
if router.NamesAnInterval(text) {
|
||||
return whenKnown{hour: true, part: true, day: true}
|
||||
}
|
||||
if !hasTime {
|
||||
return whenKnown{}
|
||||
}
|
||||
return whenKnown{
|
||||
hour: true,
|
||||
part: !router.HourIsAmbiguous(text),
|
||||
day: router.NamesADay(text),
|
||||
}
|
||||
}
|
||||
|
||||
// movedForward reports whether b says something a did not.
|
||||
func (a whenKnown) movedForward(b whenKnown) bool {
|
||||
return (!a.hour && b.hour) || (!a.part && b.part) || (!a.day && b.day)
|
||||
}
|
||||
|
||||
// whenTakenLine — the acknowledgement in front of a re-ask, in the words he
|
||||
// just used (V-593).
|
||||
//
|
||||
// It is an echo and never a restatement, for the same reason the fact
|
||||
// confirmation is (V-592): a 1.7B asked to say a Russian sentence back invents.
|
||||
// Its only job is evidence that the turn between two asks was heard, so after
|
||||
// "на 9" and then "на завтра" she does not ask "утра или вечера?" twice
|
||||
// byte-identically while he wonders whether the microphone is on.
|
||||
func whenTakenLine(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.TrimRight(text, " \t.,!?;:")
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
return "Поняла: " + text + "."
|
||||
}
|
||||
|
||||
// whenTextOf is everything he has said about when, the original request plus
|
||||
// every answer he has given to a question about it.
|
||||
//
|
||||
// The answers are kept apart from the utterance on purpose. The utterance is
|
||||
// the reminder's payload, so folding "завтра" into it would have her read the
|
||||
// day back to him at the time she says it. And a time answer has to be read
|
||||
// against the request rather than alone: "завтра" names no hour, and the hour
|
||||
// it belongs to is the one she is already holding.
|
||||
func whenTextOf(q *dialogue.PendingQuestion) string {
|
||||
if q.WhenText == "" {
|
||||
return q.Utterance
|
||||
}
|
||||
return strings.TrimSpace(q.Utterance + " " + q.WhenText)
|
||||
}
|
||||
|
||||
// slotStillMissing reports whether a slot is still open. Every slot but the
|
||||
// reminder's time is open when it is empty; the time is open until all three of
|
||||
// what he must say about it are said.
|
||||
func slotStillMissing(slot dialogue.Slot, utterance string, s dialogue.Slots) bool {
|
||||
if len(dialogue.StillMissing([]dialogue.Slot{slot}, s)) > 0 {
|
||||
return true
|
||||
}
|
||||
return slot == dialogue.SlotTime && whenGapOf(utterance, s.HasTime) != whenComplete
|
||||
}
|
||||
|
||||
// readWhen reads the instant out of what he has said about the time, newest
|
||||
// statement first.
|
||||
//
|
||||
// The request plus his latest answer is tried before the whole history, and
|
||||
// that order is what makes a correction win: "нет, сегодня в 15:00" after "в
|
||||
// 11:00" must land on 15:00, and a parser reading left to right off the joined
|
||||
// history would find the 11 he just took back. The history is the fallback,
|
||||
// because an answer often completes an earlier one rather than replacing it -
|
||||
// "вечера" says which 9, and alone it names no hour at all.
|
||||
func (h *reactiveHandler) readWhen(ctx context.Context, intent router.Intent, q *dialogue.PendingQuestion, text string) (time.Time, bool) {
|
||||
latest := strings.TrimSpace(q.Utterance + " " + text)
|
||||
if router.NamesAnHour(text) {
|
||||
if w := h.extractor.Extract(ctx, intent, latest, h.now()); w.HasTime {
|
||||
return w.Time, true
|
||||
}
|
||||
}
|
||||
if w := h.extractor.Extract(ctx, intent, whenTextOf(q), h.now()); w.HasTime {
|
||||
return w.Time, true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// asksAboutTime reports whether the parked question is one about when.
|
||||
func asksAboutTime(missing []dialogue.Slot) bool {
|
||||
for _, s := range missing {
|
||||
if s == dialogue.SlotTime {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillOpen reports whether any of the slots she asked about is still unsaid.
|
||||
func stillOpen(missing []dialogue.Slot, utterance string, s dialogue.Slots) bool {
|
||||
for _, slot := range missing {
|
||||
if slotStillMissing(slot, utterance, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stillMissingFor is missingFor's engine, in wantedSlots order. It reads the
|
||||
// utterance as well as the slots, which plain StillMissing cannot: whether an
|
||||
// hour is ambiguous is a fact about the words, not about the instant they
|
||||
// parsed to.
|
||||
func stillMissingFor(intent router.Intent, utterance string, s dialogue.Slots) []dialogue.Slot {
|
||||
var out []dialogue.Slot
|
||||
for _, want := range wantedSlots[intent] {
|
||||
if slotStillMissing(want, utterance, s) {
|
||||
out = append(out, want)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -35,6 +36,11 @@ type routedTurn struct {
|
||||
utterance string
|
||||
intent router.Intent
|
||||
at time.Time
|
||||
// traceID — the persisted trace of this turn, stamped after the fact by
|
||||
// stampLastTurn. 0 when nothing persisted, and then a spoken correction
|
||||
// still teaches the classifier: the durable label is the half that needs a
|
||||
// row to point at (V-636).
|
||||
traceID int64
|
||||
}
|
||||
|
||||
// repairWindow — how long a turn stays correctable. Long enough that he can
|
||||
@@ -54,6 +60,13 @@ const repairWindow = 5 * time.Minute
|
||||
// said. The set's note in lexicon_ru_v1.json carries the same reasoning.
|
||||
var repairMarkers = lexicon.RepairMarkers()
|
||||
|
||||
// repairNegatives — "she got it wrong" with no target. Matched against the whole
|
||||
// utterance, because these are complete sentences and the markers above are
|
||||
// fragments: "это не" needs an intent word after it, "не так поняла" does not.
|
||||
// Substring matching here would claim "не так" out of any sentence containing it
|
||||
// (V-636).
|
||||
var repairNegatives = lexicon.RepairNegatives()
|
||||
|
||||
// repairIntents — the words he uses for each intent, as dictionary forms. They
|
||||
// used to be prefixes ("заметк"), which is what a prefix list costs: "команд"
|
||||
// also matched "командировка", and "факт" matched "фактически". morph.SameWord
|
||||
@@ -147,6 +160,18 @@ func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
||||
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
||||
}
|
||||
|
||||
// stampLastTurn attaches the trace id to the turn a correction would point at.
|
||||
// It cannot be done in recordTurn: the trace is written when the turn ends, and
|
||||
// recordTurn runs in the middle of it.
|
||||
func (h *reactiveHandler) stampLastTurn(utterance string, traceID int64) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.lastRouted == nil || h.lastRouted.utterance != utterance {
|
||||
return
|
||||
}
|
||||
h.lastRouted.traceID = traceID
|
||||
}
|
||||
|
||||
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -157,6 +182,56 @@ func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||
return last
|
||||
}
|
||||
|
||||
// resolveUntargetedRepair handles the cheap half of a spoken correction: he says
|
||||
// she got it wrong and does not say what it should have been (V-636).
|
||||
//
|
||||
// It is worth having on its own. V-630 made the target optional on the web for
|
||||
// the same reason: a turn marked wrong with no target is a usable negative, and
|
||||
// requiring the target would cost the correction he was willing to give. Voice
|
||||
// needs it more than the web does — naming an intent aloud means saying
|
||||
// "заметка" or "факт", which is Maven's vocabulary and not his.
|
||||
//
|
||||
// Nothing is redone and the classifier is not taught. There is no target, so
|
||||
// there is nothing to redo it as and nothing to teach. Only the label is written,
|
||||
// and she says so, because a correction he cannot see reads as one that was
|
||||
// dropped.
|
||||
func (h *reactiveHandler) resolveUntargetedRepair(ctx context.Context, text string) (string, bool) {
|
||||
if !isRepairNegative(text) {
|
||||
return "", false
|
||||
}
|
||||
last := h.takeLastTurn()
|
||||
if last == nil || h.now().Sub(last.at) > repairWindow {
|
||||
return "", false
|
||||
}
|
||||
if last.traceID == 0 {
|
||||
// No row to point at, so there is no label to write and nothing this
|
||||
// resolver can do. Routing the words normally is the honest outcome.
|
||||
return "", false
|
||||
}
|
||||
h.labelCorrection(ctx, last, "")
|
||||
log.Printf("voice: repair — %q marked wrong, no target given", last.utterance)
|
||||
return phraser.A(phraser.RepairNoted, nil), true
|
||||
}
|
||||
|
||||
// isRepairNegative matches the whole utterance, minus a leading "нет" and any
|
||||
// trailing punctuation. "нет, не так" is the shortest one he says.
|
||||
func isRepairNegative(utterance string) bool {
|
||||
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||
s = strings.TrimRight(s, " .!?")
|
||||
for _, p := range []string{"нет,", "нет", "no,", "no"} {
|
||||
if rest := strings.TrimSpace(strings.TrimPrefix(s, p)); rest != s && rest != "" {
|
||||
s = rest
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, n := range repairNegatives {
|
||||
if s == n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resolveRepair handles a spoken correction of the previous turn: teach the
|
||||
// classifier, redo the request under the corrected intent, and say so.
|
||||
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
|
||||
@@ -182,6 +257,7 @@ func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (strin
|
||||
learned = false
|
||||
}
|
||||
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
|
||||
h.labelCorrection(ctx, last, string(corrected))
|
||||
|
||||
dec := router.Decision{
|
||||
Utterance: last.utterance,
|
||||
@@ -207,3 +283,24 @@ func repairLine(say string, learned bool) string {
|
||||
}
|
||||
return "поняла, это " + say + " — запомнила."
|
||||
}
|
||||
|
||||
// labelCorrection promotes a spoken correction into routing_labels, the same
|
||||
// table the /chat gesture writes (V-630, V-636).
|
||||
//
|
||||
// Two sinks and not one, because they keep different things. CorrectMisroute
|
||||
// appends a classifier seed, which is what makes the NEXT turn better today.
|
||||
// The label is what a fitted head trains on later, it survives the 14-day
|
||||
// transcript, and until now only the web produced any. A sample that only ever
|
||||
// held typed turns would skew to whatever he happens to be at a keyboard for,
|
||||
// and voice is where the hard cases are.
|
||||
//
|
||||
// Best-effort and silent. He has already been told the correction landed, and a
|
||||
// second sink failing is not his problem to hear about.
|
||||
func (h *reactiveHandler) labelCorrection(ctx context.Context, last *routedTurn, shouldBe string) {
|
||||
if h.api == nil || last == nil || last.traceID == 0 {
|
||||
return
|
||||
}
|
||||
if err := h.api.CorrectTurn(ctx, last.traceID, shouldBe); err != nil {
|
||||
log.Printf("voice: repair: could not label trace %d: %v", last.traceID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
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
|
||||
// answer from the stub, which is what keeps a turn from breaking on the model.
|
||||
func (r *llmReplier) Reply(d router.Decision) string {
|
||||
func (r *llmReplier) Reply(ctx context.Context, d router.Decision) string {
|
||||
if d.Clarify {
|
||||
// The deck, not the stub's single sentence: a clarify she cannot turn
|
||||
// into a question is the line he hears most often when she misses him,
|
||||
@@ -31,14 +31,22 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
||||
// a generation to say something this small.
|
||||
return clarifyMissedLine(d)
|
||||
}
|
||||
out, err := r.p.PhraseReply(context.Background(), d)
|
||||
if d.Intent == router.IntentFact {
|
||||
// A captured fact is confirmed by echoing him, and the model is not
|
||||
// asked (V-592). It has nothing to phrase FROM: replyContext hands it
|
||||
// "записала факт: water \"drank\"", so every Russian word in the reply
|
||||
// was the model's own invention, and on 2026-08-06 that was "Проверила,
|
||||
// что ты выпел стакан воды" for "я выпил воды".
|
||||
return phraser.FactAck(d.Utterance)
|
||||
}
|
||||
out, err := r.p.PhraseReply(ctx, d)
|
||||
if err != nil || out == "" {
|
||||
return r.stub.Reply(d)
|
||||
return r.stub.Reply(ctx, d)
|
||||
}
|
||||
// The persona checks, on the live path (personaguard.go). A reply that
|
||||
// leaks reasoning or calls him "вы" is worse than a flat one.
|
||||
if _, ok := guardSpoken("reply", out); !ok {
|
||||
return r.stub.Reply(d)
|
||||
return r.stub.Reply(ctx, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) {
|
||||
|
||||
func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil)
|
||||
got := r.Reply(router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
got := r.Reply(context.Background(), router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}})
|
||||
if 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.
|
||||
func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: "я всё поняла"}, nil)
|
||||
got := r.Reply(router.Decision{Clarify: true, Utterance: "мгм"})
|
||||
got := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "мгм"})
|
||||
if got == "я всё поняла" {
|
||||
t.Fatal("a clarify must not be phrased by the model")
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||
t.Errorf("on clarify: got %q, want %q", got, want)
|
||||
}
|
||||
// Two different misses do not sound identical.
|
||||
if same := r.Reply(router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||
if same := r.Reply(context.Background(), router.Decision{Clarify: true, Utterance: "а"}); same == got {
|
||||
t.Log("two utterances hashed to the same line, which is allowed but should be rare")
|
||||
}
|
||||
}
|
||||
@@ -60,14 +60,14 @@ func TestLLMReplierClarifyReadsTheDeck(t *testing.T) {
|
||||
// produce, which is the same claim without pinning one wording.
|
||||
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
|
||||
t.Helper()
|
||||
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
|
||||
if got := r.Reply(context.Background(), d); !phraser.IsAck(key, nil, got) {
|
||||
t.Errorf("on %s: got %q, want a %q line", what, got, key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||
t.Helper()
|
||||
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
||||
got, want := r.Reply(context.Background(), d), voice.NewStubReplier().Reply(context.Background(), d)
|
||||
if got != want {
|
||||
t.Errorf("on %s: got %q, want stub %q", what, got, want)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// mavend/routingtrace.go — persisting the per-turn decision record (V-629).
|
||||
//
|
||||
// internal/decision keeps a 25-turn in-memory ring and persisted nothing, on the
|
||||
// argument that a turn record is read minutes later or never. The owner reversed
|
||||
// that on 06-08-2026, because the routing heads (V-546) cannot be fitted or
|
||||
// calibrated without real utterances and there is no other source of them. The
|
||||
// reversal is written down in docs/plans/21-persisting-the-routing-trace.md.
|
||||
//
|
||||
// The ring stays. It is what /trace reads, it is fast, and it is what a test that
|
||||
// wired no store still gets. This file is the second sink beside it, and it is
|
||||
// nil unless the daemon has a database — no store, no trace, no error.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// traceWriter is the seam the handler persists through. store.Store satisfies
|
||||
// it. nil ⇒ the ring is the only sink, which is the pre-V-629 behaviour exactly.
|
||||
type traceWriter interface {
|
||||
WriteRoutingTrace(ctx context.Context, tr store.RoutingTrace) (int64, error)
|
||||
}
|
||||
|
||||
// traceSink wraps the store, or returns nil when there is none. A typed nil
|
||||
// pointer assigned straight into the interface would be non-nil and would panic
|
||||
// on the first turn, which is the classic shape of this bug.
|
||||
func traceSink(s *store.Store) traceWriter {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// The trace id rides the context, the same seam querysource.go uses and for the
|
||||
// same reason: handleText answers every reach through one string, and threading
|
||||
// a second value through the whole action dispatch would change a signature the
|
||||
// mic, telegram and the web all share. A caller that wants the id asks for a
|
||||
// sink; the mic path does not, and pays nothing.
|
||||
type traceIDKey struct{}
|
||||
|
||||
type traceIDSink struct {
|
||||
mu sync.Mutex
|
||||
id int64
|
||||
}
|
||||
|
||||
func (s *traceIDSink) note(id int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.id = id
|
||||
}
|
||||
|
||||
// ID is the persisted trace for the turn, or 0 when nothing was persisted.
|
||||
func (s *traceIDSink) ID() int64 {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.id
|
||||
}
|
||||
|
||||
// withTraceIDSink returns a context that collects the persisted trace id, and
|
||||
// the sink to read after the turn has answered.
|
||||
func withTraceIDSink(ctx context.Context) (context.Context, *traceIDSink) {
|
||||
sink := &traceIDSink{}
|
||||
return context.WithValue(ctx, traceIDKey{}, sink), sink
|
||||
}
|
||||
|
||||
func noteTraceID(ctx context.Context, id int64) {
|
||||
if sink, ok := ctx.Value(traceIDKey{}).(*traceIDSink); ok {
|
||||
sink.note(id)
|
||||
}
|
||||
}
|
||||
|
||||
// pruneTracesOnStart enforces retention once at wiring time. Pruning on write
|
||||
// alone is not enough: a box that goes quiet for a month keeps every row until
|
||||
// the next sixty-fourth turn, and "kept for fourteen days" would then be true
|
||||
// only of a box in daily use. Called for its effect and never blocks a start.
|
||||
func pruneTracesOnStart(s *store.Store, now time.Time) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := s.PruneRoutingTraces(ctx, now.Add(-store.RoutingTraceRetention)); err != nil {
|
||||
log.Printf("routing trace: prune on start: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// persistDecision writes one finished record. It takes the same *decision.Record
|
||||
// the ring takes, so the two sinks cannot disagree about what the turn did.
|
||||
//
|
||||
// Errors are logged and swallowed. A trace is diagnostic and training data, and
|
||||
// a failed insert must never change what the owner hears.
|
||||
func (h *reactiveHandler) persistDecision(turnCtx context.Context, rec *decision.Record, src turnSource) {
|
||||
ctx := turnCtx
|
||||
if h.traces == nil || rec == nil || strings.TrimSpace(rec.Utterance) == "" {
|
||||
return
|
||||
}
|
||||
// Detached from the turn's context, and bounded on its own. Two reasons, and
|
||||
// the first is the one that matters: the turn is over by the time this runs,
|
||||
// so a caller that hung up or timed out would cancel the insert, and the turn
|
||||
// he abandoned halfway is exactly the one worth having. The second is that a
|
||||
// write must not hold the reply, so it gets a second and no more.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
|
||||
defer cancel()
|
||||
claims, err := json.Marshal(rec.Claims)
|
||||
if err != nil {
|
||||
log.Printf("routing trace: marshal claims: %v", err)
|
||||
return
|
||||
}
|
||||
tr := store.RoutingTrace{
|
||||
Ts: rec.Ts,
|
||||
Utterance: rec.Utterance,
|
||||
Source: string(src),
|
||||
Winner: rec.Winner,
|
||||
Intent: wonIntent(rec),
|
||||
ClaimedBeforeHead: claimedBeforeHead(rec),
|
||||
EncoderID: h.encoderID,
|
||||
Outcome: wonAt(rec, decision.StageAction),
|
||||
Claims: claims,
|
||||
}
|
||||
id, err := h.traces.WriteRoutingTrace(ctx, tr)
|
||||
if err != nil {
|
||||
log.Printf("routing trace: write: %v", err)
|
||||
return
|
||||
}
|
||||
// The id goes back to whoever asked for it, so /chat can offer a correction
|
||||
// on the turn it is already showing (V-630). Noted on the ORIGINAL context,
|
||||
// not the detached one above: the sink belongs to the caller's turn.
|
||||
noteTraceID(turnCtx, id)
|
||||
// And the spoken path, which has no reply to hang a badge on: a correction
|
||||
// said out loud points at the previous turn, so it needs that turn's row
|
||||
// (V-636, repair.go).
|
||||
h.stampLastTurn(rec.Utterance, id)
|
||||
}
|
||||
|
||||
// wonIntent — what the winning claimant made the turn. Read from the claim
|
||||
// rather than from the route, because a pre-route resolver wins without routing
|
||||
// and its intent is the honest answer to "what was this turn".
|
||||
func wonIntent(rec *decision.Record) string {
|
||||
for _, c := range rec.Claims {
|
||||
if c.Outcome == decision.Won && c.Intent != "" {
|
||||
return c.Intent
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// wonAt — the claimant that won at one stage. The action stage is what actually
|
||||
// produced the reply, which is a different question from what was routed: a
|
||||
// route that reached a gap and a route that ran are not the same turn.
|
||||
func wonAt(rec *decision.Record, stage string) string {
|
||||
for _, c := range rec.Claims {
|
||||
if c.Stage == stage && c.Outcome == decision.Won {
|
||||
return c.Claimant
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// claimedBeforeHead — a pre-route resolver or a stage-0 grammar answered, so the
|
||||
// turn teaches nothing about the classifier. Those are a large share of real
|
||||
// traffic, and fitting a head on them would fit it to the grammars rather than
|
||||
// to him. Recorded per turn rather than filtered on write, because which share
|
||||
// that is happens to be the number V-632 needs to know.
|
||||
func claimedBeforeHead(rec *decision.Record) bool {
|
||||
stage, _, ok := strings.Cut(rec.Winner, ":")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return stage == decision.StagePreRoute || stage == decision.StageZero
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/decision"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// A real turn leaves a persisted trace, not only a ring entry. This is the whole
|
||||
// of V-629: without one there is nothing to fit the routing heads from.
|
||||
func TestTurnPersistsTrace(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.traces = traceSink(h.dataStore)
|
||||
h.encoderID = "hash-1024"
|
||||
|
||||
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("persisted %d traces, want 1", len(got))
|
||||
}
|
||||
tr := got[0]
|
||||
if tr.Utterance != "сколько сейчас времени" {
|
||||
t.Errorf("utterance %q", tr.Utterance)
|
||||
}
|
||||
if tr.Source != string(sourceText) {
|
||||
t.Errorf("source %q, want %q", tr.Source, sourceText)
|
||||
}
|
||||
// A stage-0 clock rule answers this one, so the turn teaches the classifier
|
||||
// nothing and the trace has to say so.
|
||||
if !tr.ClaimedBeforeHead {
|
||||
t.Errorf("claimed_before_head false on winner %q", tr.Winner)
|
||||
}
|
||||
if tr.EncoderID != "hash-1024" {
|
||||
t.Errorf("encoder_id %q", tr.EncoderID)
|
||||
}
|
||||
if len(tr.Claims) < 3 {
|
||||
t.Errorf("claims %s: the losers and the never-asked are the point", tr.Claims)
|
||||
}
|
||||
}
|
||||
|
||||
// No store, no trace, and no panic. A typed nil pointer in the interface would
|
||||
// pass the nil check and die on the first turn.
|
||||
func TestNoStoreNoTrace(t *testing.T) {
|
||||
ring := decision.NewRing()
|
||||
h := traceHandler(t, ring)
|
||||
h.traces = traceSink(nil)
|
||||
|
||||
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
if len(ring.Recent(5)) != 1 {
|
||||
t.Error("the ring is still the first sink and must still hold the turn")
|
||||
}
|
||||
}
|
||||
|
||||
// An empty utterance writes nothing. A blank row carries no label and no
|
||||
// diagnosis, and it is his words the retention bound exists for.
|
||||
func TestEmptyUtteranceIsNotPersisted(t *testing.T) {
|
||||
h := traceHandler(t, decision.NewRing())
|
||||
h.traces = traceSink(h.dataStore)
|
||||
h.persistDecision(context.Background(), &decision.Record{Utterance: " "}, sourceText)
|
||||
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("persisted %d traces for a blank utterance", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
var _ traceWriter = (*store.Store)(nil)
|
||||
|
||||
// The trace id rides back to the caller, which is what makes a correction one
|
||||
// gesture: /chat already has the id, so saying "that was wrong" costs a button
|
||||
// and no lookup (V-630).
|
||||
func TestTurnHandsBackItsTraceID(t *testing.T) {
|
||||
h := traceHandler(t, decision.NewRing())
|
||||
h.traces = traceSink(h.dataStore)
|
||||
|
||||
ctx, sink := withTraceIDSink(context.Background())
|
||||
if reply := h.handleText(ctx, "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
id := sink.ID()
|
||||
if id == 0 {
|
||||
t.Fatal("no trace id came back, so /chat can offer no correction")
|
||||
}
|
||||
// And it names the turn that just ran, so the correction lands on the right
|
||||
// utterance.
|
||||
if err := h.dataStore.CorrectTurn(context.Background(), id, "query", h.now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, err := h.dataStore.RoutingLabels(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(labels) != 1 || labels[0].Utterance != "сколько сейчас времени" {
|
||||
t.Fatalf("labels %+v, want the turn that just ran", labels)
|
||||
}
|
||||
}
|
||||
|
||||
// A turn nobody asked the id of costs nothing, which is the mic path.
|
||||
func TestTurnWithNoSinkStillPersists(t *testing.T) {
|
||||
h := traceHandler(t, decision.NewRing())
|
||||
h.traces = traceSink(h.dataStore)
|
||||
|
||||
if reply := h.handleText(context.Background(), "web", "сколько сейчас времени"); reply == "" {
|
||||
t.Fatal("turn produced no reply")
|
||||
}
|
||||
got, err := h.dataStore.RecentRoutingTraces(context.Background(), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("persisted %d traces, want 1", len(got))
|
||||
}
|
||||
}
|
||||
+19
-9
@@ -17,6 +17,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/say"
|
||||
)
|
||||
|
||||
@@ -73,22 +74,26 @@ func mentionsUnknownPlace(u string) bool {
|
||||
// date for a day she did not understand.
|
||||
const onlyNearDaysReply = "я считаю только сегодня, завтра, послезавтра и вчера — про другие дни пока не скажу."
|
||||
|
||||
// dayWords — day references the calendar parser cannot resolve. A weekday name
|
||||
// or a "через …" phrase means he asked about a specific other day.
|
||||
var dayWords = []string{
|
||||
"понедельник", "вторник", "сред", "четверг", "пятниц", "суббот", "воскресен",
|
||||
"через", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
|
||||
}
|
||||
|
||||
// mentionsUnknownDay reports whether the question names a day the calendar
|
||||
// parser could not resolve. Mirror of mentionsUnknownPlace: it exists only to
|
||||
// pick an honest reply over a confidently wrong one.
|
||||
//
|
||||
// Only called after ParseCalendarDate has already failed, so "завтра" and the
|
||||
// other words it does know never reach here.
|
||||
//
|
||||
// The weekday half was a list of STEMS matched with strings.Contains until
|
||||
// V-581 — "сред", "пятниц", "суббот". That is the hand-written Russian pattern
|
||||
// the sweep of 2026-08-04 took out, and it was wrong in the way such a pattern
|
||||
// always is: "среди", "средство" and "средний" all contain "сред", so a question
|
||||
// carrying any of them was answered with onlyNearDaysReply instead of the date.
|
||||
// Whole tokens now, and the weekday itself is router.WeekdayIndex, which reads
|
||||
// the lexicon and asks the dictionary about the case.
|
||||
func mentionsUnknownDay(u string) bool {
|
||||
for _, w := range dayWords {
|
||||
if strings.Contains(u, w) {
|
||||
for _, tok := range quietTokens(u) {
|
||||
if tok == "через" {
|
||||
return true
|
||||
}
|
||||
if _, ok := router.WeekdayIndex(tok); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -149,6 +154,11 @@ func hasDurationWords(u string) bool {
|
||||
// Used by the query handler when answering "когда я это сделал?"-style questions.
|
||||
func formatTime(t time.Time) string {
|
||||
now := time.Now()
|
||||
// The argument is a fact's Ts, which the store hands back as UTC. Only the
|
||||
// last branch names a wall clock, and it named the store's until V-614: an
|
||||
// answer to "когда я это сделал?" read hours off, in the same sentence
|
||||
// shape the plan reads a day in.
|
||||
t = t.Local()
|
||||
if t.After(now.Add(-2*time.Minute)) && t.Before(now.Add(2*time.Minute)) {
|
||||
return "только что"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// "когда я это сделал?" answers off a fact's Ts, which the store hands back as
|
||||
// UTC, and the branch that names a wall clock printed it in whatever zone it
|
||||
// arrived in (V-614). The instant here is built three hours off this machine's
|
||||
// zone, so the assertion holds under TZ=UTC as well.
|
||||
func TestFormatTimeReadsHisClock(t *testing.T) {
|
||||
_, off := time.Now().Zone()
|
||||
away := time.FixedZone("away", off+3*60*60)
|
||||
stored := time.Now().Add(-72 * time.Hour).In(away)
|
||||
|
||||
got := formatTime(stored)
|
||||
if want := stored.Local().Format("15:04"); !strings.Contains(got, want) {
|
||||
t.Errorf("formatTime = %q, want the hour on his clock (%s)", got, want)
|
||||
}
|
||||
if bad := stored.Format("15:04"); strings.Contains(got, bad) {
|
||||
t.Errorf("formatTime = %q reads the zone the fact arrived in (%s)", got, bad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMentionsUnknownDayReadsWordsNotStems — the defect V-581 found. The
|
||||
// weekday half of this guard was a list of stems matched with strings.Contains,
|
||||
// so "среди", "средство" and "средний" all read as Wednesday and the question
|
||||
// was answered with onlyNearDaysReply instead of a date.
|
||||
//
|
||||
// The other half of the fix is coverage: a stem list stops at the forms whoever
|
||||
// wrote it thought of, and "воскресеньях" was not one of them.
|
||||
func TestMentionsUnknownDayReadsWordsNotStems(t *testing.T) {
|
||||
for _, u := range []string{
|
||||
"какое число в понедельник",
|
||||
"какое число в среду",
|
||||
"какое число в среде",
|
||||
"что там по воскресеньям",
|
||||
"what is the date on friday",
|
||||
"какое число через неделю",
|
||||
} {
|
||||
if !mentionsUnknownDay(u) {
|
||||
t.Errorf("mentionsUnknownDay(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
for _, u := range []string{
|
||||
"какое число в среднем",
|
||||
"сколько это в среднем",
|
||||
"какое сегодня средство",
|
||||
"какое число",
|
||||
} {
|
||||
if mentionsUnknownDay(u) {
|
||||
t.Errorf("mentionsUnknownDay(%q) = true; it names no day", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -24,8 +24,8 @@
|
||||
"at": "21:00",
|
||||
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
|
||||
"audio": "ru_fact",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш"],
|
||||
"expect_reply_contains": ["записала", "выпил воды"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "ваш", "стакан"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
+3
-3
@@ -81,10 +81,10 @@
|
||||
},
|
||||
{
|
||||
"at": "08:55",
|
||||
"note": "stating a fact writes it and says so, in the feminine. This reply comes back through the replier from the scripted model, so the persona check is against generated text rather than a constant. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\", and the earlier check on the comma alone passed on \"записал что ты выпил воды\".",
|
||||
"note": "stating a fact writes it and says so, in the feminine, and in his own words. The reply no longer comes from the model at all (V-592): a 1.7B asked to restate «я выпил воды» answered «Проверила, что ты выпел стакан воды», so the confirmation is now a deck frame with his sentence in it. The masculine forms are listed with their following character — \"записал \" and \"записал,\" — because \"записала\" contains \"записал\".",
|
||||
"say": "я выпил воды",
|
||||
"expect_reply_contains": ["записала"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый"],
|
||||
"expect_reply_contains": ["записала", "я выпил воды"],
|
||||
"expect_reply_lacks": ["записал ", "записал,", "записал.", "милый", "стакан"],
|
||||
"expect_events": ["water"]
|
||||
},
|
||||
{
|
||||
|
||||
+14
-9
@@ -319,10 +319,7 @@ func (t *tickLoop) stopFinishedAlarms(ctx context.Context, keys []string, state
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
byName := make(map[string]loop.Rule, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
byName := t.rulesByName()
|
||||
live := keys[:0:0]
|
||||
for _, key := range keys {
|
||||
outcome := ""
|
||||
@@ -381,19 +378,27 @@ func (t *tickLoop) repeatableRules(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
wired := make(map[string]bool, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
wired[r.Name] = true
|
||||
}
|
||||
wired := t.rulesByName()
|
||||
out := keys[:0:0]
|
||||
for _, k := range keys {
|
||||
if wired[k] {
|
||||
if _, ok := wired[k]; ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// rulesByName indexes the wired rule set by name, for the two lookups above
|
||||
// that only care whether a key is still wired (repeatableRules) or need the
|
||||
// rule itself (stopFinishedAlarms).
|
||||
func (t *tickLoop) rulesByName() map[string]loop.Rule {
|
||||
byName := make(map[string]loop.Rule, len(t.rules))
|
||||
for _, r := range t.rules {
|
||||
byName[r.Name] = r
|
||||
}
|
||||
return byName
|
||||
}
|
||||
|
||||
// cachePhrase keeps the latest phrased nudge per rule for the sev4-repeat
|
||||
// path. writing under a mutex; the repeat path reads under the same. the
|
||||
// cache is bounded by the rule count (≤ ~30 per spec) so eviction is not a
|
||||
|
||||
+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")
|
||||
}
|
||||
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)
|
||||
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).
|
||||
// Empty, not an error, when the mcp block is absent: "not configured" is the
|
||||
// default state and the web surface renders it as such.
|
||||
|
||||
@@ -31,6 +31,7 @@ const (
|
||||
roleCorrection turnRole = "correction" // it replaces a value she already had
|
||||
roleSideQuery turnRole = "side_query" // a question of its own, asked mid-flow
|
||||
roleNewRequest turnRole = "new_request" // a different request entirely
|
||||
roleAside turnRole = "aside" // something he stated, not an answer
|
||||
roleCancel turnRole = "cancel" // call the pending action off
|
||||
roleNotApplicable turnRole = "not_applicable" // nothing is pending; not our turn
|
||||
)
|
||||
@@ -212,11 +213,28 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.
|
||||
// tests are the floor and answer for free; the route is what sees a request
|
||||
// with no shape to it — "погода в риме" asks a question and carries neither
|
||||
// a question mark nor an interrogative, and only the router knows that.
|
||||
//
|
||||
// An utterance of pure frame gets one more chance, and V-577 is why. Every
|
||||
// token of "что у меня сегодня?" is frame, so the content gate called it an
|
||||
// answer, the parked reminder took "сегодня" for its time, and the question
|
||||
// he asked was answered nowhere. A routed intent beats a frame match,
|
||||
// because the frame is a hint and the route is a decision.
|
||||
//
|
||||
// The condition is that it fills nothing she asked about. That keeps the
|
||||
// hedged "а что если в 11:00" an answer, which is what it is: it carries 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
|
||||
// that names no hour now fills nothing to weigh.
|
||||
own := false
|
||||
if len(ownContent(text)) > 0 {
|
||||
own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text))
|
||||
} else if ok && fillsNothingAsked(q, answer) {
|
||||
own = carriesOwnRequest(routed, text)
|
||||
}
|
||||
if !own {
|
||||
if isAside(q, text, answer, routed, ok) {
|
||||
return roleAside
|
||||
}
|
||||
if replacesFilledSlot(q, answer) {
|
||||
return roleCorrection
|
||||
}
|
||||
@@ -228,6 +246,59 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.
|
||||
return roleNewRequest
|
||||
}
|
||||
|
||||
// isAside reports whether the utterance is something he STATED while she was
|
||||
// waiting on a question (V-577 shape 2).
|
||||
//
|
||||
// "у меня новый ноутбук" said into a parked reminder was dropped in silence: it
|
||||
// carries no capture verb, so it is not a request of its own, and it fills no
|
||||
// slot, so it is not an answer either. Neither storing it nor saying it was
|
||||
// ignored is the one behaviour that is wrong, and it was the behaviour.
|
||||
//
|
||||
// Three conditions, and all three are needed. The route has to call it a
|
||||
// statement AND stand behind that, so a bare time is never an aside. It has to
|
||||
// fill none of what she asked about, so an answer she can use stays an answer.
|
||||
// And it has to say something, so a shrug is still a failed answer and still
|
||||
// spends a retry.
|
||||
func isAside(q *dialogue.PendingQuestion, text string, answer dialogue.Slots, routed router.Decision, ok bool) bool {
|
||||
if !ok || q == nil {
|
||||
return false
|
||||
}
|
||||
if !statesSomething(routed) {
|
||||
return false
|
||||
}
|
||||
if len(ownContent(text)) == 0 {
|
||||
return false
|
||||
}
|
||||
return fillsNothingAsked(q, answer)
|
||||
}
|
||||
|
||||
// statesSomething reports whether the route is evidence that these words state
|
||||
// a thing, rather than a guess she has to interrupt a flow over.
|
||||
//
|
||||
// Two kinds of evidence, and the second one exists because the classifier floor
|
||||
// marks nearly everything Clarify. A parsed fact key comes from the
|
||||
// deterministic fact parser and not from a similarity score, so "я выпил воды"
|
||||
// is a statement on any engine. A confident note or fact is the other kind, and
|
||||
// that is the one the resident model gives for "у меня новый ноутбук".
|
||||
func statesSomething(routed router.Decision) bool {
|
||||
switch routed.Intent {
|
||||
case router.IntentFact:
|
||||
return routed.Slots.HasKey || !routed.Clarify
|
||||
case router.IntentNote:
|
||||
return !routed.Clarify
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fillsNothingAsked reports whether the utterance gave her none of what she
|
||||
// asked for. Nothing is pending counts as nothing filled.
|
||||
func fillsNothingAsked(q *dialogue.PendingQuestion, answer dialogue.Slots) bool {
|
||||
if q == nil {
|
||||
return true
|
||||
}
|
||||
return len(dialogue.StillMissing(q.Missing, answer)) == len(q.Missing)
|
||||
}
|
||||
|
||||
// replacesFilledSlot reports whether the utterance overwrites something the
|
||||
// pending action already had, rather than filling the gap she asked about —
|
||||
// "нет, на девять" while she is waiting for the subject. Both are handled the
|
||||
|
||||
@@ -123,6 +123,37 @@ func TestTurnRoleReadsTheRoutedDecision(t *testing.T) {
|
||||
ok: true,
|
||||
want: roleAnswer,
|
||||
},
|
||||
{
|
||||
// V-577 shape 1. Every token is frame, so the content gate called
|
||||
// this an answer and the reminder took "сегодня" for its time. It
|
||||
// fills nothing she asked about, so the route decides, and the route
|
||||
// says the calendar answers it.
|
||||
name: "an agenda question of pure frame words is a side query",
|
||||
text: "что у меня сегодня?",
|
||||
routed: dec(router.IntentQuery, router.Slots{}),
|
||||
ok: true,
|
||||
want: roleSideQuery,
|
||||
},
|
||||
{
|
||||
// V-577 shape 2. Neither a slot value nor a request nor a cancel.
|
||||
// It was dropped in silence; it is an aside, and an aside is stored
|
||||
// and re-asked.
|
||||
name: "a fact stated mid-flow is an aside",
|
||||
text: "у меня новый ноутбук",
|
||||
routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}),
|
||||
ok: true,
|
||||
want: roleAside,
|
||||
},
|
||||
{
|
||||
// A route she is not sure of is not evidence that he stated
|
||||
// anything, and "позвонить маме" is the answer to the other half of
|
||||
// a reminder.
|
||||
name: "an unsure note is not an aside",
|
||||
text: "позвонить маме",
|
||||
routed: router.Decision{Intent: router.IntentNote, Clarify: true},
|
||||
ok: true,
|
||||
want: roleAnswer,
|
||||
},
|
||||
{
|
||||
name: "a bare noun that answers nothing is still an answer",
|
||||
text: "ага",
|
||||
@@ -248,3 +279,97 @@ func TestTheTurnIsRoutedOnce(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-1
@@ -110,6 +110,16 @@ func (h *reactiveHandler) routeForRole(ctx context.Context, text string) (router
|
||||
// This is a fast path to the SAME answer and must stay one. If it ever needs a
|
||||
// rule the classifier does not have, it has become a second decision procedure
|
||||
// and it is the thing V-560 deleted.
|
||||
//
|
||||
// A question shape is the exception and V-577 is why (measured 2026-08-06).
|
||||
// "что у меня сегодня?" is an interrogative, a preposition, a particle and a day
|
||||
// word, so every token of it is frame and it left no content of its own. The
|
||||
// fast path called it an answer, the parked reminder read "сегодня" as its time,
|
||||
// and the question he asked was never answered. Asked alone the same sentence
|
||||
// routes to query at stage 0, so the route knew and was never consulted.
|
||||
func needsRoute(text string) bool {
|
||||
return !isCancel(text) && len(ownContent(text)) > 0
|
||||
if isCancel(text) {
|
||||
return false
|
||||
}
|
||||
return len(ownContent(text)) > 0 || router.IsQuestionShaped(text)
|
||||
}
|
||||
|
||||
+38
-8
@@ -145,6 +145,17 @@ type reactiveHandler struct {
|
||||
// is recorded, which is what a test that did not ask for one gets.
|
||||
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
|
||||
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
|
||||
clarifyStore *dialogue.ClarifyStore
|
||||
@@ -265,7 +276,11 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
var rec *decision.Record
|
||||
ctx, rec = decision.With(ctx, text)
|
||||
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).
|
||||
@@ -350,6 +365,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
||||
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
|
||||
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||
// 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.
|
||||
if replyText == "" {
|
||||
replyText = h.replier.Reply(dec)
|
||||
replyText = h.replier.Reply(ctx, dec)
|
||||
}
|
||||
return withNotice(expiredNotice, replyText)
|
||||
}
|
||||
@@ -528,6 +551,17 @@ func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision)
|
||||
}
|
||||
}
|
||||
|
||||
// sessionAsTurn projects a stored session onto the dialogue.Turn shape used in
|
||||
// history lists. Shared by chatHistory and rememberTurn (clarify.go) so the
|
||||
// same session is described the same way in both places.
|
||||
func sessionAsTurn(s *dialogue.Session) dialogue.Turn {
|
||||
return dialogue.Turn{
|
||||
Intent: s.Intent,
|
||||
Slots: s.Slots,
|
||||
Text: s.Slots.Text,
|
||||
}
|
||||
}
|
||||
|
||||
// chatHistory collects dialogue turns from the session store for the current
|
||||
// conversation. Returns prior user utterances (newest last) up to a depth of
|
||||
// 4 turns. Returns nil when there's no session or no history.
|
||||
@@ -541,13 +575,9 @@ func (h *reactiveHandler) chatHistory(ctx context.Context) []dialogue.Turn {
|
||||
return nil
|
||||
}
|
||||
// History already includes the immediate prior turn (set by the dialogue
|
||||
// merge at lines 373-395), plus up to 3 more from deeper history.
|
||||
// merge in runTurn's step 6, above), plus up to 3 more from deeper history.
|
||||
out := make([]dialogue.Turn, 0, 1+len(prev.History))
|
||||
out = append(out, dialogue.Turn{
|
||||
Intent: prev.Intent,
|
||||
Slots: prev.Slots,
|
||||
Text: prev.Slots.Text,
|
||||
})
|
||||
out = append(out, sessionAsTurn(prev))
|
||||
out = append(out, prev.History...)
|
||||
return out
|
||||
}
|
||||
|
||||
+40
-11
@@ -149,6 +149,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
w.embedder = emb
|
||||
repairFactVectors(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) -----
|
||||
// Config tools are the declarative bootstrap: seed them into the store as
|
||||
@@ -176,7 +179,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// The LAN scanner (Vikunja #257): a read, bounded to the configured
|
||||
// subnets and rate-limited. Off unless the `netscan` block is enabled.
|
||||
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) -----
|
||||
var weatherProvider weather.Provider
|
||||
@@ -236,21 +239,22 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
memStore = memory.NewInMemoryStore()
|
||||
}
|
||||
|
||||
// ----- dialogue (multi-turn slot carry-over; 2-min follow-up window) -----
|
||||
// ----- dialogue (multi-turn slot carry-over; dialogueSessionTTL follow-up window) -----
|
||||
// Store-backed when the daemon passes a store, so a restart mid-conversation
|
||||
// keeps the thread (Vikunja #363). Sessions past their TTL are dropped on
|
||||
// load, never revived. Clarify's parked question stays in memory only, and
|
||||
// that is a decision rather than an omission (Vikunja #385, docs/design.md):
|
||||
// a restart expires it, so the thread comes back and the open question does
|
||||
// not.
|
||||
const dialogueSessionTTL = 2 * time.Minute
|
||||
var dialogueSessions *dialogue.SessionStore
|
||||
if dataStore != nil {
|
||||
dialogueSessions = dialogue.NewPersistentSessionStore(2*time.Minute, dataStore)
|
||||
dialogueSessions = dialogue.NewPersistentSessionStore(dialogueSessionTTL, dataStore)
|
||||
if err := dialogueSessions.Load(context.Background(), time.Now()); err != nil {
|
||||
log.Printf("dialogue: load saved sessions: %v", err)
|
||||
}
|
||||
} else {
|
||||
dialogueSessions = dialogue.NewSessionStore(2 * time.Minute)
|
||||
dialogueSessions = dialogue.NewSessionStore(dialogueSessionTTL)
|
||||
}
|
||||
clarifyStore := dialogue.NewClarifyStore(clarifyTTL)
|
||||
timeParser := router.NewPythonDateParser()
|
||||
@@ -297,7 +301,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
|
||||
// measured with, and one that only runs when a flag is set is not there
|
||||
// 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,
|
||||
// 0 here (unset config) ⇒ the dialogue default.
|
||||
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
|
||||
@@ -376,9 +386,9 @@ func pickLLMRouter(enabled bool, c router.Completer) *router.LLMRouter {
|
||||
// - The embedder is provided by wireVoice: HashEmbedder (floor) when no
|
||||
// embedder config is present, or the ONNX multilingual model when
|
||||
// configured — same interface, one constructor change.
|
||||
// - 6 bootstrap examples covering the 5 intents + one compound-capture
|
||||
// placeholder. Spec calls for ~10 per intent at production; this is the
|
||||
// bootstrapping floor swapped by tuning the seed set later.
|
||||
// - The classifier is floored by seedClassifier, which loads one file per
|
||||
// intent from seedDir (models/seeds/<intent>.txt) — see seedClassifier
|
||||
// below for the current intent list and file names.
|
||||
// - Threshold is from voice.router_threshold config (default 0.55).
|
||||
func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64, llmR *router.LLMRouter) *router.Router {
|
||||
cls := router.NewClassifier(emb)
|
||||
@@ -391,6 +401,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
||||
// 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())
|
||||
// The list side of the same exposure: a phrasing with no possessive in it
|
||||
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||
@@ -461,9 +475,10 @@ func seedPath() string {
|
||||
|
||||
// seedClassifier floors the embedded examples so the cold-boot path
|
||||
// doesn't return ErrNoIntents. Loads examples from seedDir — one file per
|
||||
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt). When the
|
||||
// classifier can't decide it falls through to Clarify — the last-resort
|
||||
// path asks the user to rephrase rather than guessing wrong.
|
||||
// intent (act.txt, reminder.txt, fact.txt, note.txt, query.txt, chat.txt,
|
||||
// system.txt). When the classifier can't decide it falls through to
|
||||
// Clarify — the last-resort path asks the user to rephrase rather than
|
||||
// guessing wrong.
|
||||
func seedClassifier(c *router.Classifier) {
|
||||
intents := []router.Intent{
|
||||
router.IntentAct,
|
||||
@@ -513,6 +528,20 @@ func loadSeedFile(c *router.Classifier, intent router.Intent) (int, error) {
|
||||
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
|
||||
// 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
|
||||
|
||||
+39
-9
@@ -7,6 +7,10 @@ package main
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/lexicon"
|
||||
"github.com/kami/maven/internal/morph"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// isWeatherQuery returns true if the utterance is about weather.
|
||||
@@ -27,16 +31,42 @@ func isWeatherQuery(u string) bool {
|
||||
// come through whole and "в 5 утра" does not.
|
||||
var weatherPlace = regexp.MustCompile(`(?i)(?:^|\s)(?:в|во|in)\s+([\p{L}-]+(?:\s+[\p{L}-]+)?)`)
|
||||
|
||||
// weatherNonPlaces — words that follow "в" in a weather question and are not
|
||||
// cities. "какая погода в доме" is the smart-home sensor, not Open-Meteo, and
|
||||
// "тепло в комнате" is the same question about the same room.
|
||||
var weatherNonPlaces = map[string]bool{
|
||||
// weatherRooms — the rooms of the house, which are the only words in this
|
||||
// guard that belong to it. "какая погода в доме" is the smart-home sensor, not
|
||||
// Open-Meteo, and "тепло в комнате" is the same question about the same room.
|
||||
//
|
||||
// The rest of the guard used to be a third copy of three closed sets that
|
||||
// already exist in the lexicon: the weekdays, the parts of the day, and the
|
||||
// words that follow "в" without naming a place (V-581). Each copy was short in
|
||||
// its own direction — "среду" but not "среде", "утром" but not "утра", "целом"
|
||||
// but not "общем" — so the same question phrased one word differently reached
|
||||
// the geocoder as a city.
|
||||
var weatherRooms = map[string]bool{
|
||||
"доме": true, "квартире": true, "комнате": true, "спальне": true,
|
||||
"гостиной": true, "кухне": true, "гараже": true, "офисе": true,
|
||||
"выходные": true, "субботу": true, "воскресенье": true, "понедельник": true,
|
||||
"вторник": true, "среду": true, "четверг": true, "пятницу": true,
|
||||
"обед": true, "обеде": true, "утро": true, "утром": true, "вечер": true,
|
||||
"вечером": true, "ночь": true, "ночью": true, "целом": true, "принципе": true,
|
||||
"обед": true, "обеде": true, "выходные": true, "выходных": true,
|
||||
}
|
||||
|
||||
// isWeatherNonPlace reports whether the word after "в" names something other
|
||||
// than a place he could ask the weather for.
|
||||
func isWeatherNonPlace(word string) bool {
|
||||
if weatherRooms[word] {
|
||||
return true
|
||||
}
|
||||
if _, ok := router.WeekdayIndex(word); ok {
|
||||
return true
|
||||
}
|
||||
for _, w := range lexicon.PartsOfDay() {
|
||||
if word == w || morph.SameWord(word, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, w := range lexicon.NotPlaceAfterV() {
|
||||
if word == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// extractWeatherLocation returns the place he named, or the configured default
|
||||
@@ -63,7 +93,7 @@ func extractWeatherLocation(u, defaultLoc string) string {
|
||||
}
|
||||
place := strings.TrimSpace(m[1])
|
||||
first := strings.ToLower(strings.Fields(place)[0])
|
||||
if weatherNonPlaces[first] {
|
||||
if isWeatherNonPlace(first) {
|
||||
return defaultLoc
|
||||
}
|
||||
return place
|
||||
|
||||
@@ -29,6 +29,13 @@ func TestExtractWeatherLocation(t *testing.T) {
|
||||
// the house sensors and the day words answer elsewhere.
|
||||
{"тепло в комнате?", "Berlin", "Berlin"},
|
||||
{"какая погода в выходные", "Berlin", "Berlin"},
|
||||
// The cases the three private copies of the lexicon were short by
|
||||
// (V-581): a weekday in a case the old map did not list, a part of the
|
||||
// day in one it did not list, and "в общем".
|
||||
{"какая погода в среде", "Berlin", "Berlin"},
|
||||
{"какая погода в воскресеньях", "Berlin", "Berlin"},
|
||||
{"какая погода в понедельникам", "Berlin", "Berlin"},
|
||||
{"какая погода в общем", "Berlin", "Berlin"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := extractWeatherLocation(c.utterance, c.def); got != c.want {
|
||||
|
||||
+139
-58
@@ -6,10 +6,11 @@
|
||||
// restart-free, fail-independent — a crashing poller can't touch the store key
|
||||
// (it never had it), worst case a stale env fact until the next tick.
|
||||
//
|
||||
// Two sources, each its own provenance (the loop's rules trust source):
|
||||
// Four sources, each its own provenance (the loop's rules trust source):
|
||||
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
|
||||
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
|
||||
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
|
||||
// - wireguard → infer:wg latest handshake, the presence signal
|
||||
//
|
||||
// The zenmoney source is why the token lives HERE and not in core: the poller
|
||||
// already owns every other third-party credential, it holds no store key, and
|
||||
@@ -76,27 +77,31 @@ func run(args []string) error {
|
||||
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
|
||||
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
|
||||
}
|
||||
// A bad duration or an empty -wg-cmd used to get past start and kill the
|
||||
// poller on the first tick — time.NewTicker panics on a non-positive
|
||||
// interval, and pollWg indexed field 0 of an empty command. A zero -timeout
|
||||
// is worse than a crash: http.Client reads it as "no deadline", so one
|
||||
// wedged source stalls every other source behind it forever. Refuse all
|
||||
// three here, where the operator sees the message.
|
||||
if *interval <= 0 {
|
||||
return fmt.Errorf("-interval must be positive, got %s", *interval)
|
||||
}
|
||||
if *timeout <= 0 {
|
||||
return fmt.Errorf("-timeout must be positive, got %s", *timeout)
|
||||
}
|
||||
if *wgIface != "" && strings.TrimSpace(*wgCmd) == "" {
|
||||
return fmt.Errorf("-wg-cmd is empty but -wg is set")
|
||||
}
|
||||
|
||||
// The token is read from a file, never taken as a flag value: an argv token
|
||||
// is visible in `ps` to every user on the box and lands in the compose file
|
||||
// and the shell history. Read once at start — a rotated token means a
|
||||
// restart, which is cheaper than re-reading his credential every hour.
|
||||
var zen *zenmoney.Client
|
||||
if *zenTokenFile != "" {
|
||||
raw, err := os.ReadFile(*zenTokenFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read zenmoney token: %w", err)
|
||||
}
|
||||
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
zen, err := newZenClient(*zenTokenFile, *zenURL, *timeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
core, err := ipc.DialWait(*socket, 60*time.Second)
|
||||
core, err := ipc.DialWait(*socket, coreDialWait)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -117,14 +122,46 @@ func run(args []string) error {
|
||||
// The token is never logged, not even its length.
|
||||
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
|
||||
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
|
||||
p.loop(ctx, *interval)
|
||||
return nil
|
||||
}
|
||||
|
||||
// coreDialWait — how long to wait for core's socket at start. The poller and
|
||||
// core come up together under compose, so a cold start is a wait, not a failure.
|
||||
const coreDialWait = 60 * time.Second
|
||||
|
||||
// zenTimeoutFactor — the zenmoney client gets a longer deadline than the other
|
||||
// sources. A diff call walks his whole transaction history, where netdata and
|
||||
// kuma answer from memory.
|
||||
const zenTimeoutFactor = 3
|
||||
|
||||
// newZenClient builds the money client, or nil when no token file was given.
|
||||
//
|
||||
// The token is read from a file, never taken as a flag value: an argv token is
|
||||
// visible in `ps` to every user on the box and lands in the compose file and
|
||||
// the shell history. Read once at start — a rotated token means a restart,
|
||||
// which is cheaper than re-reading his credential every hour.
|
||||
func newZenClient(tokenFile, baseURL string, timeout time.Duration) (*zenmoney.Client, error) {
|
||||
if tokenFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := os.ReadFile(tokenFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read zenmoney token: %w", err)
|
||||
}
|
||||
return zenmoney.New(strings.TrimSpace(string(raw)), baseURL, timeout*zenTimeoutFactor)
|
||||
}
|
||||
|
||||
// loop polls until the context is cancelled.
|
||||
func (p *poller) loop(ctx context.Context, interval time.Duration) {
|
||||
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
|
||||
t := time.NewTicker(*interval)
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Printf("mavpoll: bye")
|
||||
return nil
|
||||
return
|
||||
case <-t.C:
|
||||
p.pollOnce(ctx)
|
||||
}
|
||||
@@ -151,8 +188,8 @@ type poller struct {
|
||||
zenLast time.Time
|
||||
}
|
||||
|
||||
// pollOnce — one sweep of both sources. A failure in one source logs and does
|
||||
// NOT abort the other: netdata being down shouldn't blind kuma and vice versa.
|
||||
// pollOnce — one sweep of every configured source. A failure in one logs and
|
||||
// does NOT abort the rest: netdata being down shouldn't blind kuma.
|
||||
func (p *poller) pollOnce(ctx context.Context) {
|
||||
now := time.Now()
|
||||
if p.netdataURL != "" {
|
||||
@@ -244,6 +281,14 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||
|
||||
// ---- wireguard: latest handshake → presence signal -------------------------
|
||||
|
||||
const (
|
||||
// wgFactKey / wgSource — the presence signal, read by the decay in core.
|
||||
// The source says infer because a handshake is evidence he is home, not a
|
||||
// reading of where he is.
|
||||
wgFactKey = "wg_handshake"
|
||||
wgSource = "infer:wg"
|
||||
)
|
||||
|
||||
// pollWg reads `wg show <iface> latest-handshakes` and writes a wg_handshake
|
||||
// fact (source=infer:wg) stamped with the MOST RECENT peer handshake time — not
|
||||
// now(). Presence decays from the real handshake instant, so the fact's ts must
|
||||
@@ -253,9 +298,19 @@ func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
|
||||
// `wg show` needs CAP_NET_ADMIN; run mavpoll with the cap or set -wg-cmd "sudo wg".
|
||||
func (p *poller) pollWg(ctx context.Context) error {
|
||||
fields := strings.Fields(p.wgCmd)
|
||||
if len(fields) == 0 {
|
||||
return fmt.Errorf("wg command is empty")
|
||||
}
|
||||
args := append(fields[1:], "show", p.wgIface, "latest-handshakes")
|
||||
out, err := exec.CommandContext(ctx, fields[0], args...).Output()
|
||||
if err != nil {
|
||||
// wg says why it refused on stderr — usually a missing CAP_NET_ADMIN or
|
||||
// an interface that does not exist. Output() drops that, leaving a log
|
||||
// line that reads "exit status 1" and diagnoses nothing.
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
||||
return fmt.Errorf("run %s: %w: %s", p.wgCmd, err, strings.TrimSpace(string(ee.Stderr)))
|
||||
}
|
||||
return fmt.Errorf("run %s: %w", p.wgCmd, err)
|
||||
}
|
||||
maxTs := parseMaxHandshake(string(out))
|
||||
@@ -263,20 +318,19 @@ func (p *poller) pollWg(ctx context.Context) error {
|
||||
return nil // no peer has ever handshaked → drop out of presence
|
||||
}
|
||||
hs := time.Unix(maxTs, 0)
|
||||
prev, err := p.core.LatestFactBySource(ctx, "wg_handshake", "infer:wg")
|
||||
prev, err := p.core.LatestFactBySource(ctx, wgFactKey, wgSource)
|
||||
if err == nil && !hs.After(prev.Ts) {
|
||||
return nil // not newer → no churn
|
||||
}
|
||||
if err != nil && err != ipc.ErrNoFact && !isNoFact(err) {
|
||||
return fmt.Errorf("read wg_handshake: %w", err)
|
||||
if err != nil && !isNoFact(err) {
|
||||
return fmt.Errorf("read %s: %w", wgFactKey, err)
|
||||
}
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: hs, Kind: "env", Key: "wg_handshake", Value: `"up"`,
|
||||
Source: "infer:wg", Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write wg_handshake: %w", err)
|
||||
// The ts is the handshake instant, not now(): presence decays from when he
|
||||
// was last seen.
|
||||
if err := p.writeFact(ctx, wgFactKey, wgSource, `"up"`, hs); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("mavpoll: wg_handshake @ %s (infer:wg)", hs.Format(time.RFC3339))
|
||||
log.Printf("mavpoll: %s @ %s (%s)", wgFactKey, hs.Format(time.RFC3339), wgSource)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -431,29 +485,53 @@ func kumaState(v float64) string {
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
// writeIfChanged writes a `facts(kind=env)` row only when val differs from the
|
||||
// latest fact for (key, source). Values are stored JSON-encoded (the store's
|
||||
// convention: `"down"`, `"critical"`), matching how rules compare f.Value.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error {
|
||||
jv, _ := json.Marshal(val) // string never fails to marshal
|
||||
// factConfidence — every poll is a direct reading of another service, never an
|
||||
// inference, so the fact goes in at full confidence.
|
||||
const factConfidence = 1.0
|
||||
|
||||
// unchanged reports whether the latest fact for (key, source) already holds
|
||||
// jsonVal. A missing fact is not an error here, it is the first write.
|
||||
func (p *poller) unchanged(ctx context.Context, key, source, jsonVal string) (bool, error) {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == string(jv):
|
||||
return nil // unchanged → no churn
|
||||
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
|
||||
return fmt.Errorf("read %s: %w", key, err)
|
||||
case err == nil:
|
||||
return prev.Value == jsonVal, nil
|
||||
case isNoFact(err):
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("read %s: %w", key, err)
|
||||
}
|
||||
_, err = p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
}
|
||||
|
||||
// writeFact writes one `facts(kind=env)` row. Every poll in this file lands
|
||||
// here, so the row shape is written once.
|
||||
func (p *poller) writeFact(ctx context.Context, key, source, jsonVal string, now time.Time) error {
|
||||
_, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
Kind: "env",
|
||||
Key: key,
|
||||
Value: string(jv),
|
||||
Value: jsonVal,
|
||||
Source: source,
|
||||
Confidence: 1.0, // a direct reading, not an inference
|
||||
Confidence: factConfidence,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeIfChanged writes only when val differs from the latest fact for
|
||||
// (key, source). Values are stored JSON-encoded (the store's convention:
|
||||
// `"down"`, `"critical"`), matching how rules compare f.Value.
|
||||
func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, now time.Time) error {
|
||||
jv, _ := json.Marshal(val) // a string never fails to marshal
|
||||
same, err := p.unchanged(ctx, key, source, string(jv))
|
||||
if err != nil || same {
|
||||
return err // unchanged → no churn
|
||||
}
|
||||
if err := p.writeFact(ctx, key, source, string(jv), now); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("mavpoll: %s=%s (%s)", key, val, source)
|
||||
return nil
|
||||
}
|
||||
@@ -466,18 +544,12 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
|
||||
// The log line names the key and the source, never the figures: mavpoll's log
|
||||
// is not the place his spending ends up.
|
||||
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
|
||||
prev, err := p.core.LatestFactBySource(ctx, key, source)
|
||||
switch {
|
||||
case err == nil && prev.Value == jsonVal:
|
||||
return nil
|
||||
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
|
||||
return fmt.Errorf("read %s: %w", key, err)
|
||||
same, err := p.unchanged(ctx, key, source, jsonVal)
|
||||
if err != nil || same {
|
||||
return err
|
||||
}
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||
Source: source, Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
if err := p.writeFact(ctx, key, source, jsonVal, now); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("mavpoll: %s updated (%s)", key, source)
|
||||
return nil
|
||||
@@ -490,11 +562,8 @@ func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal str
|
||||
// The log line names the key only, never the figures: mavpoll's log is not the
|
||||
// place his spending ends up.
|
||||
func (p *poller) writeMoneyFact(ctx context.Context, key, jsonVal string, now time.Time) error {
|
||||
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: now, Kind: "env", Key: key, Value: jsonVal,
|
||||
Source: zenmoney.Source, Confidence: 1.0,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("write %s: %w", key, err)
|
||||
if err := p.writeFact(ctx, key, zenmoney.Source, jsonVal, now); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("mavpoll: %s read (%s)", key, zenmoney.Source)
|
||||
return nil
|
||||
@@ -506,6 +575,15 @@ func isNoFact(err error) bool {
|
||||
return errors.Is(err, ipc.ErrNoFact)
|
||||
}
|
||||
|
||||
// maxBodyBytes caps what a source can make the poller hold. Kuma's whole
|
||||
// metrics page is a few hundred kilobytes, so 4 MiB is slack, not a budget.
|
||||
//
|
||||
// Hitting the cap is an error, not a shorter body. A truncated kuma page parses
|
||||
// cleanly right up to the cut, and every monitor past it reads as deleted — the
|
||||
// poller would write "unknown" over live services and the down-rule would go
|
||||
// quiet. Reading one byte past the cap is how we tell full from truncated.
|
||||
const maxBodyBytes = 4 << 20
|
||||
|
||||
func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
@@ -519,12 +597,15 @@ func (p *poller) get(ctx context.Context, url, basicUser string) ([]byte, error)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
return nil, fmt.Errorf("GET %s: body over %d bytes", url, maxBodyBytes)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -212,3 +213,59 @@ func TestRunRequiresSomethingToPoll(t *testing.T) {
|
||||
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A flag value that would kill the poller later is refused at start, before it
|
||||
// dials core: a non-positive interval panics time.NewTicker on the first tick, a
|
||||
// zero timeout means http.Client waits forever, and an empty wg command used to
|
||||
// index field 0 of an empty slice.
|
||||
func TestRunRefusesFlagsThatCrashLater(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"zero interval", []string{"-interval", "0"}, "-interval must be positive"},
|
||||
{"negative interval", []string{"-interval", "-5s"}, "-interval must be positive"},
|
||||
{"zero timeout", []string{"-timeout", "0"}, "-timeout must be positive"},
|
||||
{"empty wg command", []string{"-wg", "wg0", "-wg-cmd", " "}, "-wg-cmd is empty"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
args := append([]string{"-socket", "/tmp/nope.sock"}, c.args...)
|
||||
err := run(args)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("err = %v, want %q", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// pollWg refuses an empty command rather than panicking on fields[0].
|
||||
func TestPollWgEmptyCommand(t *testing.T) {
|
||||
p := &poller{core: &factCore{}, wgIface: "wg0", wgCmd: ""}
|
||||
if err := p.pollWg(context.Background()); err == nil {
|
||||
t.Error("want an error, got a poll that ran something")
|
||||
}
|
||||
}
|
||||
|
||||
// A body at the cap is a truncated body, and a truncated kuma page reads as
|
||||
// "every monitor past the cut was deleted". Refuse it instead of parsing it.
|
||||
func TestGetRefusesTruncatedBody(t *testing.T) {
|
||||
big := strings.Repeat("x", maxBodyBytes+64)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, big)
|
||||
}))
|
||||
defer srv.Close()
|
||||
p := &poller{http: srv.Client()}
|
||||
if _, err := p.get(context.Background(), srv.URL, ""); err == nil {
|
||||
t.Error("want an over-size refusal, got a silently truncated body")
|
||||
}
|
||||
small := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
io.WriteString(w, "ok")
|
||||
}))
|
||||
defer small.Close()
|
||||
body, err := p.get(context.Background(), small.URL, "")
|
||||
if err != nil || string(body) != "ok" {
|
||||
t.Errorf("get = %q, %v; want the whole small body", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,18 @@ import (
|
||||
// to it before sending, so it's also the rate the silence gate assumes.
|
||||
const whisperSampleRate = 16000
|
||||
|
||||
// whisperThreads — greedy decode is single-pass and this is a laptop CPU
|
||||
// (homesrv), not a server box; 4 was picked to leave headroom for the rest
|
||||
// of the daemons sharing the machine, not measured against a latency target.
|
||||
const whisperThreads = 4
|
||||
|
||||
// noSpeechFloor — whisper's own no_speech_prob past this point means the
|
||||
// segment it transcribed is not speech (the model still emits token
|
||||
// probabilities for silence/noise, so a high avgLogProb-derived confidence
|
||||
// can coexist with a segment that should be zero). Read as "at least 90%
|
||||
// sure this was not speech."
|
||||
const noSpeechFloor = 0.9
|
||||
|
||||
type whisperHandler struct {
|
||||
ctx *C.struct_whisper_context
|
||||
minMs int // clips shorter than this are dropped (hallucination bait)
|
||||
@@ -101,7 +113,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
params.print_realtime = false
|
||||
params.print_timestamps = false
|
||||
params.print_special = false
|
||||
params.n_threads = C.int(4)
|
||||
params.n_threads = C.int(whisperThreads)
|
||||
params.single_segment = true
|
||||
|
||||
lang := C.CString(req.Lang)
|
||||
@@ -162,7 +174,7 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
||||
}
|
||||
|
||||
noSpeechProb := float64(C.whisper_full_get_segment_no_speech_prob(h.ctx, 0))
|
||||
if noSpeechProb > 0.9 {
|
||||
if noSpeechProb > noSpeechFloor {
|
||||
confidence = 0
|
||||
}
|
||||
|
||||
|
||||
@@ -116,18 +116,27 @@ func (h *piperHandler) Synthesize(ctx context.Context, req worker.SynthesizeReq)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resample22050To16000 converts raw 16-bit PCM from 22050 Hz to 16000 Hz
|
||||
// using linear interpolation.
|
||||
// piperSampleRate is the rate piper's onnx voices render at (ru_RU-irina and
|
||||
// the other models this daemon has been pointed at). targetSampleRate is the
|
||||
// canonical maven wire rate (audio.PCM16kMono) that every downstream
|
||||
// consumer — playback, the voice wire, whisper on the way back in — expects.
|
||||
const (
|
||||
piperSampleRate = 22050
|
||||
targetSampleRate = 16000
|
||||
)
|
||||
|
||||
// resample22050To16000 converts raw 16-bit PCM from piperSampleRate to
|
||||
// targetSampleRate using linear interpolation.
|
||||
func resample22050To16000(input []byte) []byte {
|
||||
if len(input) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
nSamples := len(input) / 2
|
||||
outSamples := int(float64(nSamples) * 16000.0 / 22050.0)
|
||||
outSamples := int(float64(nSamples) * float64(targetSampleRate) / float64(piperSampleRate))
|
||||
output := make([]byte, outSamples*2)
|
||||
|
||||
ratio := 22050.0 / 16000.0
|
||||
ratio := float64(piperSampleRate) / float64(targetSampleRate)
|
||||
|
||||
for i := 0; i < outSamples; i++ {
|
||||
srcPos := float64(i) * ratio
|
||||
|
||||
+105
-2
@@ -2,12 +2,15 @@ package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"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
|
||||
// the reply. Empty for a turn no source claimed (V-539).
|
||||
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.
|
||||
@@ -38,12 +56,21 @@ func handleChatPage(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
||||
msgs = append(msgs, chatMsg{Role: "user", Text: q})
|
||||
}
|
||||
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 {
|
||||
Error string
|
||||
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.
|
||||
@@ -88,5 +115,81 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
|
||||
if 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)
|
||||
}
|
||||
|
||||
// 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>
|
||||
{{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>
|
||||
{{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}}
|
||||
<div class=empty>
|
||||
<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")
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,13 @@ memory only, so a restart empties this.</div>
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>noticed<th>happened<th>source<th>kind<th>pri<th>what<th>detail</tr>
|
||||
{{range .Events}}<tr>
|
||||
<td>{{.NoticedAt.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
|
||||
<!-- Both columns in his clock (V-469 on /reminders, same rule here). NoticedAt
|
||||
is the bus's local instant, OccurredAt is whatever zone the source used —
|
||||
the store hands back UTC and internal/rss parses a pubDate to UTC — so
|
||||
rendering them raw put two zones side by side in the same row and made a
|
||||
feed item look hours older than it was. -->
|
||||
<td>{{.NoticedAt.Local.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.OccurredAt.Local.Format "02.01 15:04:05"}}</td>
|
||||
<td class=gray>{{.Source}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td class=gray>{{.Priority}}</td>
|
||||
|
||||
@@ -46,7 +46,8 @@ func TestEventsPageRendersTheJournal(t *testing.T) {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
|
||||
occurred := time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC).Local().Format("02.01 15:04:05")
|
||||
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", occurred} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page does not mention %q", want)
|
||||
}
|
||||
@@ -89,6 +90,38 @@ func TestEventsPageWithoutCore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// awayFromLocal returns a zone three hours off whatever this machine runs in,
|
||||
// so a test can tell "rendered in his clock" apart from "rendered in whatever
|
||||
// zone the value arrived in" without depending on TZ.
|
||||
func awayFromLocal() *time.Location {
|
||||
_, off := time.Now().Zone()
|
||||
return time.FixedZone("away", off+3*60*60)
|
||||
}
|
||||
|
||||
func TestEventsPageRendersBothTimesInLocalZone(t *testing.T) {
|
||||
// OccurredAt carries the source's zone — the store hands back UTC and
|
||||
// internal/rss parses a pubDate to UTC — while NoticedAt is the bus's local
|
||||
// instant. Rendered raw, the two columns of one row were in two zones and a
|
||||
// feed item read hours older than it was.
|
||||
away := awayFromLocal()
|
||||
occurred := time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC).In(away)
|
||||
noticed := occurred.Add(2 * time.Minute)
|
||||
core := &eventsCore{events: []ipc.IntakeEvent{{
|
||||
Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
|
||||
OccurredAt: occurred, NoticedAt: noticed,
|
||||
}}}
|
||||
body := getEvents(t, core).Body.String()
|
||||
const layout = "02.01 15:04:05"
|
||||
for _, ts := range []time.Time{occurred, noticed} {
|
||||
if !strings.Contains(body, ts.Local().Format(layout)) {
|
||||
t.Errorf("page does not render %s in his clock (%s)", ts, ts.Local().Format(layout))
|
||||
}
|
||||
if strings.Contains(body, ts.In(away).Format(layout)) {
|
||||
t.Errorf("page rendered %s in the source's zone", ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsPageEscapesIntakeText(t *testing.T) {
|
||||
// Titles come from outside — a feed headline, a notification. They are shown
|
||||
// on a page and must never be able to inject markup into it.
|
||||
|
||||
+20
-1
@@ -58,6 +58,12 @@ func main() {
|
||||
// mutex, so sharing the connection would freeze every other page for the
|
||||
// length of the load. See handleModels.
|
||||
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 != "" {
|
||||
c, err := ipc.DialWait(*coreSock, 60*time.Second)
|
||||
if err != nil {
|
||||
@@ -71,6 +77,12 @@ func main() {
|
||||
defer sc.Close()
|
||||
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
|
||||
@@ -208,8 +220,15 @@ func main() {
|
||||
// decides how every utterance is routed and how every reply is worded.
|
||||
mux.HandleFunc("/tools", gatedPage(handleTools))
|
||||
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/correct", gatedPage(handleCorrectAPI))
|
||||
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<div class=scroll><table class=mono>
|
||||
<tr><th>at<th>kind<th>what</tr>
|
||||
{{range .Items}}<tr>
|
||||
<td>{{.At.Format "15:04"}}</td>
|
||||
<!-- In his clock. A plan item's At is a calendar fact's Ts or a reminder's
|
||||
FireTs, and the store hands both back as UTC, so the raw hour printed a
|
||||
reminder here at an hour /reminders did not agree with (V-469). -->
|
||||
<td>{{.At.Local.Format "15:04"}}</td>
|
||||
<td class=gray>{{.Kind}}</td>
|
||||
<td>{{if .Uncertain}}<span class=hint title="relayed notification, not a calendar read">похоже,</span> {{end}}{{.Text}}</td>
|
||||
</tr>{{end}}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// morningCore serves a canned checklist and day plan.
|
||||
type morningCore struct {
|
||||
ipc.UnimplementedCoreAPI
|
||||
status []ipc.MorningRoutineStatus
|
||||
plan ipc.DayPlan
|
||||
}
|
||||
|
||||
func (c *morningCore) MorningStatus(context.Context) ([]ipc.MorningRoutineStatus, error) {
|
||||
return c.status, nil
|
||||
}
|
||||
|
||||
func (c *morningCore) DayPlan(context.Context) (ipc.DayPlan, error) { return c.plan, nil }
|
||||
|
||||
func TestMorningRendersPlanTimesInLocalZone(t *testing.T) {
|
||||
// A plan item's At is a calendar fact's Ts or a reminder's FireTs, and the
|
||||
// store hands both back as UTC. Printed raw, /morning named an hour for a
|
||||
// reminder that /reminders — which does call Local — disagreed with.
|
||||
away := awayFromLocal()
|
||||
at := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC).In(away)
|
||||
core := &morningCore{plan: ipc.DayPlan{
|
||||
Date: at,
|
||||
Items: []ipc.DayPlanItem{{At: at, Text: "выпить таблетки", Kind: "reminder"}},
|
||||
}}
|
||||
w := httptest.NewRecorder()
|
||||
handleMorning(w, httptest.NewRequest(http.MethodGet, "/morning", nil), core)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", w.Code)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, at.Local().Format("15:04")) {
|
||||
t.Errorf("plan item not rendered in his clock (%s): %s", at.Local().Format("15:04"), body)
|
||||
}
|
||||
if strings.Contains(body, at.In(away).Format("15:04")) {
|
||||
t.Errorf("plan item rendered in the stored zone: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ var pageChrome = map[string]struct{ Title, Icon string }{
|
||||
"reminders": {"Reminders", "i-calendar"},
|
||||
"routines": {"Routines", "i-repeat"},
|
||||
"morning": {"Morning Routines", "i-calendar"},
|
||||
"events": {"Intake", "i-download"},
|
||||
"chat": {"Chat", "i-message"},
|
||||
"voice": {"Voice", "i-mic"},
|
||||
"ecosystem": {"Ecosystem", "i-grid"},
|
||||
|
||||
@@ -702,6 +702,11 @@ details[open] > summary { margin-bottom: var(--space-1); }
|
||||
.chat-form { display: flex; gap: var(--space-2); }
|
||||
.chat-form input { flex: 1; }
|
||||
.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 ── */
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: var(--space-1) var(--space-3); font-size: var(--fs-sm); }
|
||||
|
||||
+9
-5
@@ -300,11 +300,15 @@ func promoteCandidate(ctx context.Context, core ipc.CoreAPI, r *http.Request, id
|
||||
if err := core.SetTaskFields(ctx, id, doneWhen, blockedOn); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if due != nil {
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
wgt, err := formWeight(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The importance select is posted whether or not a date is. This ran under
|
||||
// `if due != nil`, so confirming a candidate as "срочно" with no deadline
|
||||
// dropped the word on the floor — the row came back normal and nothing said
|
||||
// why. A promote with neither field set still writes nothing.
|
||||
if due != nil || wgt != 0 {
|
||||
if err := core.EditTask(ctx, id, text, due, wgt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -32,6 +32,31 @@ type fakeTaskCore struct {
|
||||
statusErr error
|
||||
|
||||
promoted bool
|
||||
|
||||
// The promote path's two extra writes.
|
||||
fields []any
|
||||
edits []editCall
|
||||
editErr error
|
||||
fieldErr error
|
||||
}
|
||||
|
||||
// editCall records one EditTask, so a test can say what the form actually sent
|
||||
// down rather than only that the promotion succeeded.
|
||||
type editCall struct {
|
||||
ID int64
|
||||
Text string
|
||||
Due *time.Time
|
||||
Weight int
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) EditTask(_ context.Context, id int64, text string, due *time.Time, weight int) error {
|
||||
f.edits = append(f.edits, editCall{id, text, due, weight})
|
||||
return f.editErr
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) SetTaskFields(_ context.Context, id int64, doneWhen, blockedOn string) error {
|
||||
f.fields = append(f.fields, []any{id, doneWhen, blockedOn})
|
||||
return f.fieldErr
|
||||
}
|
||||
|
||||
func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) {
|
||||
@@ -220,6 +245,49 @@ func TestApplyTaskPostCarriesWeight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Confirming a candidate posts the importance select whether or not a date is
|
||||
// set. The weight write hung off `if due != nil`, so "срочно" with no deadline
|
||||
// was read off the form and thrown away, and the row came back normal.
|
||||
func TestPromoteCandidateCarriesWeightWithoutADueDate(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{
|
||||
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
|
||||
"done_when": {"полис на руках"}, "weight": {"3"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
|
||||
if len(core.edits) != 1 {
|
||||
t.Fatalf("edits = %+v, want the weight written once", core.edits)
|
||||
}
|
||||
if core.edits[0].Weight != 3 || core.edits[0].ID != 4 {
|
||||
t.Errorf("edit = %+v, want id 4 at weight 3", core.edits[0])
|
||||
}
|
||||
if core.edits[0].Due != nil {
|
||||
t.Errorf("edit invented a due date: %v", core.edits[0].Due)
|
||||
}
|
||||
if core.statusVal != "open" {
|
||||
t.Errorf("status = %q, want the candidate promoted", core.statusVal)
|
||||
}
|
||||
}
|
||||
|
||||
// A promote with neither field set still writes nothing: the row is unchanged
|
||||
// apart from its status, and an EditTask here would be a no-op that can fail.
|
||||
func TestPromoteCandidateWithNoDateAndNoWeightDoesNotEdit(t *testing.T) {
|
||||
core := &fakeTaskCore{}
|
||||
form := url.Values{
|
||||
"action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"},
|
||||
"done_when": {"полис на руках"}, "weight": {"0"},
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
handleTasks(httptest.NewRecorder(), req, core)
|
||||
if len(core.edits) != 0 {
|
||||
t.Errorf("edits = %+v, want none", core.edits)
|
||||
}
|
||||
}
|
||||
|
||||
// Out of range clamps rather than 400s; a non-number is a real client error.
|
||||
func TestApplyTaskPostClampsWeight(t *testing.T) {
|
||||
core := &fakeTaskCore{created: true}
|
||||
|
||||
+52
-13
@@ -25,6 +25,25 @@
|
||||
"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": {
|
||||
"bot_token": "${TELEGRAM_BOT_TOKEN}",
|
||||
"chat_id": "${TELEGRAM_CHAT_ID}",
|
||||
@@ -37,7 +56,15 @@
|
||||
"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"
|
||||
],
|
||||
"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": [
|
||||
@@ -212,18 +239,30 @@
|
||||
"clarify_max_attempts": 3,
|
||||
"tool_timeout": "30s",
|
||||
"tools": [
|
||||
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false },
|
||||
{ "name": "ps", "cmd": ["docker", "ps"], "scope": "homelab", "destructive": false },
|
||||
{ "name": "uptime", "cmd": ["uptime"], "scope": "homelab", "destructive": false },
|
||||
{ "name": "disk", "cmd": ["df", "-h"], "scope": "homelab", "destructive": false },
|
||||
{ "name": "memory", "cmd": ["free", "-h"], "scope": "homelab", "destructive": false },
|
||||
{ "name": "logs", "cmd": ["journalctl", "-n", "50", "-u"], "scope": "homelab", "destructive": false },
|
||||
{ "name": "restart", "cmd": ["systemctl", "restart"], "scope": "homelab", "destructive": true },
|
||||
{ "name": "stop", "cmd": ["systemctl", "stop"], "scope": "homelab", "destructive": true },
|
||||
{ "name": "start", "cmd": ["systemctl", "start"], "scope": "homelab", "destructive": true },
|
||||
{ "name": "docker-restart", "cmd": ["docker", "restart"], "scope": "homelab", "destructive": true },
|
||||
{ "name": "docker-stop", "cmd": ["docker", "stop"], "scope": "homelab", "destructive": true },
|
||||
{ "name": "reboot", "cmd": ["systemctl", "reboot"], "scope": "homelab", "destructive": true }
|
||||
{ "name": "status", "cmd": ["systemctl", "status"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["статус", "покажи статус", "проверь статус"] },
|
||||
{ "name": "ps", "cmd": ["docker", "ps"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["статус докера", "лог докера", "покажи запущенные контейнеры", "покажи контейнеры", "список контейнеров", "что запущено"] },
|
||||
{ "name": "uptime", "cmd": ["uptime"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["покажи uptime", "аптайм", "как работает сервер", "сколько работает сервер"] },
|
||||
{ "name": "disk", "cmd": ["df", "-h"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["сколько места на диске", "сколько свободного места на диске", "место на диске", "покажи диск"] },
|
||||
{ "name": "memory", "cmd": ["free", "-h"], "scope": "homelab", "destructive": false,
|
||||
"aliases": ["свободная память", "сколько оперативной памяти свободно", "покажи память"] },
|
||||
{ "name": "logs", "cmd": ["journalctl", "-n", "50", "-u"], "scope": "homelab", "destructive": false,
|
||||
"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": ["перезагрузи сервер", "перезагрузи хост"] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
# deploy/telegram.env is gitignored — never commit the real secrets.
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
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
|
||||
# - ./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:
|
||||
dbdata:
|
||||
sockets:
|
||||
|
||||
+34
-1
@@ -1,6 +1,6 @@
|
||||
# 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`
|
||||
> (consolidated decisions, 2026-06-30) and `ROADMAP.md` (execution plan,
|
||||
@@ -282,6 +282,39 @@ Three reasons, in the order they settle it:
|
||||
So the notice stays what it is: the in-process TTL case, where she really did
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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,94 @@
|
||||
# The reminder completeness rule, measured on the box
|
||||
|
||||
Measured 2026-08-06 at 02:34 local, on homesrv, master `59cc882`, immediately after V-577
|
||||
and V-579 merged as #231. Probe is `POST /api/chat` through mavweb, which is the same
|
||||
`handleText` seam the mic and telegram use.
|
||||
|
||||
## The rule being measured
|
||||
|
||||
The owner ruled it the same night. A reminder commits only when three things are answered:
|
||||
what, what time, and what day. Anything missing is asked for. Every time question opens by
|
||||
stating the clock.
|
||||
|
||||
He gave two examples and confirmed two derived cases when asked.
|
||||
|
||||
## Result: the rule holds on all four
|
||||
|
||||
| said | what | time | day | reply |
|
||||
|---|---|---|---|---|
|
||||
| `напомни завтра в 3 заказать цветы` | yes | no part of day | yes | `Сейчас 02:34. Это утра или вечера?` |
|
||||
| `напомни в 9 вечера разгрузить стиралку` | yes | yes | no | `Сейчас 02:34. В какой день?` |
|
||||
| `напомни завтра в 15:00 позвонить врачу` | yes | yes | yes | `хорошо, напомню завтра в 15:00.` |
|
||||
| `напомни через час выключить духовку` | yes | yes | yes | `хорошо, напомню сегодня в 03:34.` |
|
||||
|
||||
Row two is the one worth noting. `в 9 вечера` is unambiguous as a clock time and is still
|
||||
asked about, because the day is missing. Today being a valid reading is not enough.
|
||||
|
||||
## The invented clock is gone
|
||||
|
||||
Last night the worst failure was silent. A reminder was written at the current minute and
|
||||
nothing said so.
|
||||
|
||||
```text
|
||||
напомни забрать посылку -> Сейчас 02:34. Когда?
|
||||
что у меня сегодня? -> на 06.08.2026 ничего нет, на какое время поставить напоминание? [s=calendar]
|
||||
в 15 -> Сейчас 02:34. В какой день?
|
||||
```
|
||||
|
||||
Measured last night, the second turn answered nothing and wrote a reminder at the clock.
|
||||
The question now reaches the calendar and the parked question resumes on the same reply.
|
||||
|
||||
The root cause was one step earlier than filed. `ownContent("что у меня сегодня?")` returns
|
||||
empty, because every token is frame. `что` is an interrogative, `у` a frame word, `меня`
|
||||
filler, `сегодня` a day offset. So `needsRoute` said no and no route was computed at all.
|
||||
The turn fell through to answering the parked question. The extractor read `сегодня`, and
|
||||
the date parser answered a bare day word with that day at the current minute.
|
||||
|
||||
So this was not a claimant beating the route. It was a claimant running where the route was
|
||||
never asked for.
|
||||
|
||||
## Two defects the probe found
|
||||
|
||||
**V-592, invented content.** `я выпил воды` came back as
|
||||
`Проверила, что ты выпел стакан воды`. `выпел` is not a Russian word and `стакан` was never
|
||||
said. The arbitration around it is correct: the aside is taken, the parked question resumes,
|
||||
nothing is dropped. Only the content is wrong. Whether the store agrees with the sentence is
|
||||
not yet known. That answer decides whether this is a phrasing defect or a capture defect.
|
||||
|
||||
**V-593, an unacknowledged partial answer.** `на 9` then `на завтра` produces the same ask
|
||||
twice, byte-identical.
|
||||
|
||||
```text
|
||||
а, да, прости - на 9 -> Сейчас 02:34. Это утра или вечера?
|
||||
на завтра -> Сейчас 02:34. Это утра или вечера?
|
||||
```
|
||||
|
||||
The behaviour is right, since the part of day is missing both times. The wording gives him
|
||||
no evidence he was heard, which is the failure mode the V-558 family exists to remove.
|
||||
|
||||
## The pinned acceptance transcript is now superseded
|
||||
|
||||
The transcript the owner wrote has her guess a reading and accept `на завтра` as the last
|
||||
turn:
|
||||
|
||||
```text
|
||||
me: а, да, прости - на 9.
|
||||
maven: сейчас 15:23 - на 9 сегодня вечером?
|
||||
me: на завтра.
|
||||
maven: напомню.
|
||||
```
|
||||
|
||||
Under the rule he later ruled, `на 9` plus `на завтра` still names no part of day. So she
|
||||
must ask a third time rather than commit. The transcript and the rule disagree, and the rule
|
||||
is the newer statement. Offering a reading, as that transcript does, remains allowed and is
|
||||
not required. The implementation asks `Это утра или вечера?` and offers none.
|
||||
|
||||
## Regression checked
|
||||
|
||||
A new request still drops the old one out loud. That is the behaviour the owner kept when he
|
||||
rejected the silent drop for side queries.
|
||||
|
||||
```text
|
||||
напомни позвонить маме -> Сейчас 02:34. Когда?
|
||||
напомни завтра в 9 купить молоко -> Прошлую просьбу отпускаю. Сейчас 02:34. Это утра или вечера?
|
||||
```
|
||||
@@ -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,82 @@
|
||||
# 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 six of them cluster
|
||||
|
||||
The six 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.
|
||||
|
||||
## 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,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.
|
||||
+12
-2
@@ -1,6 +1,6 @@
|
||||
# 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`.
|
||||
|
||||
@@ -44,7 +44,8 @@ Config path: `~/.config/maven/mavend.json`. Full example with all options.
|
||||
"repeat_interval": "5m",
|
||||
"ntfy": {
|
||||
"base_url": "https://ntfy.kvmx.ru",
|
||||
"topic": "maven"
|
||||
"topic": "maven",
|
||||
"token": "${NTFY_TOKEN}"
|
||||
},
|
||||
"phraser": {
|
||||
"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.
|
||||
|
||||
`${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)
|
||||
|
||||
Requires `LD_LIBRARY_PATH` to include deps/lib (for libwhisper.so, libggml-vulkan.so).
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
# Two artifacts, and neither one is Spring
|
||||
|
||||
Proposal. V-585. Related umbrella V-558, and the design collected in
|
||||
`docs/plans/19-dialogue-arbitration.md`.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Thesis one holds for four seams and fails for one.** Four are one shape: the routing
|
||||
cascade, the query source chain, the pre-route resolver ladder, the digestion tick. Reach
|
||||
selection is not. It maps severity and presence to a set of channels. It has no claimants and
|
||||
no losers.
|
||||
|
||||
**Thesis two holds.** An arbitration kernel is a package and a convention inside one program.
|
||||
It is not a framework. A framework whose only client is the codebase it came from is that
|
||||
codebase with more ceremony.
|
||||
|
||||
**The answer is two artifacts of different sizes.** One package inside Maven, built from
|
||||
`internal/claim` and `internal/decision`. Both already exist and neither is wired. One small
|
||||
library across the four services, holding the correlation id, the headers, the timeout policy
|
||||
and the named gap. Neither is Spring. The daemons must not get a third.
|
||||
|
||||
**The strongest finding is the duplication, not the shape.** Three structural holes make a
|
||||
route untrustworthy. They are written out by hand in three files, for three consumers, with
|
||||
three return types. `gateLLMDecision` flattens them to a float. `vetoOf` re-derives them as a
|
||||
sentence. `thinReason` re-derives them again as a trace string. That is what having no common
|
||||
unit costs, and it is countable in lines rather than in taste.
|
||||
|
||||
## The five seams, tested
|
||||
|
||||
### 1. The routing cascade. The shape, ordered by hand.
|
||||
|
||||
The claimant is `router.Grammar` (`internal/router/stage0.go:19`). It holds a name, a regex
|
||||
and a `Build` that may still decline. A claim is a regex match plus `ok` from `Build`.
|
||||
|
||||
Ordering is the append order in `buildRouter` (`cmd/mavend/voicewire.go:384`). Twelve appends.
|
||||
Each one carries a comment arguing its position against its neighbours. First match wins at
|
||||
confidence 1.0.
|
||||
|
||||
Below stage 0 the two engines are alternatives, not rivals. The classifier runs only when the
|
||||
router is nil or errored. Inside the classifier the order is cosine score, and the top three
|
||||
are recorded.
|
||||
|
||||
Losers are recorded and change nothing. `noteGrammarOutcomes`
|
||||
(`internal/router/decisiontrace.go:56`) separates a grammar that did not match from one whose
|
||||
`Build` declined. Everything past the winner is marked `NeverAsked`.
|
||||
|
||||
### 2. The query source chain. The shape, ordered by hand, with a boundary in it.
|
||||
|
||||
The claimant is `querySource` (`cmd/mavend/actions_query.go:45`), a name and one function
|
||||
returning `(string, bool)`. Twenty-four of them sit in one slice literal, walked in order. The
|
||||
comment on the slice says the order is load-bearing. It is right.
|
||||
|
||||
This seam carries something the others do not. The personal boundary at line 139 is a stop,
|
||||
not an answer. Everything above it reads the owner's data. Everything below reads the world. A
|
||||
question about him that reaches the boundary ends there.
|
||||
|
||||
### 3. The pre-route resolver ladder. The shape, and the one that hurts.
|
||||
|
||||
Seven rungs, each returning `(reply string, handled bool)`. Ordering is the order of the `if`
|
||||
statements in `runTurn` (`cmd/mavend/voice.go:258`). The roster in `preRouteLadder`
|
||||
(`cmd/mavend/decisiontrace.go:33`) is kept by hand, and its own comment admits nothing
|
||||
enforces the correspondence.
|
||||
|
||||
The recurring bug lives here. A rung claims before the utterance is routed. So the claimant
|
||||
with the earliest and strongest trigger is the one that knows least about what was said. V-560
|
||||
fixed half of it. It computes the route once, before the ladder, and lets the clarify resolver
|
||||
read it. The other rungs still decide without reading it.
|
||||
|
||||
### 4. The digestion tick. The shape, and the only one already done right.
|
||||
|
||||
This corrects the brief. `loop.Tick` (`internal/loop/loop.go:85`) is not a first-to-claim
|
||||
walk. It is an arbitration with a declared comparator.
|
||||
|
||||
- The claimant is `loop.Rule`. Its `Predicate` says whether it wants the turn.
|
||||
- The gate is separate from the claim. `Gate` (`loop.go:21`) checks snooze, cooldown, quiet
|
||||
hours, calendar busy, presence and missing data. `ExplainGate` names which one blocked.
|
||||
- The comparator is data, not position. Max severity wins, and ties break on name.
|
||||
`DefaultRules` states outright that slice order is not load-bearing.
|
||||
- Losers are recorded with what they lost to. `ExplainTick` (`internal/loop/explain.go:86`)
|
||||
fills `LostTo`, and rewrites the previous best when a higher severity displaces it.
|
||||
- Losers get a second life. `DigestEligible` (`loop.go:129`) decides which suppressed
|
||||
candidates are bundled for later. It refuses cooldown and snooze, because neither is
|
||||
restraint.
|
||||
|
||||
Every property the kernel wants already exists here, on five rules. The kernel argues that the
|
||||
other three seams should look like this one. It does not need a new idea.
|
||||
|
||||
### 5. Reach selection. Not the shape.
|
||||
|
||||
`ChannelsFor` (`internal/delivery/channel.go:73`) takes severity and presence and returns a
|
||||
slice of channels. Nothing claims. Nothing passes. Nothing loses. Every channel in the
|
||||
returned slice sends, so there is not even one winner.
|
||||
|
||||
Naming the sinks claimants would be the forced abstraction. It would also hide the property
|
||||
this table has and the ladders lack. It is total, it is pure, and every cell is covered by
|
||||
`TestChannelsForEveryTableCell`.
|
||||
|
||||
One thing in the dispatcher does re-decide. `ErrVoiceNoSession` means the presence guess was
|
||||
wrong, so the remaining channel list is replaced with the away table
|
||||
(`internal/delivery/dispatcher.go:188`). That is a retry on new evidence, not a contest. Leave
|
||||
it alone.
|
||||
|
||||
### What else has the shape
|
||||
|
||||
`fillMatchedSlots` (`internal/router/router.go:212`) arbitrates per slot. A matched value
|
||||
always wins, and the extractor fills only what was left empty. That is the coverage-first rule
|
||||
the kernel proposes, written once for four slots.
|
||||
|
||||
`bestRecall` and the topic veto pick between a fact and a note by score with a margin. That
|
||||
one is a real score comparison, and it should stay one.
|
||||
|
||||
## The abstractions
|
||||
|
||||
Three, not four. The straw man had `Claimant`, `Claim`, `Arbiter` and `Record`. Drop
|
||||
`Claimant`.
|
||||
|
||||
**`claim.Claim`, evidence rather than a verdict.** It exists at `internal/claim/claim.go`,
|
||||
built and tested, imported by one function that nothing calls. It carries `Consumed` and
|
||||
`Unexplained` for coverage, an ordinal `Band`, and a `Veto` string that keeps the reason a
|
||||
float threw away. Coverage is compared before band. That is the fix for the Rome failure,
|
||||
where a parked reminder ate the whole utterance while explaining none of it.
|
||||
|
||||
**`decision.Record`, the trace.** It exists at `internal/decision/decision.go` and it is wired
|
||||
everywhere. It separates won, declined, lost on score, thinned, merged and never asked. The
|
||||
last one is the valuable one. A claimant that never looked reads identically to one that looked and
|
||||
passed. That is what hardcoded order hides.
|
||||
|
||||
**`Arbiter`, the thing that does not exist.** One function. It takes a set of claims and a
|
||||
comparator, returns a winner, and notes the rest. `loop.Tick` is that function, specialised to
|
||||
rules. Generalising it is the proposal.
|
||||
|
||||
**Against a `Claimant` interface.** Every seam already rejected one, for the same reason.
|
||||
`querySource` is a struct of one function because the sources are methods on one handler with
|
||||
no state. An interface would mean one empty type per source. `confirmResolver` is the same
|
||||
shape, and `loop.Rule` is a struct with a closure. An interface would buy a shared name and
|
||||
cost twenty-four empty types. The claimants stay what they are. Each seam builds `claim.Claim`
|
||||
values at its own edge, which is what `router.ClaimOf` already does.
|
||||
|
||||
## What ordering becomes
|
||||
|
||||
Ordering becomes a comparator plus a rank, and the rank is data.
|
||||
|
||||
Today ordering is position in a slice, and position is invisible in the record. Add a rung to
|
||||
`runTurn`, forget `preRouteLadder`, and the rung vanishes from the trace. The roster's own
|
||||
comment admits nothing enforces it.
|
||||
|
||||
The proposal is smaller than a dependency graph. A claimant declares a rank. The arbiter sorts
|
||||
by coverage, then band, then rank. Rank breaks the tie that evidence cannot break.
|
||||
|
||||
**A dependency graph is the wrong tool.** The real constraints are pairwise and local. Day
|
||||
plan before calendar. Praxis before the capture marker. Narrative last. A graph turns those
|
||||
into edges and then needs a topological sort whose output nobody can read. The twelve comments
|
||||
in `buildRouter` would become twelve edges with the arguments deleted. Keep the arguments.
|
||||
|
||||
**Two claimants at the same rank must be an error, caught at wiring time.** Not at turn time.
|
||||
The registry is built once at boot, so a duplicate rank is a boot failure naming both
|
||||
claimants. Falling back to slice order on a tie would restore the invisible ordering the
|
||||
kernel exists to remove.
|
||||
|
||||
**The roster stops being hand-kept.** A claimant registered with the arbiter is on the roster
|
||||
by construction. That deletes the `preRouteLadder` failure mode outright.
|
||||
|
||||
## What it buys
|
||||
|
||||
**It deletes three copies of one test.** The three structural holes appear in
|
||||
`gateLLMDecision` (`internal/router/router.go:271`), in `vetoOf`
|
||||
(`internal/router/claim.go:101`), and in `thinReason` (`internal/router/decisiontrace.go:28`).
|
||||
Three files, three return types, one rule. A fourth consumer would write it a fourth time.
|
||||
With a claim carrying `Veto`, the rule is written once. The float, the sentence and the trace
|
||||
string all derive from it. This one is worth the work on its own.
|
||||
|
||||
**It makes the recurring bug expressible.** Rome, V-567 and V-577 are one defect. The claimant
|
||||
that knows least holds the earliest trigger. Coverage-first arbitration states the fix once,
|
||||
in `MoreSpecificThan`. A parked clarify explaining zero tokens of "какая сейчас погода в
|
||||
Риме?" loses to a weather claim explaining all of them. Nobody has to encode that a parked
|
||||
clarify is less trustworthy than a grammar.
|
||||
|
||||
**The limit.** The kernel prevents the class only where the losing claimant
|
||||
computes low coverage. Rome, V-567 and V-577 all qualify. Each is a stateful claimant
|
||||
swallowing an utterance it explains none of. A claimant that matches a substring does explain
|
||||
those tokens, and coverage does not catch it. V-567's substring match is that case from the
|
||||
other side. Coverage there has to be measured against the whole utterance rather than the
|
||||
matched span. `claimSpans` already does that, in the safe direction. So the kernel
|
||||
prevents most of the class and describes the rest. Claiming more would be dishonest.
|
||||
|
||||
**It makes contention countable.** Today it is not. The 91-case fixture draws two stage-0
|
||||
grammars exactly once, at `ru-query-019`, and both route the same intent. Nobody knows whether
|
||||
contention is rare or whether the fixture omits it. An arbiter that sees every claim can
|
||||
count.
|
||||
|
||||
**What it does not buy.** No accuracy point comes from this alone. Every number in
|
||||
`docs/evals/2026-08-05-routing-resident-model.md` is reachable without it. The kernel is a
|
||||
place to put the fix, not the fix.
|
||||
|
||||
## What it costs
|
||||
|
||||
Every seam rewritten is a chance to break a measured number.
|
||||
|
||||
Re-measure the 91-case routing fixture. The baseline is 75.8% full and 80.2% intent-only at
|
||||
p50 1.19s, in `docs/evals/2026-08-05-routing-resident-model.md`. Judge against the classifier
|
||||
and the resident model, because those are what always answer.
|
||||
|
||||
Re-measure Praxis reach. The baseline is 27/30 overall, 11/12 Praxis and 5/5 lifecycle, in
|
||||
`docs/evals/2026-08-05-praxis-reach.md`. The Praxis grammars are the only path to Praxis, so a
|
||||
reordering that demotes them costs every point.
|
||||
|
||||
Re-run the nine-scenario interleave probe in `docs/evals/2026-08-06-claimant-interleave.md`.
|
||||
Six of nine pass today. That probe measures exactly what this proposal is for.
|
||||
|
||||
Re-run the dialogue contract tests from V-563. They are whole multi-turn traces, and the only
|
||||
tests that cover the ladder as a ladder.
|
||||
|
||||
Latency is the cheap part. A stage-0 query costs 3.7µs. One claim per claimant adds two slices
|
||||
and a token split, on a path whose p50 is over a second.
|
||||
|
||||
The real cost is the arguments. Twenty-three comments across `buildRouter` and `querySources`
|
||||
explain why each entry sits where it does. A migration that turns them into rank integers and
|
||||
drops the prose destroys the only documentation the ordering has.
|
||||
|
||||
## Migration order
|
||||
|
||||
**Step one, no behaviour change. Delete the duplication.** Make `vetoOf` the single definition
|
||||
of the three structural holes. Have `gateLLMDecision` and `thinReason` read it. One rule,
|
||||
three consumers, no new abstraction. Re-measure the routing fixture and nothing else. This is
|
||||
worth landing whether or not the rest does.
|
||||
|
||||
**Step two, the proof. Arbitrate the pre-route ladder.** Smallest seam, seven rungs, and the
|
||||
one with the measured defect. Each resolver returns a claim instead of a bool. The arbiter
|
||||
compares coverage, then band, then rank. The roster comes from the registry. The proof is the
|
||||
interleave probe reaching nine of nine with the routing fixture unmoved.
|
||||
|
||||
**Step three, the query source chain.** Twenty-four sources, most of which already compute a
|
||||
match span. The personal boundary does not become a ranked claimant. It stays a hard stop, and
|
||||
the arbiter runs above it and below it separately. Re-measure Praxis reach and the search and
|
||||
Kiwix fallback.
|
||||
|
||||
**Step four, stage 0, or not at all.** Twelve grammar groups whose order encodes twelve
|
||||
arguments, scoring 20/20 on the fixture. Most to lose, least to gain. Defer it until steps two
|
||||
and three have sat in the deploy long enough to break something.
|
||||
|
||||
**`loop.Tick` moves last or never.** It already has the comparator, the gate with reasons, the
|
||||
loser trace and the loser rescue. Rewriting it to call a generic arbiter risks the digest path
|
||||
to gain a shared name.
|
||||
|
||||
## What must not be in the kernel
|
||||
|
||||
**Authorization.** This is the hard line. CLAUDE.md is explicit that LLM output is not
|
||||
authorization, and that a confirmation binds capability id, target entity, arguments,
|
||||
requester and expiry (`cmd/mavend/confirm.go`). A generic arbiter turns many opinions into
|
||||
one winner. That is the wrong shape for a binding. Make confirm a claimant with a rank and a band, and a claim that scored higher could take the
|
||||
turn from it. The binding would be softened into a comparison. Confirm may report to the record. It must not compete in the
|
||||
arbiter.
|
||||
|
||||
The same rule covers the Hexis path. Free text never reaches a mutating call, and resolution
|
||||
happens against Nexus. Neither is a contest, so neither is arbitration.
|
||||
|
||||
**The personal boundary.** Same reason, different currency. The boundary is not the most
|
||||
specific claimant. It is a stop. A boundary that can lose to a higher-coverage claim is not a
|
||||
boundary. The failure is the owner's notes reaching a search engine.
|
||||
|
||||
**Confidence as a float.** The band exists because the measurement said a calibrated float is
|
||||
not available. The classifier scores 62% correct below its median and 62% above, over a spread
|
||||
0.083 wide. Its top-two margin has a p50 of 0.009
|
||||
(`docs/plans/19-dialogue-arbitration.md`). A kernel with a `Score float64` on the claim invites
|
||||
every claimant to invent one. When V-546 lands a softmax head with a calibrated probability,
|
||||
that number is read beside the bands, not squeezed inside them.
|
||||
|
||||
**Slot extraction and validation.** `fillMatchedSlots` runs after a winner exists, and slot
|
||||
validation against the action schema is V-562. Both ask whether a claim is well formed. Neither
|
||||
asks which claim wins.
|
||||
|
||||
## The second artifact: the ecosystem client
|
||||
|
||||
The kernel is Maven's alone. The one thing here with plural clients by construction is the
|
||||
contract between the four services. Maven implements its side of it twice and a half.
|
||||
|
||||
### Is the contract uniform today? No.
|
||||
|
||||
Nexus and Praxis share one implementation. `ecosystemHTTP` (`cmd/mavend/ecosystem.go:62`) is
|
||||
embedded in both, so both get the same 10 second timeout, the same `setHeaders`, the same
|
||||
typed `ecosystemError`, and the same correlation key. Hexis is a separate client in another
|
||||
repository, vendored at `vendor/github.com/kami/hexis/pkg/client`, and it agrees on some of
|
||||
that and not the rest.
|
||||
|
||||
Eleven divergences. Four of them are defects rather than style, and each is filed on its own:
|
||||
V-587 the 401, V-588 the unnamed Praxis service, V-590 the uncorrelated discovery hop, V-591
|
||||
the unsent causation id. They are ranked in that order, worst first, and none of them waits on
|
||||
this proposal. V-587 is the only one that makes the owner check the wrong thing.
|
||||
|
||||
**A Hexis 401 is spoken as an outage.** Nexus and Praxis return `*ecosystemError` with
|
||||
`Unauthorized()`, `ContractMismatch()` and `Unreachable()` classifiers
|
||||
(`cmd/mavend/ecosystem.go:144`). Hexis returns `fmt.Errorf` strings
|
||||
(`hexis/pkg/client/client.go:157`). So `unauthorizedEcosystemError`
|
||||
(`cmd/mavend/ecosystem_acts.go:549`) does `errors.As` and always gets false for Hexis. The
|
||||
owner hears "Hexis is down" when the truth is that Hexis refused the credential. The comment
|
||||
at `ecosystem_acts.go:32` says that conflation must not happen.
|
||||
|
||||
**The Hexis discovery hop is uncorrelated.** Hexis carries its own context key
|
||||
(`client.go:81`), invisible to Maven's. The bridge is a manual second stamp at
|
||||
`ecosystem.go:546`. `discoverCapabilities` (`ecosystem.go:527`) does not do it, and discovery
|
||||
runs before execute, so that call goes out with no correlation id. The doc comment above it
|
||||
claims the opposite.
|
||||
|
||||
**Causation is computed and never sent.** `causationID` is derived at
|
||||
`ecosystem_acts.go:771`. Hexis supports `X-Causation-ID` (`client.go:147`). Nothing passes it.
|
||||
|
||||
**A Praxis failure names no service.** `ecosystemGap` (`ecosystem_acts.go:36`) is the shared
|
||||
named-gap helper, and Nexus and Hexis call it. Praxis returns per-verb Russian strings instead
|
||||
(`ecosystem_acts.go:62`, `:73`, `:84`, `:95`). There is no `servicePraxis` constant. A Praxis
|
||||
outage and a Praxis 403 both say "не получилось", with the service unnamed.
|
||||
|
||||
The rest are real but smaller. Hexis sends no `X-Requested-By: maven` and no `Accept` header,
|
||||
so Hexis cannot attribute a read call to Maven at all. Its timeout is 30 seconds against
|
||||
Maven's 10, which Maven cannot change from here. Its success predicate is `>= 400` where the
|
||||
shared client uses `!= 200`. `withToken` is duplicated verbatim per client because the
|
||||
embedded struct cannot return the concrete type. The version constant `"v1"` is defined twice,
|
||||
in two repositories, with nothing keeping the two equal.
|
||||
|
||||
### Would a shared library have plural clients?
|
||||
|
||||
**Partly verifiable, and the honest answer is that two of the three cannot be checked from
|
||||
here.**
|
||||
|
||||
Hexis is verified. It is a Go module consumed through a `replace` directive, and it already
|
||||
publishes a Go client library that Maven imports. That is an existence proof that the pattern
|
||||
works for one of them.
|
||||
|
||||
Praxis is suggested and not proven. Two comments reference Go paths in its repository
|
||||
(`cmd/mavend/factenrichment.go:4`, `cmd/mavend/ecosystem.go:365`). Nothing here compiles
|
||||
against it.
|
||||
|
||||
Nexus is unverifiable from this repository. There is an HTTP base URL and some JSON shapes,
|
||||
and no language signal at all.
|
||||
|
||||
So the claim that a shared library would have plural clients rests on one confirmed adopter
|
||||
and two assumptions. Do not present it as settled. The cheaper test is to fix Maven's side first,
|
||||
in Maven. Offer the package outward once it has proven itself on one caller.
|
||||
|
||||
### What belongs in it
|
||||
|
||||
The straw man is right, with one addition.
|
||||
|
||||
- The correlation id, minted once per action, with one context key that all clients read. The
|
||||
two-key split is the cause of the uncorrelated discovery hop.
|
||||
- Causation, since one of the three already supports it and the value is already computed.
|
||||
- The version header and `X-Requested-By`.
|
||||
- The timeout policy, as one number rather than 10 in one repository and 30 in another.
|
||||
- A typed error with the three classifiers, so a refused credential never speaks as an outage.
|
||||
- The named gap shape, so no client invents its own vocabulary the way Praxis did.
|
||||
|
||||
### What must stay out
|
||||
|
||||
**Authorization, for the second time and the same reason.** The confirmation binding is
|
||||
Maven's. It binds capability id, target entity, arguments, requester and expiry, and it lives
|
||||
in `cmd/mavend/confirm.go`. A shared client that offered a policy hook would invite each
|
||||
service to supply its own, and the binding would become configuration. Free text never reaches
|
||||
a mutating Hexis call, and entity resolution stays in Nexus. Neither belongs in a transport
|
||||
library.
|
||||
|
||||
Retry stays out too, or nearly. The only retry Maven has is Nexus enrichment at the worker
|
||||
layer, with backoff from one minute to one hour (`cmd/mavend/factenrichment.go:49`). It is
|
||||
there because enrichment is a background job with no listener. A turn cannot retry, because
|
||||
the owner is standing there. A transport-level retry in a shared library would hide a
|
||||
second budget behind a turn that already has a name for failing.
|
||||
|
||||
## The daemons must not get a framework
|
||||
|
||||
The instinct is right, and half the work is already done in a way that shows why.
|
||||
|
||||
Transport is shared and abstracted. `ipc.Dial` (`internal/ipc/client.go:94`) and `ipc.Listen`
|
||||
(`internal/ipc/server.go:202`) both go through `netaddr`. A bare path is a unix socket with
|
||||
`SO_PEERCRED` identity. A `tcp://host:port?token=...` address binds a network listener with a
|
||||
mandatory token. Callers pass a string and never branch on scheme. That seam made the
|
||||
workstation offload a deployment rather than a build. It is a library, it has nine clients in
|
||||
this repository, and it earns its weight.
|
||||
|
||||
Startup is not shared, and should not be. There are twelve binaries under `cmd/`, each with a
|
||||
hand-written `main`, and no common lifecycle package. `cmd/mavend/main.go` runs 830 lines. The
|
||||
reason is visible in it. The daemon can boot **locked**, with no store at all, and wire its
|
||||
components later from inside an unlock handler. Thirteen subsystem pointers are pre-declared
|
||||
nil (`main.go:236`) and filled on one of two paths. The whole graph is built a second time
|
||||
inside the unlock path (`main.go:478`). A container owning object lifecycle would have to model
|
||||
a graph whose nodes do not exist at boot and may never exist.
|
||||
|
||||
The deeper reason is a design property. Every daemon degrades alone, and every ecosystem
|
||||
client is nil unless configured. A wiring framework's job is to fail loudly when a dependency
|
||||
is missing. Maven needs the opposite: a missing dependency is a named gap in one answer and a
|
||||
working daemon everywhere else. An abstraction over startup would trade that property for a
|
||||
shorter `main`.
|
||||
|
||||
The duplicated locked and unlocked wiring in `mavend` is a real defect and worth fixing. Fix it
|
||||
by extracting one function in that file. That is not a framework.
|
||||
|
||||
## The honest comparison
|
||||
|
||||
Spring and Django own object lifecycle and request handling for applications they have never
|
||||
seen. That is where the weight is paid for. The abstraction is general because the clients are
|
||||
unknown.
|
||||
|
||||
Maven is one application, on one box, with one user. Its clients are known, there are nine of
|
||||
them, and they are in this repository. A framework here would be an abstraction with a census.
|
||||
|
||||
So the right unit is a shared package and a convention. That is a smaller and more defensible
|
||||
claim. The evidence for its size is that both halves already exist in that form, and nobody
|
||||
called them a framework. `internal/ipc` is a shared package and a convention, and it carried
|
||||
the daemons off the box. `internal/decision` is a shared package and a convention, and it made
|
||||
the losers readable in one release. `internal/claim` is the third, written and waiting for a
|
||||
caller.
|
||||
|
||||
The thing to disagree with: **the answer is two artifacts of different sizes. A package inside
|
||||
Maven, a library across the four services, and neither one is Spring.**
|
||||
@@ -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.
|
||||
@@ -82,6 +82,38 @@ func TestWAVRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A LIST chunk sitting between fmt and data is common (arecord and ffmpeg both
|
||||
// write one), and its payload is free text that can spell "data". The parser
|
||||
// walks chunk headers, so the text is skipped and the real samples are read.
|
||||
func TestPCMFromWAVSkipsLISTChunk(t *testing.T) {
|
||||
t.Parallel()
|
||||
pcm := []byte{1, 0, 2, 0, 3, 0, 4, 0}
|
||||
list := []byte("LIST")
|
||||
payload := []byte("INFOICMTdata is not here")
|
||||
list = binary.LittleEndian.AppendUint32(list, uint32(len(payload)))
|
||||
list = append(list, payload...)
|
||||
|
||||
plain, err := WAVFromPCM(PCM16kMono, pcm)
|
||||
if err != nil {
|
||||
t.Fatalf("WAVFromPCM: %v", err)
|
||||
}
|
||||
wav := append([]byte{}, plain[:36]...)
|
||||
wav = append(wav, list...)
|
||||
wav = append(wav, plain[36:]...)
|
||||
binary.LittleEndian.PutUint32(wav[4:8], uint32(len(wav)-8))
|
||||
|
||||
f, got, err := PCMFromWAV(wav)
|
||||
if err != nil {
|
||||
t.Fatalf("PCMFromWAV: %v", err)
|
||||
}
|
||||
if !f.IsValid() {
|
||||
t.Fatalf("parsed format invalid: %+v", f)
|
||||
}
|
||||
if !bytes.Equal(got, pcm) {
|
||||
t.Fatalf("PCM mismatch: got %v, want %v", got, pcm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPCMFromWAVRejectsNonCanonical(t *testing.T) {
|
||||
t.Parallel()
|
||||
// too short
|
||||
|
||||
+30
-12
@@ -36,6 +36,10 @@ const wavHeaderSize = 44
|
||||
// raw PCM samples (little-endian int16 as bytes). A non-canonical blob is
|
||||
// rejected with ErrNotCanonicalPCM; the format mismatch is logged at the seam
|
||||
// so the caller surfaces it, not a hidden silent downmix.
|
||||
//
|
||||
// The returned PCM aliases wav rather than copying it, because a recording is
|
||||
// large and the caller already owns the bytes. A caller that keeps the PCM past
|
||||
// the life of wav, or that reuses wav as a read buffer, must copy first.
|
||||
func PCMFromWAV(wav []byte) (Format, []byte, error) {
|
||||
if len(wav) < wavHeaderSize {
|
||||
return Format{}, nil, fmt.Errorf("audio: wav too short: %d bytes", len(wav))
|
||||
@@ -61,17 +65,13 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
|
||||
return Format{}, nil, fmt.Errorf("%w: channels=%d bits=%d (want 1/16)", ErrNotCanonicalPCM, channels, bitsPerSample)
|
||||
}
|
||||
// data chunk: the spec mandates it appears right after fmt, but real
|
||||
// recorders sometimes append extra chunks (LIST, fact). Find the "data"
|
||||
// chunk by scanning; require it within the region we'd expect.
|
||||
dataIdx := -1
|
||||
for i := wavHeaderSize - 8; i+8 <= len(wav) && i < wavHeaderSize+4096; i++ {
|
||||
if string(wav[i:i+4]) == "data" {
|
||||
dataIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if dataIdx < 0 {
|
||||
return Format{}, nil, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
|
||||
// recorders sometimes append extra chunks (LIST, fact). Walk the chunk
|
||||
// headers rather than scanning for the four bytes "data", because those
|
||||
// bytes occur inside a LIST/INFO payload as ordinary text and a byte scan
|
||||
// would take the middle of a comment for a chunk header.
|
||||
dataIdx, err := findDataChunk(wav)
|
||||
if err != nil {
|
||||
return Format{}, nil, err
|
||||
}
|
||||
dataSize := binary.LittleEndian.Uint32(wav[dataIdx+4 : dataIdx+8])
|
||||
body := wav[dataIdx+8:]
|
||||
@@ -90,6 +90,24 @@ func PCMFromWAV(wav []byte) (Format, []byte, error) {
|
||||
return f, body, nil
|
||||
}
|
||||
|
||||
// findDataChunk returns the offset of the "data" chunk header, walking the
|
||||
// chunk list that starts after the 16-byte fmt chunk. Chunks are word-aligned,
|
||||
// so an odd size carries one pad byte the next header sits behind.
|
||||
func findDataChunk(wav []byte) (int, error) {
|
||||
for pos := wavHeaderSize - 8; pos+8 <= len(wav); {
|
||||
size := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8]))
|
||||
if string(wav[pos:pos+4]) == "data" {
|
||||
return pos, nil
|
||||
}
|
||||
next := pos + 8 + size + size%2
|
||||
if next <= pos || next > len(wav) {
|
||||
break
|
||||
}
|
||||
pos = next
|
||||
}
|
||||
return 0, fmt.Errorf("%w: no data chunk", ErrNotCanonicalPCM)
|
||||
}
|
||||
|
||||
// WAVFromPCM wraps raw 16-bit mono PCM bytes in a canonical 44-byte WAV
|
||||
// header so the result can be written to disk and played with `aplay`.
|
||||
// Used by the reference client to write the TTS reply; not on the wire.
|
||||
@@ -115,7 +133,7 @@ const WAVHeaderSize = wavHeaderSize
|
||||
// avoiding.
|
||||
func WAVHeader(format Format, n int) ([]byte, error) {
|
||||
if !format.IsValid() {
|
||||
return nil, fmt.Errorf("audio: WAVFromPCM: %w: %+v", ErrNotCanonicalPCM, format)
|
||||
return nil, fmt.Errorf("audio: WAVHeader: %w: %+v", ErrNotCanonicalPCM, format)
|
||||
}
|
||||
out := make([]byte, wavHeaderSize)
|
||||
// RIFF header
|
||||
|
||||
@@ -140,6 +140,12 @@ const EventKeyPrefix = "calendar_event_"
|
||||
//
|
||||
// An end at or before the start is read as crossing midnight, so a 23:30-00:15
|
||||
// meeting covers the quarter hour it actually covers.
|
||||
//
|
||||
// Both readings are built with time.Date rather than added to midnight as a
|
||||
// duration. A day is 23 or 25 hours wide on the two DST changeovers, so
|
||||
// midnight plus fourteen hours is 13:00 or 15:00 on those days, and the busy
|
||||
// gate would then read a 14:00 meeting an hour off. The same goes for the
|
||||
// midnight crossing, which is AddDate and not a 24-hour add.
|
||||
func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok bool) {
|
||||
if !strings.HasPrefix(key, EventKeyPrefix) {
|
||||
return time.Time{}, time.Time{}, false
|
||||
@@ -172,10 +178,11 @@ func FactSpan(key, value string, loc *time.Location) (start, end time.Time, ok b
|
||||
if !ok1 || !ok2 {
|
||||
return time.Time{}, time.Time{}, false
|
||||
}
|
||||
start = day.Add(time.Duration(sh)*time.Hour + time.Duration(sm)*time.Minute)
|
||||
end = day.Add(time.Duration(eh)*time.Hour + time.Duration(em)*time.Minute)
|
||||
y, mo, d := day.Date()
|
||||
start = time.Date(y, mo, d, sh, sm, 0, 0, loc)
|
||||
end = time.Date(y, mo, d, eh, em, 0, 0, loc)
|
||||
if !end.After(start) {
|
||||
end = end.Add(24 * time.Hour)
|
||||
end = end.AddDate(0, 0, 1)
|
||||
}
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func ParseICalDay(body []byte, now time.Time) []Event {
|
||||
// Reports false for all-day events and parse failures.
|
||||
func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
var e Event
|
||||
for _, line := range strings.Split(block, "\n") {
|
||||
for _, line := range strings.Split(unfold(block), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DTSTART"):
|
||||
@@ -78,9 +78,9 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
e.End = t
|
||||
}
|
||||
case strings.HasPrefix(line, "SUMMARY"):
|
||||
e.Summary = afterColon(line)
|
||||
e.Summary = unescapeText(afterColon(line))
|
||||
case strings.HasPrefix(line, "UID"):
|
||||
e.UID = afterColon(line)
|
||||
e.UID = unescapeText(afterColon(line))
|
||||
}
|
||||
}
|
||||
if e.Start.IsZero() || e.End.IsZero() {
|
||||
@@ -89,6 +89,48 @@ func parseVEVENT(block string, loc *time.Location) (Event, bool) {
|
||||
return e, true
|
||||
}
|
||||
|
||||
// unfold undoes RFC 5545 content-line folding, where a long property is split
|
||||
// with a CRLF and the continuation begins with one space or tab.
|
||||
//
|
||||
// It runs before the block is split into lines, because splitting first and
|
||||
// trimming each line destroys the leading space that marks a continuation. A
|
||||
// server folds at 75 octets and a Russian summary is two bytes a letter, so
|
||||
// "Еженедельная планёрка с командой" crosses the limit easily — without this
|
||||
// the tail of the summary was read as an unknown property and dropped, and the
|
||||
// event was filed under a truncated name.
|
||||
func unfold(block string) string {
|
||||
if !strings.Contains(block, "\n ") && !strings.Contains(block, "\n\t") {
|
||||
return block
|
||||
}
|
||||
return strings.NewReplacer("\r\n ", "", "\r\n\t", "", "\n ", "", "\n\t", "").Replace(block)
|
||||
}
|
||||
|
||||
// unescapeText reverses the RFC 5545 TEXT escaping escapeText applies. Without
|
||||
// it a summary a server wrote as "Обед\, потом созвон" reaches the day plan
|
||||
// with the backslash still in it, and FactKey folds that literal into the key.
|
||||
func unescapeText(s string) string {
|
||||
if !strings.Contains(s, `\`) {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] != '\\' || i+1 >= len(s) {
|
||||
b.WriteByte(s[i])
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch s[i] {
|
||||
case 'n', 'N':
|
||||
b.WriteByte('\n')
|
||||
default:
|
||||
// ";", ",", "\\" and anything else a writer escaped needlessly.
|
||||
b.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func afterColon(line string) string {
|
||||
if i := strings.Index(line, ":"); i >= 0 {
|
||||
return strings.TrimSpace(line[i+1:])
|
||||
|
||||
@@ -61,6 +61,44 @@ func TestRenderICalEscapesInjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A folded SUMMARY is one property, not a property plus a dropped tail. Servers
|
||||
// fold at 75 octets and a Russian summary is two bytes a letter.
|
||||
func TestParseICalUnfoldsAndUnescapes(t *testing.T) {
|
||||
body := []byte("BEGIN:VEVENT\r\n" +
|
||||
"UID:u1\r\n" +
|
||||
"DTSTART:20260703T130000Z\r\n" +
|
||||
"DTEND:20260703T140000Z\r\n" +
|
||||
"SUMMARY:Еженедельная планёрка\\, потом\r\n созвон\r\n" +
|
||||
"END:VEVENT\r\n")
|
||||
from := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
||||
events := ParseICal(body, from, from.AddDate(0, 0, 1))
|
||||
if len(events) != 1 {
|
||||
t.Fatalf("got %d events, want 1", len(events))
|
||||
}
|
||||
if want := "Еженедельная планёрка, потом созвон"; events[0].Summary != want {
|
||||
t.Errorf("Summary = %q, want %q", events[0].Summary, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A day is 23 hours wide where DST starts, so a wall clock reading has to be
|
||||
// built with time.Date and never as midnight plus a duration.
|
||||
func TestFactSpanAcrossDSTStart(t *testing.T) {
|
||||
loc, err := time.LoadLocation("Europe/Berlin")
|
||||
if err != nil {
|
||||
t.Skipf("no tzdata for Europe/Berlin: %v", err)
|
||||
}
|
||||
start, end, ok := FactSpan("calendar_event_20260329_Planerka", "Planerka @ 14:00-15:00", loc)
|
||||
if !ok {
|
||||
t.Fatal("FactSpan reported not ok")
|
||||
}
|
||||
if start.Hour() != 14 || start.Minute() != 0 {
|
||||
t.Errorf("start = %s, want a 14:00 wall clock", start)
|
||||
}
|
||||
if end.Hour() != 15 {
|
||||
t.Errorf("end = %s, want a 15:00 wall clock", end)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReminderEventEmptyPayload(t *testing.T) {
|
||||
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
||||
if e.Summary != "напоминание" {
|
||||
|
||||
+41
-26
@@ -161,6 +161,18 @@ func (s *Session) finish() error {
|
||||
return s.spool.Sync()
|
||||
}
|
||||
|
||||
// discard closes the spool and deletes it, leaving nothing behind. Used by the
|
||||
// reaper and by Abort, which throw a recording away rather than harvest it.
|
||||
func (s *Session) discard() {
|
||||
s.mu.Lock()
|
||||
_ = s.finish()
|
||||
path := s.path
|
||||
s.mu.Unlock()
|
||||
if path != "" {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
// Duration is how much audio has been collected, from the bytes rather than the
|
||||
// wall clock: a stream that dropped frames should report the audio that exists,
|
||||
// not the time that passed.
|
||||
@@ -174,9 +186,16 @@ func (s *Session) duration() time.Duration {
|
||||
return pcmDuration(s.format, s.n)
|
||||
}
|
||||
|
||||
// bytesPerSample is one sample across all channels. Cutting a buffer anywhere
|
||||
// that is not a multiple of it shifts every following sample by a byte.
|
||||
func bytesPerSample(f audio.Format) int64 { return int64(f.SampleBits / 8 * f.Channels) }
|
||||
|
||||
// bytesPerSecond is the format's byte rate, 32000 for the canonical 16 kHz mono.
|
||||
func bytesPerSecond(f audio.Format) int64 { return int64(f.SampleRate) * bytesPerSample(f) }
|
||||
|
||||
// pcmDuration is how long n bytes of PCM lasts in the given format.
|
||||
func pcmDuration(f audio.Format, n int64) time.Duration {
|
||||
per := int64(f.SampleRate) * int64(f.Channels) * int64(f.SampleBits) / 8
|
||||
per := bytesPerSecond(f)
|
||||
if per <= 0 {
|
||||
return 0
|
||||
}
|
||||
@@ -279,19 +298,21 @@ func (r *Recorder) Start(label string) (*Session, error) {
|
||||
f.Close()
|
||||
return nil, err
|
||||
}
|
||||
// A session that never opened leaves no spool file behind.
|
||||
abandon := func(err error) (*Session, error) {
|
||||
f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, err
|
||||
}
|
||||
// The header is written first and rewritten at Stop with the real length,
|
||||
// so the spool file is a playable WAV rather than headerless PCM that has
|
||||
// to be copied to gain 44 bytes.
|
||||
if _, err := f.Write(hdr); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, fmt.Errorf("capture: spool header: %w", err)
|
||||
return abandon(fmt.Errorf("capture: spool header: %w", err))
|
||||
}
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, err
|
||||
return abandon(err)
|
||||
}
|
||||
s := &Session{
|
||||
Label: strings.TrimSpace(label),
|
||||
@@ -330,15 +351,11 @@ func (r *Recorder) reapLocked() {
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.expired = true
|
||||
_ = s.finish()
|
||||
path := s.path
|
||||
s.mu.Unlock()
|
||||
if path != "" {
|
||||
// The audio goes with it. A recording nobody stopped is one nobody is
|
||||
// waiting for, and keeping it would mean storing a meeting on the
|
||||
// strength of a dropped connection.
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
// The audio goes with it. A recording nobody stopped is one nobody is
|
||||
// waiting for, and keeping it would mean storing a meeting on the strength
|
||||
// of a dropped connection.
|
||||
s.discard()
|
||||
r.current = nil
|
||||
}
|
||||
|
||||
@@ -536,13 +553,7 @@ func (r *Recorder) Abort(token string) bool {
|
||||
return false
|
||||
}
|
||||
r.current = nil
|
||||
s.mu.Lock()
|
||||
_ = s.finish()
|
||||
path := s.path
|
||||
s.mu.Unlock()
|
||||
if path != "" {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
s.discard()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -576,7 +587,7 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio
|
||||
}
|
||||
// Never cut mid-sample: a split inside an int16 shifts every following
|
||||
// sample by a byte and turns the tail of the window into noise.
|
||||
if bps := int64(format.SampleBits / 8 * format.Channels); bps > 0 {
|
||||
if bps := bytesPerSample(format); bps > 0 {
|
||||
size -= size % bps
|
||||
}
|
||||
if size <= 0 {
|
||||
@@ -608,12 +619,16 @@ func (r *Recorder) transcribeFile(ctx context.Context, path string, format audio
|
||||
// is read by him in a note next to the words around it.
|
||||
const gapMarker = "[…не разобрала…]"
|
||||
|
||||
// windowBytes is how many PCM bytes one STT window holds.
|
||||
// windowBytes is how many PCM bytes one STT window holds, rounded down to a
|
||||
// whole sample.
|
||||
func windowBytes(f audio.Format, window time.Duration) int64 {
|
||||
bps := int64(f.SampleBits / 8 * f.Channels)
|
||||
bps := bytesPerSample(f)
|
||||
if bps <= 0 || f.SampleRate <= 0 || window <= 0 {
|
||||
return 0
|
||||
}
|
||||
per := int64(window.Seconds()) * int64(f.SampleRate) * bps
|
||||
// Fractional seconds count. Truncating the window to whole seconds turned
|
||||
// any sub-second window into zero bytes, which the caller reads as "no
|
||||
// window" and answers by handing the transcriber the entire meeting at once.
|
||||
per := int64(window.Seconds() * float64(bytesPerSecond(f)))
|
||||
return per - per%bps
|
||||
}
|
||||
|
||||
@@ -193,9 +193,7 @@ func ChunkText(text string, maxRunes int) []string {
|
||||
// Oversized sentence: emit what is buffered, then cut this one on
|
||||
// word boundaries.
|
||||
flush()
|
||||
for _, piece := range splitWords(sr, maxRunes) {
|
||||
out = append(out, piece)
|
||||
}
|
||||
out = append(out, splitWords(sr, maxRunes)...)
|
||||
continue
|
||||
}
|
||||
if len(cur)+len(sr) > maxRunes {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// utterance (V-565, umbrella V-558, design in
|
||||
// docs/plans/19-dialogue-arbitration.md).
|
||||
//
|
||||
// Maven's cascade has roughly ten stage-0 grammars, seven router intents,
|
||||
// twenty-two query sources and four stateful pre-emptors, and every one of them
|
||||
// Maven's cascade has twenty-two stage-0 grammars, seven router intents,
|
||||
// twenty-two query sources and seven stateful pre-emptors, and every one of them
|
||||
// answers "is this mine?" alone. None can answer "is this more mine than
|
||||
// yours?", because their scores are not comparable: stage 0 asserts 1.0 by
|
||||
// fiat, the classifier reports a cosine, the LLM router derives one from
|
||||
@@ -56,8 +56,9 @@ const (
|
||||
// BandStructural — the claimant read the whole sentence and produced a
|
||||
// complete route, every slot its intent requires filled. The LLM router at
|
||||
// full confidence, and a stateful claimant holding a pending question.
|
||||
// Below BandAnchored on purpose: the four stateful claimants pre-empt
|
||||
// unconditionally today, and that is the V-558 defect.
|
||||
// Below BandAnchored on purpose: the stateful claimants pre-empt
|
||||
// unconditionally today, and that is the V-558 defect. There are seven of
|
||||
// them and preRouteLadder in cmd/mavend/decisiontrace.go is the roster.
|
||||
BandStructural
|
||||
|
||||
// BandAnchored — a literal pattern anchored in the utterance matched, and
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// Two loops that run behind the conversation and never speak. Both are absent
|
||||
// by default, and both follow the same rule as every other cost the owner did
|
||||
// not ask for: present-but-empty (`{}`) is a valid "on with the defaults".
|
||||
|
||||
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
|
||||
// Absent ⇒ off, like every other capability that costs something the owner did
|
||||
// not ask for. Each evaluation is a full LLM round-trip on the one resident
|
||||
// model, which is the same model answering him; running it hourly by default
|
||||
// would put a multi-second stall in front of an occasional voice turn for a
|
||||
// feature he may not want.
|
||||
//
|
||||
// The loop only ever writes notes (source infer:memory-eval, visible on
|
||||
// /dash). It cannot speak — see internal/memeval.
|
||||
type MemoryEvalConfig struct {
|
||||
// Interval — how often to evaluate. 0 ⇒ DefaultMemoryEvalInterval.
|
||||
Interval Duration `json:"interval,omitempty"`
|
||||
|
||||
// MaxItems — recent facts / notes / nudges fed into one evaluation.
|
||||
// 0 ⇒ memeval.DefaultMaxItems.
|
||||
MaxItems int `json:"max_items,omitempty"`
|
||||
|
||||
// MinConfidence — observations the model scores below this are dropped.
|
||||
// 0 ⇒ memeval.DefaultMinConfidence.
|
||||
MinConfidence float64 `json:"min_confidence,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultMemoryEvalInterval — the plan's cadence (1h) for the memory
|
||||
// evaluation loop, applied only when the block is present at all.
|
||||
const DefaultMemoryEvalInterval = time.Hour
|
||||
|
||||
// normaliseMemoryEval leaves an absent block nil (⇒ no evaluation loop) and
|
||||
// gives a present one the plan's cadence.
|
||||
func (c *Config) normaliseMemoryEval() {
|
||||
if c.MemoryEval != nil && c.MemoryEval.Interval <= 0 {
|
||||
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// EmailConfig — core's half of the email reader: how many task candidates one
|
||||
// message may produce, and how long the extraction call may take.
|
||||
//
|
||||
// There is deliberately nothing about a mailbox here. Core does not connect to
|
||||
// IMAP, does not know an account exists, and holds no mail credential — the
|
||||
// reader daemon does, the same split mavpoll uses for the zenmoney token. This
|
||||
// block only says "extraction is allowed, with these bounds".
|
||||
type EmailConfig struct {
|
||||
// MaxTasks — candidates per message. 0 ⇒ email.MaxCandidates (3).
|
||||
MaxTasks int `json:"max_tasks,omitempty"`
|
||||
|
||||
// Timeout — per-message extraction budget. 0 ⇒ DefaultEmailTimeout. This is
|
||||
// a Thinking model reading a mail; nobody is waiting on the answer, but a
|
||||
// hung llama-server must not pin the reader's connection forever.
|
||||
Timeout Duration `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultEmailTimeout — extraction budget per message.
|
||||
const DefaultEmailTimeout = 2 * time.Minute
|
||||
|
||||
// normaliseEmail leaves an absent block nil (⇒ mail ingestion refused) and
|
||||
// gives a present one the timeout default.
|
||||
func (c *Config) normaliseEmail() {
|
||||
if c.Email != nil && c.Email.Timeout <= 0 {
|
||||
c.Email.Timeout = Duration(DefaultEmailTimeout)
|
||||
}
|
||||
}
|
||||
+82
-1375
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user