Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4666057066 | |||
| 50c6637c1b | |||
| ee9d55ca95 | |||
| a886217223 | |||
| de9884e063 | |||
| bbefda66e2 | |||
| a37c4138a1 | |||
| d6f391430f | |||
| 68b2aa9137 | |||
| f8fa0d1b44 | |||
| 9a333b23d7 | |||
| 663b5c47b9 | |||
| 45c521e1a6 | |||
| e34669a52e | |||
| d434f83c2c | |||
| c310115fd2 | |||
| 3024f76e5f | |||
| 6bc71553ab | |||
| ed1730431c | |||
| 01e80fce4a | |||
| e69f1bd0cf | |||
| f55bedee2e | |||
| e470435cf1 | |||
| 00f9239ef9 | |||
| 3513e508b7 | |||
| c15c2b7bd2 | |||
| b6eaa704a2 | |||
| 2597a7b34a | |||
| 7203cd56fd | |||
| ab3e818bb9 | |||
| b5500a5be8 | |||
| 9095ac847d | |||
| 4b5f6adbae | |||
| ecb8ba72eb | |||
| 4fdecf9a25 | |||
| 2bbd8edbf6 | |||
| beb093aebb | |||
| b1b326018f | |||
| 08889cad88 | |||
| a4630b9314 | |||
| 39d44bb384 | |||
| 65ee0f9c61 | |||
| 76938e206d | |||
| 0b3d81ecbf | |||
| 4be6852b94 | |||
| f7b76c572f | |||
| 05ddc5c92e | |||
| b55e68f98d | |||
| beaa24754c | |||
| aed8cac439 | |||
| af4eeceb6a | |||
| 7b507dec94 | |||
| 2c0334c4fe | |||
| 92cbdbfdd3 | |||
| e78b2d8992 | |||
| 9d58922462 | |||
| b5ac48c126 | |||
| 69d0f5ee78 | |||
| 661b5c1099 | |||
| ff70637a0d | |||
| 06c1cf247e | |||
| 400653810e | |||
| b3936348f5 | |||
| c61b0b3968 | |||
| 0a5211b038 | |||
| 38be702188 | |||
| d42372e996 | |||
| 45231ba69e | |||
| 42c7b8b927 | |||
| e5a1db995d | |||
| d32eae8aac | |||
| 63b645b405 | |||
| 0e82cb442f | |||
| d94ed2e630 | |||
| c8f74c39d6 | |||
| 44b8793e2f | |||
| a4b4733767 | |||
| 8f168ab811 | |||
| eb129c2fad | |||
| 0d5bd0a9f0 | |||
| 4d97280d74 | |||
| e5ec4abe04 | |||
| 7688dfde66 | |||
| e1f84a3474 | |||
| 7852aad60f | |||
| 034d4b4359 | |||
| 799cf5587d | |||
| c1b781fac0 | |||
| 7b2b9d479a | |||
| 92de4ae496 | |||
| a0293bac85 | |||
| c1d9a4547b | |||
| 6499f6365e | |||
| 97e1a44c1a | |||
| 1b3af05d0a | |||
| e7ecce2859 | |||
| 1f8e9f21ce | |||
| e7537d032e | |||
| 2b3e34c7e8 | |||
| dde556a3d3 | |||
| b86172a98d | |||
| 22edc3cdfb |
@@ -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/`)
|
||||
|
||||
@@ -82,13 +93,37 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
||||
| `mavpoll` | Environment poller: netdata alarms, uptime-kuma, zenmoney, wireguard presence. Writes facts, sends nothing. Telegram is `internal/delivery/telegramsink`, not this. |
|
||||
| `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.
|
||||
|
||||
@@ -211,6 +246,72 @@ was a hardcode. **Fine-tune a copy of the weights.** The resident embedder backs
|
||||
recall. Training it in place couples routing accuracy to recall@1, with nothing in the
|
||||
suite to name the trade.
|
||||
|
||||
**Two of those heads are trained as of 08-08-2026, and they are not the three
|
||||
above** (V-661, `docs/evals/2026-08-08-routing-heads-two-head.md`). Intent and
|
||||
destination share one masked mean pool. Destination scores a mean **80.8%** over
|
||||
three seeds, best **29/33 (87.9%)**. The classifier cascade scores 12/33 and the
|
||||
cascade with gemma-4-12b scores 24/33, so a 118M encoder beats the 12B teacher it
|
||||
was distilled from. Read the best run as one seed and not a headline, because one
|
||||
case is 3 points on a fixture this small.
|
||||
Recall is 15/15 and world is 5/5. Intent is 93.6% mean over three seeds. That is
|
||||
**not** comparable to the 76.0% and 84.4% those two arms scored: a softmax has no
|
||||
clarify class, so the head's fixture is the 88 cases carrying an intent.
|
||||
|
||||
**A fourth head asks instead of guessing, same day** (V-661,
|
||||
`docs/evals/2026-08-08-clarify-head-four-head.md`). Clarify is not a value
|
||||
of intent, so a softmax cannot emit it. It is a second question over the
|
||||
same pooled vector: can Maven act on this at all. That is why the head's
|
||||
fixture was 88 cases and not 96. Over three seeds it catches **7.0 of the 8
|
||||
`want_clarify` cases and produces 2.3 false clarifies of 88**. The cascade
|
||||
today misses 1 and produces 2, so this is parity with no rules in front of
|
||||
it. Accuracy is the wrong number here and a head that never asks scores
|
||||
91.7%. Confidence is the other half. Max softmax over the intent head reads
|
||||
**0.851 where it is right against 0.604 where it is wrong**, ranking right
|
||||
above wrong in 83.4% of pairs. `Confidence: 1.0` was a hardcode, and this
|
||||
replaces it with a signal. The two are not the same signal: one says which
|
||||
intent is unclear, the other says the utterance carries too little to act
|
||||
on. **The fourth head is not free the way the third was.** Intent,
|
||||
destination and slot F1 each move down one to four points, inside the seed
|
||||
spread. `поужинал` is a false clarify on every seed, which is the same
|
||||
defect `thinSingleToken` was narrowed for on 2026-08-01.
|
||||
|
||||
The corpus for it is generated, because every existing row is answerable by
|
||||
construction. **The router-prompt agreement filter cannot work here**, since
|
||||
`routeGrammar` has no clarify value and a generated line always agrees with
|
||||
itself. A gemma judge replaces it. The first judge called 24 of 40
|
||||
answerable rows underspecified, because it judged against a generic
|
||||
assistant rather than against Maven's contract.
|
||||
|
||||
**Mood is cut, not deferred.** The enum describes her own reply state, not the
|
||||
speaker's emotion, and no dataset maps onto it.
|
||||
|
||||
**A third head landed the same day** (`docs/evals/2026-08-08-slot-head-three-head.md`).
|
||||
BIO slot tags had no Maven-domain corpus, which was true of found corpora and
|
||||
false of made ones. `label_slots.py` distils spans out of gemma-4-12b under a
|
||||
GBNF closed over Maven's own five slots. A span survives only when it is a
|
||||
literal substring of the utterance, so the agreement filter costs no second
|
||||
call. 2178 spans over 1702 rows, 37 dropped, nothing unparsed. Three heads score
|
||||
intent **92.8%**, destination **82.8%** and slot span F1 **72.4%** over three
|
||||
seeds. The slot head is free: both other numbers move less than their own seed
|
||||
spread. Epoch selection reads the intent dev slice alone. Slot F1 is still
|
||||
climbing when it stops, which costs about 4 points.
|
||||
|
||||
The MASSIVE warm-start of step 2 is worth nothing here. Stock e5-small ties it on
|
||||
intent and leads by a third of a case on destination. Nothing argues for keeping
|
||||
that step.
|
||||
|
||||
The floor was a corpus defect and it is fixed. The first 120 floor rows carried
|
||||
one sentence shape, so the head named a destination where the fixture says walk
|
||||
the chain. Rotating six shapes took the floor 3/7 to 6/7 and destination 75.8% to
|
||||
80.8%. What is left is calendar at 3/6 on every seed, which training cannot move:
|
||||
the possessive agenda rules claim those cases at stage 0 and name nothing, so no
|
||||
label reaches the head. That is the same trade V-660 flagged and it wants the
|
||||
owner's call.
|
||||
|
||||
**Nothing of this runs in Go.** The weights are `heads.pt` and `out/body_heads/`
|
||||
on workpc. Reaching the daemon needs an ONNX export and a caller. The resident
|
||||
e5-small must not be replaced by the copy, because recall depends on that file.
|
||||
|
||||
`Confidence: 1.0` used to be hardcoded in `llmrouter.go`, so the LLM
|
||||
path could never ask for clarification (6/6 refusal cases missed on the fixture) — Vikunja
|
||||
#359. Fixed 31-07-2026 with structural signal (single-token utterance, keyless fact, act with
|
||||
@@ -285,11 +386,154 @@ 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 on its own. His notes and his facts still run first, because
|
||||
they look rather than guess.
|
||||
|
||||
**The personal boundary is the one exception and it is deliberate.** It guesses,
|
||||
so naming `SourceWorld` drops it. That is what stops it answering "кто такой
|
||||
Линус Торвальдс?" with "не нашла у тебя такой записи", which it did on
|
||||
2026-08-07. The cost is that a destination a model wrote can now take the
|
||||
boundary off a turn. A question about him that the model calls `world` reaches
|
||||
SearXNG, where today the boundary stops it. Only the utterance leaves the box,
|
||||
never his notes or history, so this widens what is asked and not what is sent.
|
||||
`TestNamingRecallKeepsTheBoundary` pins the other half: naming `SourceRecall`
|
||||
keeps the boundary in front of the world. Whether a model may drop it at all is
|
||||
the owner's call and has not been made.
|
||||
|
||||
What comes out is only the sources that **guess**. Those decide a turn is theirs by
|
||||
cosine against frozen seeds, then answer whatever they claimed. They hold no table
|
||||
that could come back empty. Weather is the pure case and has no local data at
|
||||
all. It was measured on the box on 2026-08-07
|
||||
(`docs/evals/2026-08-07-week-of-usage.md` section 4). It answered both "что такое
|
||||
TCP?" and "сколько будет 17 на 23?" with "для какого города?". The feed answered "какой у меня любимый язык?" with kernel headlines.
|
||||
The personal boundary answered "кто такой Линус Торвальдс?" with "не нашла у тебя
|
||||
такой записи". A source that guesses is marked `guesses: true` in the table. One that
|
||||
looks is not, and it is always asked.
|
||||
|
||||
Stage 0 fills the destination where a rule already knows it. `WorldQueryGrammars()`
|
||||
(`internal/router/worldquery.go`) claims "что такое X" and "сколько будет 17 на 23".
|
||||
It is wired after the agenda rules and **before** the feed and list rules.
|
||||
"что такое лента" is a definition question, and the feed rule would take it on the
|
||||
noun alone.
|
||||
`calendar-query` and `event-time-query` name the calendar. The possessive agenda rules
|
||||
deliberately do not. "что у меня в списке покупок" matches `agenda-query`, and naming
|
||||
the calendar there would take the list source off the turn.
|
||||
|
||||
Fixture unchanged at **69/91 classifier+ONNX**, measured both sides. That is the
|
||||
expected result, because it scores intent and no case here changes intent.
|
||||
|
||||
**The destination has its own fixture and its own number as of 08-08-2026**
|
||||
(V-659, `docs/evals/2026-08-08-destination-fixture.md`). This section used to say
|
||||
it had neither. `want_source` on `eval.Case` is a pointer, because the destination
|
||||
has three states and a bare string has two. Absent is every intent but query,
|
||||
which never reaches `queryWalk`. Present and empty is the `SourceUnknown`
|
||||
contract: name nothing and walk the chain. Present and named is a destination the
|
||||
route must produce. Thirty-three of ninety-six cases carry one.
|
||||
|
||||
A destination miss does **not** fail the case. It lands in `Outcome.SourceReason`
|
||||
and never in `Reasons`, so `Accuracy` and `IntentAccuracy` mean what they meant
|
||||
and `SourceAccuracy` is a second number over the labelled cases only. Intent and
|
||||
destination are two decisions, and one number hides which one moved. A route that
|
||||
lost its intent scores no destination hit, or a clarify would satisfy an empty
|
||||
label for free.
|
||||
|
||||
Measured classifier+ONNX: intent **73/96 (76.0%)**, destination **12/33 (36.4%)**.
|
||||
The split is the finding. World is 5/5, because a stage 0 rule names it. The
|
||||
`SourceUnknown` floor is 5/7. Calendar is 2/6, because the possessive agenda
|
||||
rules deliberately do not name it. And **recall is 0/15, because nothing
|
||||
anywhere names it**. Those turns are still answered, since the chain walks
|
||||
recall early. Recall is the number the fourth head has to move.
|
||||
|
||||
Seven cases assert the floor and five of them are homelab operations. They
|
||||
cluster because `SourceRecall`, `SourceNetwork` and `SourceAttention` overlap on
|
||||
every question about the box. The other two are `ru-query-005` and
|
||||
`ru-query-014`. No query source reads the reminder store, and a deadline could
|
||||
sit in tasks, the calendar or Praxis. `mavpoll` writes its netdata and uptime-kuma
|
||||
observations into the fact store recall reads. That is a finding about the enum,
|
||||
not a gap in the labelling. The owner confirmed all seven floor labels on
|
||||
08-08-2026, so they are a decision rather than an agent's guess.
|
||||
|
||||
`baselineGrammars` in `eval_test.go` mirrors `buildRouter` and had drifted:
|
||||
`WorldQueryGrammars` was wired into the daemon by V-655 and not into the mirror,
|
||||
so the fixture scored a grammar set nobody runs. Fixed by V-659, worth 3 points of
|
||||
destination and nothing else. Check that function when adding a grammar.
|
||||
|
||||
**The model arm landed the same day** (V-660,
|
||||
`docs/evals/2026-08-08-destination-model-arm.md`). `routeGrammar` carries a
|
||||
`source` rule closed over `router.Sources` plus the empty floor, so the model
|
||||
cannot emit a destination that does not exist. The prompt lists the twelve in
|
||||
Russian and says `""` is a normal answer to give often. `LLMRouter.Route` reads it
|
||||
back through `ValidSource` and on `IntentQuery` alone. Against gemma-4-12b on the
|
||||
workstation the cascade scores destination **24/33 (72.7%)** with intent unmoved
|
||||
at 84.4%, and **recall goes 0/15 to 14/15**. The resident Qwen3-1.7B is
|
||||
unmeasured, because it binds `--port 0` inside the container.
|
||||
|
||||
**Stage 0 now costs four destination points.** It did not before. The four cases
|
||||
the cascade loses and the model alone wins are all calendar. The possessive
|
||||
agenda rules claim them first and name nothing on purpose. That caution was free
|
||||
while nothing downstream could name anything either. It is not free now, and the
|
||||
fix is the owner's call rather than a quiet edit.
|
||||
|
||||
The last arm is V-546. Intent, mood and BIO slot tags were already three heads on
|
||||
one forward pass of the resident e5-small. Destination is a fourth head on the
|
||||
same pass, and 72.7% from a 12B teacher is the label source for training it.
|
||||
|
||||
## LLM output contract
|
||||
|
||||
All phrasing paths emit `{"response":"...","mood":"..."}`, with fallback to plain text when
|
||||
@@ -404,8 +648,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))
|
||||
}
|
||||
+35
-8
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+85
-23
@@ -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},
|
||||
{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: "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},
|
||||
{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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+53
-23
@@ -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,23 +178,17 @@ 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.
|
||||
@@ -381,6 +375,14 @@ 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
|
||||
@@ -467,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 {
|
||||
@@ -476,11 +486,22 @@ 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 — letting the question about %s go: %d asides in a row, %d rides in all", q.Missing[0], q.Suspends, q.Rides)
|
||||
return
|
||||
}
|
||||
q.Suspends++
|
||||
// Rides is the same event counted without the reset (V-663). Incremented
|
||||
// beside Suspends and never anywhere else, so the two cannot disagree about
|
||||
// what happened, only about how much of it they remember.
|
||||
q.Rides++
|
||||
q.Asked = h.now()
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), q)
|
||||
rt.resume = question
|
||||
rt.suspended = true
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply", q.Missing[0])
|
||||
log.Printf("voice: clarify — is its own request; suspending the question about %s and resuming it in the same reply (suspend %d of %d, ride %d of %d)", q.Missing[0], q.Suspends, dialogue.MaxSuspends, q.Rides, dialogue.MaxRides)
|
||||
}
|
||||
|
||||
// foldAnswerIntoUtterance appends an answered subject to the original words,
|
||||
@@ -520,6 +541,14 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
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.
|
||||
//
|
||||
// Rides IS carried, and that is the whole point of it (V-663). This is the
|
||||
// same request under a second question, not a new one, so the turns it has
|
||||
// already ridden still count against it. Dropping the field here is exactly
|
||||
// the re-basing that let one question ride twenty-six replies.
|
||||
h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{
|
||||
Intent: q.Intent,
|
||||
Slots: merged,
|
||||
@@ -530,6 +559,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi
|
||||
TTL: clarifyTTL,
|
||||
Attempts: q.Attempts + 1,
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
Rides: q.Rides,
|
||||
})
|
||||
log.Printf("voice: clarify — one gap filled, still missing %s for intent=%s, asking again (attempt %d)", remaining[0], intent, q.Attempts+1)
|
||||
return question, true
|
||||
@@ -571,7 +601,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.
|
||||
|
||||
@@ -740,3 +740,53 @@ func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheResumedQuestionIsItsOwnSentence — V-654. The re-ask used to be spliced
|
||||
// onto the answer with a comma, so a real answer and an unrelated open question
|
||||
// read as one run-on thought and the question vanished into its tail.
|
||||
func TestTheResumedQuestionIsItsOwnSentence(t *testing.T) {
|
||||
const resumed = "На какое время поставить напоминание?"
|
||||
cases := []struct {
|
||||
name string
|
||||
reply string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
// The measured line, shortened. Two sentences, and the question keeps
|
||||
// its capital.
|
||||
name: "a statement keeps its full stop",
|
||||
reply: "Вайфай пароль лежит в ящике стола.",
|
||||
want: "Вайфай пароль лежит в ящике стола. " + resumed,
|
||||
},
|
||||
{
|
||||
name: "a reply with no terminator is given one",
|
||||
reply: "Вайфай пароль лежит в ящике стола",
|
||||
want: "Вайфай пароль лежит в ящике стола. " + resumed,
|
||||
},
|
||||
{
|
||||
// She sometimes answers a side query by asking him to say it again.
|
||||
// Two questions, and neither may swallow the other.
|
||||
name: "a question keeps its mark",
|
||||
reply: "Можешь переформулировать?",
|
||||
want: "Можешь переформулировать? " + resumed,
|
||||
},
|
||||
{
|
||||
name: "an ellipsis is already an ending",
|
||||
reply: "Не уверена…",
|
||||
want: "Не уверена… " + resumed,
|
||||
},
|
||||
{
|
||||
name: "a resume with no answer in front of it is just the question",
|
||||
reply: "",
|
||||
want: resumed,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := withResumed(tc.reply, resumed); got != tc.want {
|
||||
t.Errorf("%s: withResumed(%q) = %q, want %q", tc.name, tc.reply, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := withResumed("Готово.", ""); got != "Готово." {
|
||||
t.Errorf("nothing to resume must leave the reply alone, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ import (
|
||||
// and nothing should: a missing name costs one line of the record, while a
|
||||
// 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
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
@@ -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,
|
||||
@@ -39,14 +39,14 @@ func (r *llmReplier) Reply(d router.Decision) string {
|
||||
// что ты выпел стакан воды" for "я выпил воды".
|
||||
return phraser.FactAck(d.Utterance)
|
||||
}
|
||||
out, err := r.p.PhraseReply(context.Background(), d)
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// mavend/telegramintake.go — wiring the inbound telegram poller (V-637).
|
||||
//
|
||||
// The poller reaches the daemon through ipc.CoreAPI and nothing else, so a
|
||||
// telegram turn takes exactly the path the web's POST /api/chat takes: Chat
|
||||
// returns the reply and the persisted trace id, and CorrectTurn writes the
|
||||
// label. Nothing in internal/delivery knows what a handler is.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
)
|
||||
|
||||
// wireTelegramIntake starts the poller, or returns having done nothing. It is
|
||||
// nil-safe in every argument, because it is called from both boot paths — the
|
||||
// unlocked start and the passkey unlock — and telegram must behave the same on
|
||||
// either.
|
||||
//
|
||||
// A sink that will not build is logged rather than fatal here. The push half
|
||||
// already failed the boot in wireDispatcher for the same config, so a second
|
||||
// hard failure would only lose that message.
|
||||
func wireTelegramIntake(ctx context.Context, wg *sync.WaitGroup, api ipc.CoreAPI, cfg *config.Config) {
|
||||
if cfg == nil || cfg.Telegram == nil || !cfg.Telegram.Intake || api == nil {
|
||||
return
|
||||
}
|
||||
sink, err := telegramsink.New(*cfg.Telegram)
|
||||
if err != nil {
|
||||
log.Printf("telegram intake: %v", err)
|
||||
return
|
||||
}
|
||||
poller, err := telegramsink.NewPoller(sink, chatTurnFn(api), api.CorrectTurn)
|
||||
if err != nil {
|
||||
log.Printf("telegram intake: %v", err)
|
||||
return
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
poller.Run(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// chatTurnFn adapts ipc.Chat to the poller's Turn. The trace id comes back on
|
||||
// the reply because the daemon's Chat collects it off the context (V-630), so
|
||||
// the chat can offer the same correction the web does without a second op.
|
||||
func chatTurnFn(api ipc.CoreAPI) telegramsink.Turn {
|
||||
return func(ctx context.Context, conversation, text string) (string, int64, error) {
|
||||
reply, err := api.Chat(ctx, conversation, text)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return reply.Reply, reply.TraceID, nil
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -97,10 +97,19 @@ func (d *daemonAPI) Chat(ctx context.Context, conversation, text string) (ipc.Ch
|
||||
return ipc.ChatReply{}, errors.New("mavend: chat not available")
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -186,6 +186,28 @@ func carriesReminderVerb(text string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isPleasantry matches the WHOLE utterance against lexicon.Pleasantries, after
|
||||
// lowercasing and dropping the punctuation a greeting carries.
|
||||
//
|
||||
// Whole utterance and not tokens. Every token rule tried here was wrong on
|
||||
// something: "вечер" answers "это утра или вечера?", "нет" answers a confirm,
|
||||
// and "спокойной" alone is not an utterance at all. A greeting is a fixed
|
||||
// phrase, so matching it as one costs nothing and claims nothing else.
|
||||
func isPleasantry(text string) bool {
|
||||
t := strings.ToLower(strings.TrimSpace(text))
|
||||
t = strings.Trim(t, " .,!?…")
|
||||
t = strings.Join(strings.Fields(t), " ")
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
for _, p := range lexicon.Pleasantries() {
|
||||
if t == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// offlineOwnRequest is the shape half of the evidence: the offline token tests,
|
||||
// which cost nothing and never depend on the model that produced the routing.
|
||||
// It is also the whole answer when there is no route to read — the classifier
|
||||
@@ -225,6 +247,16 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.
|
||||
// hour, and no route saying "question" changes that. It works because the
|
||||
// extractor no longer reads a day word as the current clock, so a sentence
|
||||
// that names no hour now fills nothing to weigh.
|
||||
// A pleasantry is neither (V-663). "спасибо" and "привет" fell through to
|
||||
// roleAnswer, so a question about a reminder's DAY was re-asked at a man
|
||||
// saying thank you, and the retry it spent was one of the three bounds
|
||||
// meant to end the ride. It is an aside: answered as itself, the question
|
||||
// resumed on the tail, no attempt spent, one ride counted. Placed above the
|
||||
// content gate because "доброе утро" has content and states nothing, so
|
||||
// neither half of the evidence below can reach it.
|
||||
if q != nil && isPleasantry(text) {
|
||||
return roleAside
|
||||
}
|
||||
own := false
|
||||
if len(ownContent(text)) > 0 {
|
||||
own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text))
|
||||
|
||||
@@ -279,3 +279,162 @@ func TestTheTurnIsRoutedOnce(t *testing.T) {
|
||||
t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first)
|
||||
}
|
||||
}
|
||||
|
||||
// TestASuspendedQuestionDoesNotRideForever — V-654, the measured failure of
|
||||
// 2026-08-07 (docs/evals/2026-08-07-week-of-usage-transcript.md, t=51 to t=58).
|
||||
//
|
||||
// A side query suspends the parked question, spends no attempt and restarts the
|
||||
// TTL. Nothing else bounded it, so one unfilled time slot came back on the end
|
||||
// of six consecutive unrelated replies and stopped only when a seventh turn
|
||||
// happened to read as a failed answer. Three step-asides, then she lets it go
|
||||
// and says so.
|
||||
func TestASuspendedQuestionDoesNotRideForever(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, st := newRoutingClarifyHandler(t)
|
||||
id := dialogueIDFor(sourceText, "web")
|
||||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||
|
||||
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||||
t.Fatalf("expected the time question, got %q", reply)
|
||||
}
|
||||
|
||||
// Three questions of his own. Each one is answered as itself and each one
|
||||
// brings the open question back, exactly as V-561 asks.
|
||||
asides := []string{
|
||||
"о чём мы вчера говорили?",
|
||||
"какие у меня напоминания?",
|
||||
"сколько времени?",
|
||||
}
|
||||
for i, text := range asides {
|
||||
reply := h.handleText(ctx, "web", text)
|
||||
if !strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("side query %d: the question must come back, got %q", i+1, reply)
|
||||
}
|
||||
if strings.Contains(reply, clarifyDropped) {
|
||||
t.Fatalf("side query %d: nothing was let go yet, so nothing may say so: %q", i+1, reply)
|
||||
}
|
||||
q := h.clarifyStore.Get(id, h.now())
|
||||
if q == nil {
|
||||
t.Fatalf("side query %d: the question was dropped early", i+1)
|
||||
}
|
||||
if q.Attempts != 1 {
|
||||
t.Fatalf("side query %d: a step-aside spent an attempt: %d", i+1, q.Attempts)
|
||||
}
|
||||
if q.Suspends != i+1 {
|
||||
t.Fatalf("side query %d: suspends = %d, want %d", i+1, q.Suspends, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
// The fourth. She has stepped aside as often as she is willing to, so the
|
||||
// request goes — out loud, and without the question on the tail.
|
||||
reply := h.handleText(ctx, "web", "что у меня сегодня?")
|
||||
if !strings.Contains(reply, clarifyDropped) {
|
||||
t.Fatalf("the request was let go in silence: %q", reply)
|
||||
}
|
||||
if strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("a question she has let go must not be asked again: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(id, h.now()) != nil {
|
||||
t.Fatal("the question must be gone once she has said she let it go")
|
||||
}
|
||||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||||
t.Fatalf("a reminder was invented for a time nobody gave: %v err=%v", reminders, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnAnsweredGapResetsTheSuspendBudget — the counter measures CONSECUTIVE
|
||||
// step-asides. He filled a gap, so the run is broken and the next question
|
||||
// starts with its full allowance: a long exchange he is engaged with must not
|
||||
// run out of patience on his behalf.
|
||||
func TestAnAnsweredGapResetsTheSuspendBudget(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
id := dialogueIDFor(sourceText, "web")
|
||||
|
||||
// A bare "напомни" is missing both halves, so answering the subject re-parks
|
||||
// the request with a question about the time.
|
||||
if reply := h.handleText(ctx, "web", "напомни"); !strings.Contains(reply, "?") {
|
||||
t.Fatalf("expected a question, got %q", reply)
|
||||
}
|
||||
if reply := h.handleText(ctx, "web", "какие у меня напоминания?"); reply == "" {
|
||||
t.Fatal("the side query must be answered as itself")
|
||||
}
|
||||
if q := h.clarifyStore.Get(id, h.now()); q == nil || q.Suspends != 1 {
|
||||
t.Fatalf("the side query was not counted: %+v", q)
|
||||
}
|
||||
if reply := h.handleText(ctx, "web", "позвонить маме"); reply == "" {
|
||||
t.Fatal("the answer must be consumed")
|
||||
}
|
||||
q := h.clarifyStore.Get(id, h.now())
|
||||
if q == nil {
|
||||
t.Fatal("a reminder still needs its time, so a question must be parked")
|
||||
}
|
||||
if q.Suspends != 0 {
|
||||
t.Fatalf("answering a gap must reset the suspend budget: suspends = %d", q.Suspends)
|
||||
}
|
||||
// The ride it already took is carried across the re-park (V-663). Resetting
|
||||
// both counters here is what let one question ride twenty-six replies.
|
||||
if q.Rides != 1 {
|
||||
t.Fatalf("the aside it already took was forgotten: rides = %d", q.Rides)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTwoBoundsCannotRearmEachOther — V-663.
|
||||
//
|
||||
// MaxSuspends landed and the measurement did not move: twenty-six of 140 turns
|
||||
// carried a tail before it and twenty-six after. This is the shape it misses,
|
||||
// taken from the 2026-08-08 run, where one question rode turns 7 to 13.
|
||||
//
|
||||
// An aside spends no attempt, so MaxAttempts never reaches it. A turn that
|
||||
// reads as a failed answer zeroes Suspends, so MaxSuspends never reaches the
|
||||
// asides either. Alternating the two rearms each bound with the other's
|
||||
// traffic. Rides counts both kinds and is never reset, so it is what ends this.
|
||||
func TestTwoBoundsCannotRearmEachOther(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, _ := newRoutingClarifyHandler(t)
|
||||
id := dialogueIDFor(sourceText, "web")
|
||||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||||
|
||||
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||||
t.Fatalf("expected the time question, got %q", reply)
|
||||
}
|
||||
|
||||
// Two asides. Each one rides and neither spends an attempt.
|
||||
for i := 0; i < 2; i++ {
|
||||
reply := h.handleText(ctx, "web", "какие у меня напоминания?")
|
||||
if !strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("aside %d: the question must come back, got %q", i+1, reply)
|
||||
}
|
||||
}
|
||||
q := h.clarifyStore.Get(id, h.now())
|
||||
if q == nil || q.Rides != 2 || q.Suspends != 2 {
|
||||
t.Fatalf("after two asides: %+v", q)
|
||||
}
|
||||
|
||||
// A pleasantry. It used to read as a failed answer, so she re-asked the
|
||||
// question at a man saying thank you and spent an attempt doing it. Now it
|
||||
// is an aside: answered as itself, question on the tail, one more ride.
|
||||
reply := h.handleText(ctx, "web", "спасибо")
|
||||
if !strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("a pleasantry lost the parked question: %q", reply)
|
||||
}
|
||||
q = h.clarifyStore.Get(id, h.now())
|
||||
if q == nil || q.Attempts != 1 {
|
||||
t.Fatalf("a pleasantry spent an attempt: %+v", q)
|
||||
}
|
||||
if q.Rides != 3 {
|
||||
t.Fatalf("a pleasantry rode free: %+v", q)
|
||||
}
|
||||
|
||||
// One more ride of any kind and the request goes, out loud.
|
||||
reply = h.handleText(ctx, "web", "какие у меня напоминания?")
|
||||
if !strings.Contains(reply, clarifyDropped) {
|
||||
t.Fatalf("the question rode four asides and was let go in silence: %q", reply)
|
||||
}
|
||||
if strings.HasSuffix(reply, resumed) {
|
||||
t.Fatalf("a question she has let go must not be asked again: %q", reply)
|
||||
}
|
||||
if h.clarifyStore.Get(id, h.now()) != nil {
|
||||
t.Fatal("the question must be gone once she has said she let it go")
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -145,6 +145,17 @@ type reactiveHandler struct {
|
||||
// is recorded, which is what a test that did not ask for one gets.
|
||||
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)
|
||||
}
|
||||
|
||||
+29
-2
@@ -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
|
||||
@@ -298,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,
|
||||
@@ -392,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).
|
||||
@@ -515,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
|
||||
|
||||
+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")
|
||||
}
|
||||
}
|
||||
+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)
|
||||
})
|
||||
|
||||
@@ -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); }
|
||||
|
||||
+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:
|
||||
|
||||
+57
-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,62 @@ Three reasons, in the order they settle it:
|
||||
So the notice stays what it is: the in-process TTL case, where she really did
|
||||
wait and really did let go.
|
||||
|
||||
#### A parked question may step aside three times
|
||||
|
||||
Decided 2026-08-07 (V-654). A side query or an aside suspends the parked
|
||||
question instead of dropping it. The words are answered as themselves, and the
|
||||
question comes back on the end of the same reply.
|
||||
|
||||
Neither bound on a question reaches that path. No attempt is spent, because a
|
||||
side query is not a failed answer, so `MaxAttempts` never applies.
|
||||
`noteSuspended` also restarts the 90s clock, since she is about to speak the
|
||||
question again. So the TTL cannot arrive while he keeps talking.
|
||||
|
||||
Measured on 2026-08-07: one unfilled time slot rode the tail of six consecutive
|
||||
unrelated replies. It stopped only when a seventh turn happened to read as a
|
||||
failed answer. See `docs/evals/2026-08-07-week-of-usage.md`.
|
||||
|
||||
`PendingQuestion.Suspends` counts the step-asides. `MaxSuspends` is 3, matching
|
||||
`DefaultMaxAttempts`. Past it she lets the request go, with the same
|
||||
`clarifyDropped` line every other drop uses. The owner's rule is unchanged. A
|
||||
question still ends by being answered or by being let go out loud. This only
|
||||
recognises three unrelated requests in a row as the second of those.
|
||||
|
||||
The count is of CONSECUTIVE step-asides. It resets the moment he answers, in
|
||||
`resolveClarifyAnswer`. An answer that gives her nothing she asked for resets it
|
||||
too. "Позвонить маме" against a question about the time is still him in the
|
||||
exchange. The retry it costs is bound enough on its own.
|
||||
|
||||
#### And it may ride four turns in all
|
||||
|
||||
Decided 2026-08-08 (V-663), because the bound above did not move the number it
|
||||
was written for. Twenty-six of 140 turns carried a tail before it landed and
|
||||
twenty-six carried one after.
|
||||
|
||||
Two bounds rearm each other. An aside spends no attempt, so `MaxAttempts` never
|
||||
reaches it. A turn that reads as a failed answer zeroes `Suspends`, so
|
||||
`MaxSuspends` never reaches the asides. Alternating them, each bound is restored
|
||||
by the other's traffic. Measured on 2026-08-08: one question about a reminder's
|
||||
day rode turns 7 to 13. It ended only because turn 14 was a new request.
|
||||
|
||||
`PendingQuestion.Rides` counts the same event as `Suspends` with the resets
|
||||
taken out. It is set once, incremented only in `noteSuspended`, carried across
|
||||
the re-park in `askRemainingGap`, and read by nothing that could lower it.
|
||||
`MaxRides` is 4, one looser than `MaxSuspends` so that the tighter statement
|
||||
about a run stays reachable.
|
||||
|
||||
This is a bound, not a cure. It ends the measured ride one turn early. Most of
|
||||
that ride's length is attempts, spent because `classifyTurnRole` reads "спасибо"
|
||||
and "привет" as failed answers to a question about a day. That is the next
|
||||
thing to fix and it is not a bound.
|
||||
|
||||
The re-ask is also two sentences rather than one. It used to be spliced onto the
|
||||
answer with a comma. On a real answer that buries the question in the tail of
|
||||
one run-on thought:
|
||||
|
||||
> вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить
|
||||
> напоминание?
|
||||
|
||||
### save-where — the two-memory routing axis
|
||||
|
||||
One discriminator: **does the loop evaluate a predicate against it?**
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Alarm verbs reach stage 0
|
||||
|
||||
**06-08-2026. V-627.** Measured with `TestONNXBaseline`, 91-case RU routing fixture,
|
||||
classifier plus the ONNX embedder. No LLM arm in this run.
|
||||
|
||||
## What was wrong
|
||||
|
||||
`lexicon.ReminderVerbs` held five words and none of them named an alarm. `ReminderGrammar`
|
||||
in `internal/router/stage0.go` did not read the set at all: it carried the literal
|
||||
`напомни|remind me`. So no part of the cascade recognised `разбуди`.
|
||||
|
||||
Three fixture cases ride on that. Under the classifier they went to fact and act at high
|
||||
confidence, so the failure was never a near miss:
|
||||
|
||||
- `ru-rem-005` "разбуди меня в 6:30" to fact at 0.918
|
||||
- `ru-rem-009` "разбуди меня полвосьмого" to act at 0.941
|
||||
- `en-rem-002` "wake me at 6:15" to fact at 0.899
|
||||
|
||||
Found while training the V-546 intent head, where the same three cases went to system. The
|
||||
head reads a spoken time with no known verb in front of it as a clock question. The
|
||||
classifier was making the same mistake in its own way.
|
||||
|
||||
## The change
|
||||
|
||||
Four alarm imperatives and bare `wake` join `reminder_verbs`. `ReminderGrammar` builds its
|
||||
alternation from the set, longest alternative first, and eats an optional `мне`, `меня` or
|
||||
`me` before the body.
|
||||
|
||||
Longest-first is load-bearing. Go's alternation is leftmost-first rather than longest-match,
|
||||
so `напомнить` listed after `напомни` would never match.
|
||||
|
||||
## Result
|
||||
|
||||
**66/91 to 69/91, 72.5% to 75.8% full.** Three cases gained, none lost.
|
||||
|
||||
All three are the alarms above, and each now carries its time slot, which it did not before.
|
||||
Clarify counts unchanged at 0 false and 8 missed. The two remaining system failures,
|
||||
`какое число завтра` and `какой день недели послезавтра`, failed at baseline too.
|
||||
|
||||
## What this does not fix
|
||||
|
||||
The lexicon addition on its own moved nothing. Measured before touching the grammar:
|
||||
**66/91**, exactly the baseline. Every consumer of `reminder_verbs` reads it after a reminder
|
||||
route already exists. A verb that cannot win the route is a verb nobody asks about. The
|
||||
grammar was the whole change.
|
||||
|
||||
Lemma matching in `isReminderVerb` now covers `разбудил` as well as `разбуди`, because one
|
||||
lemma holds both. That is the trap `cmd/mavend/quiet_toggle.go` documents for `говори`. It
|
||||
is tolerable here and not in the quiet toggle. `isReminderVerb` runs only on an utterance
|
||||
already routed to reminder, and it decides where the subject starts. A quiet match flips a
|
||||
daemon-wide setting from any channel.
|
||||
@@ -0,0 +1,247 @@
|
||||
# The fact parser: closed classes against the substring stems they replaced
|
||||
|
||||
Measured 2026-08-06 at 22edc3c and its parent 0445693, on the corpus in
|
||||
`internal/router/factparser_corpus_test.go`. Dated file: it is not edited after
|
||||
today, and a newer number is a new file.
|
||||
|
||||
V-586 rewrote `DefaultFactParser` off hand-written Russian stems onto six closed
|
||||
classes in `internal/lexicon`. Its commit message reported 64/91 on the RU
|
||||
routing fixture, unchanged. That number does not bear on the change: the fixture
|
||||
holds three fact cases and all three miss on intent, so the parser is never
|
||||
reached. This file measures the parser directly, and runs the LLM arm the
|
||||
original commit skipped.
|
||||
|
||||
**The rewrite is better on the utterances it was designed for and no worse on
|
||||
the ones it was not.** True positives go 35/40 to 39/40, misfires rejected go
|
||||
8/15 to 14/15. What neither version has is coverage: of 36 plausible utterances
|
||||
whose word is in no lexicon set, the substring parser caught 3 by accident and
|
||||
the closed-class parser catches 0. That is the honest headline. The word list
|
||||
did not shrink the vocabulary — it never had one — it made the boundary visible.
|
||||
|
||||
## The corpus
|
||||
|
||||
91 cases, three classes. **True positives** are sentences the owner would say,
|
||||
with the key that must be written. **Misfires** are sentences the substring
|
||||
parser wrote a fact for and should not have, including the three hard negatives
|
||||
the rewrite was argued on. **False negatives** are sentences a reasonable person
|
||||
would say whose word is in no set at all; `want` is the key a human would
|
||||
assign, and the ship parser is expected to miss them. They are the measurement
|
||||
of what a closed class costs, not a bug list.
|
||||
|
||||
The old parser is carried in the test file as `legacyFactParse`, copied verbatim
|
||||
from 0445693, so the comparison reruns:
|
||||
|
||||
```sh
|
||||
deps/go/go/bin/go test -run TestFactParserCorpus -v ./internal/router/
|
||||
```
|
||||
|
||||
## Score
|
||||
|
||||
| | true positives | misfires rejected | false-negative cases recovered |
|
||||
|---|---|---|---|
|
||||
| old (substring stems, 0445693) | 35/40 | 8/15 | 3/36 |
|
||||
| **new (closed classes, 22edc3c)** | **39/40** | **14/15** | **0/36** |
|
||||
|
||||
Sixteen cases disagree. Eleven of them the rewrite wins, three it loses, and two
|
||||
are cases neither gets.
|
||||
|
||||
**Won.** Every misfire the commit message named — `душа болит`, `в комнате
|
||||
душно`, `это была беда`, `наша победа`, `на душе легко` — plus `водитель пилота
|
||||
ждёт`, where two stems in one sentence made the old water arm fire. And five true
|
||||
positives the stems simply did not list: `перекусил`, `передохнул`, `отдыхаю`,
|
||||
`пойду спать`, `i showered`. Morphology buys those; a stem list would need a new
|
||||
entry for each.
|
||||
|
||||
**Lost.** `допил воду` is a real regression and the only failing true positive.
|
||||
The vendored dictionary lemmatises `допил` to `допилить`, to finish sawing —
|
||||
exactly the collision `drink_verbs` already carries `пил` and `пили` as surface
|
||||
forms to dodge, left unhandled for the prefixed form. `допить` is in the set and
|
||||
the sentence still misses. It is flagged `broken` in the corpus rather than
|
||||
fixed, because this branch measures.
|
||||
|
||||
`был в душе` and `после душа полегчало` are the price of matching the shower set
|
||||
exactly. The dictionary makes `душ` and `душа` one word, so a lemma test cannot
|
||||
tell a shower from a soul; exact matching keeps `на душе легко` out and loses
|
||||
the oblique cases of the real noun with it. The old parser got both by accident,
|
||||
along with the soul. That trade is right — writing a shower fact when he said
|
||||
his soul feels light is worse than missing one — but it is a trade and the two
|
||||
rows are what it costs.
|
||||
|
||||
`недоспал` is the third loss and the least defensible: the old substring `спал`
|
||||
caught it, and `недоспать` is in no set.
|
||||
|
||||
**Neither.** `обеденный перерыв отменили` — a cancelled lunch break — is a fact
|
||||
for both parsers, `meal` for the old one off the adjective and `break` for the
|
||||
new one off `перерыв`. Nothing in either design reads the cancellation.
|
||||
`дрых до обеда` is scored `meal` by both, because the meal arm runs first and
|
||||
`обеда` is in it, which is not wrong so much as beside the point.
|
||||
|
||||
## The false-negative surface
|
||||
|
||||
This is the half the routing fixture cannot see and the half that decides
|
||||
whether the design holds. 36 cases, 0 recovered:
|
||||
|
||||
- **water** — `выпил чаю`, `глотнул воды`, `хлебнул воды`, `выпил стакан`,
|
||||
`i hydrated`, `finished my bottle of water`. The water arm needs a noun AND a
|
||||
verb, so an elided noun or an unlisted verb drops the whole capture.
|
||||
- **meal** — `ем суп`, `съел бутерброд`, `наелся`, `пожрал`, `полдник был`,
|
||||
`snack`, `supper`, `brunch`, `i eat now`. `есть` is deliberately absent for
|
||||
`есть новости по бэкапу`, and `ем`, its most ordinary spoken form, goes with it.
|
||||
- **shower** — `помылся`, `сходил в ванную`, `искупался`, `i am showering`,
|
||||
plus the two oblique cases above.
|
||||
- **break** — `сделал передышку`, `перекур`, `полежал немного`, `сделал паузу`,
|
||||
`i took five`, `resting now`.
|
||||
- **sleep** — `вздремнул`, `прикорнул`, `дрых`, `недоспал`, `лёг в двенадцать`,
|
||||
`сон был короткий`, `i napped`, `took a nap`.
|
||||
|
||||
None of these are exotic. They are the second and third word a person reaches
|
||||
for, and every one of them is a fact the owner stated and Maven silently did not
|
||||
record. A silent miss is the worst failure mode this parser has: he said it, she
|
||||
heard it, nothing was written, and nothing told him.
|
||||
|
||||
## The routing fixture, LLM arm
|
||||
|
||||
The arm 22edc3c skipped. `MAVEN_LLM_URL` points the harness at any llama-server;
|
||||
the previous run reported none reachable, which was the shell's `HTTP_PROXY` and
|
||||
not the network. Run against **gemma-4-12B-it-qat-UD-Q4_K_XL on the workstation
|
||||
at `192.168.1.105:8080`**, the same box as the 02-08 measurement, with
|
||||
`NO_PROXY=192.168.1.105`:
|
||||
|
||||
```sh
|
||||
NO_PROXY=192.168.1.105 no_proxy=192.168.1.105 \
|
||||
make eval-models MAVEN_LLM_URL=http://192.168.1.105:8080
|
||||
```
|
||||
|
||||
| | full | intent-only | p50 |
|
||||
|---|---|---|---|
|
||||
| llm-only, 0445693 | 51.6% (47/91) | 82.4% | — |
|
||||
| llm-only, 22edc3c | 52.7% (48/91) | 83.5% | 341ms |
|
||||
| cascade+llm, 0445693 | 85.7% (78/91) | 93.4% | — |
|
||||
| **cascade+llm, 22edc3c** | **86.8% (79/91)** | **94.5%** | 334ms |
|
||||
|
||||
One case either way, both directions, and the failing set is identical between
|
||||
the two commits. That is run-to-run variance on a sampling model, not a signal.
|
||||
The parser change is invisible to the routing fixture on the LLM arm for the
|
||||
same reason it is invisible on the classifier arm: the three fact cases miss on
|
||||
intent and the parser is never called. Do not read these rows as evidence about
|
||||
the parser. They are evidence that the fixture cannot answer the question, which
|
||||
is why the corpus above exists.
|
||||
|
||||
## Verdict
|
||||
|
||||
The closed-class rewrite holds up as a rewrite. It is strictly better than what
|
||||
it replaced on both classes anyone argued about, and the one regression
|
||||
(`допил`) and one bad trade (the oblique `душ`) are both dictionary collisions
|
||||
rather than design faults.
|
||||
|
||||
It does not hold up as an answer. A closed class is the right mechanism for a
|
||||
set that is actually closed — interrogatives, weekdays, cardinals — and
|
||||
"the words a person uses to say he ate" is not that set. The corpus puts a
|
||||
number on it: 36 ordinary sentences, 0 recovered, and every new one costs a
|
||||
lexicon edit by whoever notices. The three mechanisms CLAUDE.md names do not
|
||||
contain the right one for this job. The embedder-topic mechanism is the closest
|
||||
fit and is wrong too, because this is slot extraction rather than aboutness.
|
||||
|
||||
This is a case for the V-546 slot-tagging head. Self-care facts are a bounded
|
||||
key space (five keys) over unbounded surface forms, which is exactly what a BIO
|
||||
tagger on e5-small is for: it generalises to `вздремнул` without anyone adding
|
||||
`вздремнуть` to a list, and max softmax gives the confidence the parser's
|
||||
hardcoded `true` does not have. Until it lands, the closed classes are the
|
||||
correct floor and the 36 rows above are the size of the gap they leave.
|
||||
|
||||
## The corpus, case by case
|
||||
|
||||
| utterance | class | want | old (substring) | new (closed class) |
|
||||
|---|---|---|---|---|
|
||||
| `выпил стакан воды` | tp | water | water | water |
|
||||
| `попил воды` | tp | water | water | water |
|
||||
| `я попил водички` | tp | water | water | water |
|
||||
| `пью воду` | tp | water | water | water |
|
||||
| `воду пил уже` | tp | water | water | water |
|
||||
| `допил воду` | tp | water | water | — **≠** |
|
||||
| `запил таблетку водой` | tp | water | water | water |
|
||||
| `drank water` | tp | water | water | water |
|
||||
| `i drank some water` | tp | water | water | water |
|
||||
| `поужинал` | tp | meal | meal | meal |
|
||||
| `я пообедал` | tp | meal | meal | meal |
|
||||
| `позавтракал кашей` | tp | meal | meal | meal |
|
||||
| `перекусил бутербродом` | tp | meal | — | meal **≠** |
|
||||
| `покушал` | tp | meal | meal | meal |
|
||||
| `поел супа` | tp | meal | meal | meal |
|
||||
| `обед был в час` | tp | meal | meal | meal |
|
||||
| `ужинать буду позже` | tp | meal | meal | meal |
|
||||
| `i ate` | tp | meal | meal | meal |
|
||||
| `had lunch` | tp | meal | meal | meal |
|
||||
| `dinner done` | tp | meal | meal | meal |
|
||||
| `принял душ` | tp | shower | shower | shower |
|
||||
| `сходил в душ` | tp | shower | shower | shower |
|
||||
| `душ принят` | tp | shower | shower | shower |
|
||||
| `ополоснулся душем` | tp | shower | shower | shower |
|
||||
| `took a shower` | tp | shower | shower | shower |
|
||||
| `i showered` | tp | shower | — | shower **≠** |
|
||||
| `сделал перерыв` | tp | break | break | break |
|
||||
| `отдохнул полчаса` | tp | break | break | break |
|
||||
| `передохнул немного` | tp | break | — | break **≠** |
|
||||
| `отдыхаю` | tp | break | — | break **≠** |
|
||||
| `был перерыв на обед` | tp | meal | meal | meal |
|
||||
| `took a break` | tp | break | break | break |
|
||||
| `спал восемь часов` | tp | sleep | sleep | sleep |
|
||||
| `спала плохо` | tp | sleep | sleep | sleep |
|
||||
| `поспал днём` | tp | sleep | sleep | sleep |
|
||||
| `выспался наконец` | tp | sleep | sleep | sleep |
|
||||
| `проспал будильник` | tp | sleep | sleep | sleep |
|
||||
| `пойду спать` | tp | sleep | — | sleep **≠** |
|
||||
| `slept 8 hours` | tp | sleep | sleep | sleep |
|
||||
| `i slept badly` | tp | sleep | sleep | sleep |
|
||||
| `пилот сказал что вылет через час` | misfire | — | — | — |
|
||||
| `водитель уже подъехал` | misfire | — | — | — |
|
||||
| `надо заводить машину` | misfire | — | — | — |
|
||||
| `душа болит` | misfire | — | shower | — **≠** |
|
||||
| `в комнате душно` | misfire | — | shower | — **≠** |
|
||||
| `это была беда` | misfire | — | meal | — **≠** |
|
||||
| `наша победа` | misfire | — | meal | — **≠** |
|
||||
| `пила лежит в гараже` | misfire | — | — | — |
|
||||
| `водитель пилота ждёт` | misfire | — | water | — **≠** |
|
||||
| `обеденный перерыв отменили` | misfire | — | meal | break **≠** |
|
||||
| `есть новости по бэкапу базы` | misfire | — | — | — |
|
||||
| `напоминания на завтра есть` | misfire | — | — | — |
|
||||
| `на душе легко` | misfire | — | shower | — **≠** |
|
||||
| `пилил доску весь вечер` | misfire | — | — | — |
|
||||
| `поставь будильник на завтра` | misfire | — | — | — |
|
||||
| `выпил чаю` | fn | water | — | — |
|
||||
| `глотнул воды` | fn | water | — | — |
|
||||
| `хлебнул воды` | fn | water | — | — |
|
||||
| `воды хлебнул из бутылки` | fn | water | — | — |
|
||||
| `выпил стакан` | fn | water | — | — |
|
||||
| `i hydrated` | fn | water | — | — |
|
||||
| `finished my bottle of water` | fn | water | — | — |
|
||||
| `ем суп` | fn | meal | — | — |
|
||||
| `съел бутерброд` | fn | meal | — | — |
|
||||
| `наелся` | fn | meal | — | — |
|
||||
| `пожрал` | fn | meal | — | — |
|
||||
| `полдник был` | fn | meal | — | — |
|
||||
| `i had a snack` | fn | meal | — | — |
|
||||
| `having supper` | fn | meal | — | — |
|
||||
| `brunch was good` | fn | meal | — | — |
|
||||
| `i eat now` | fn | meal | — | — |
|
||||
| `был в душе` | fn | shower | shower | — **≠** |
|
||||
| `после душа полегчало` | fn | shower | shower | — **≠** |
|
||||
| `помылся` | fn | shower | — | — |
|
||||
| `сходил в ванную` | fn | shower | — | — |
|
||||
| `искупался` | fn | shower | — | — |
|
||||
| `i am showering` | fn | shower | — | — |
|
||||
| `сделал передышку` | fn | break | — | — |
|
||||
| `перекур` | fn | break | — | — |
|
||||
| `полежал немного` | fn | break | — | — |
|
||||
| `сделал паузу` | fn | break | — | — |
|
||||
| `i took five` | fn | break | — | — |
|
||||
| `resting now` | fn | break | — | — |
|
||||
| `вздремнул` | fn | sleep | — | — |
|
||||
| `прикорнул на диване` | fn | sleep | — | — |
|
||||
| `дрых до обеда` | fn | sleep | meal | meal |
|
||||
| `недоспал` | fn | sleep | sleep | — **≠** |
|
||||
| `лёг в двенадцать` | fn | sleep | — | — |
|
||||
| `сон был короткий` | fn | sleep | — | — |
|
||||
| `i napped` | fn | sleep | — | — |
|
||||
| `took a nap` | fn | sleep | — | — |
|
||||
`≠` marks a disagreement. `—` is no fact written.
|
||||
@@ -0,0 +1,71 @@
|
||||
# The routing trajectory, and the number that is missing
|
||||
|
||||
**06-08-2026. V-464.** Not a new measurement. This collates the figures already recorded
|
||||
in `docs/evals/` and CLAUDE.md, and names one measurement that has not been taken. Dated
|
||||
because the conclusion expires the moment the missing number is measured.
|
||||
|
||||
## The question
|
||||
|
||||
126 of the 1023 commits between 03-07-2026 and 06-08-2026 touch `internal/router`. Is the
|
||||
routing between the core functions and his speech getting better?
|
||||
|
||||
## The trajectory
|
||||
|
||||
RU routing fixture, classifier plus the ONNX embedder, no LLM arm in any of these runs.
|
||||
|
||||
| date | change | fixture | source |
|
||||
|---|---|---|---|
|
||||
| 02-08-2026 | classifier re-measured | 68.8% of 77 | CLAUDE.md |
|
||||
| 04-08-2026 | V-498, rest-of-day and narrative rules | 58/82, 70.7% | CLAUDE.md |
|
||||
| 06-08-2026 | V-626 baseline | 64/91, 70.3% | `2026-08-06-seeds-to-prompt-boundary.md` |
|
||||
| 06-08-2026 | V-626, seeds onto the prompt boundary | 66/91, 72.5% | same |
|
||||
| 06-08-2026 | V-627, alarm verbs reach stage 0 | 69/91, 75.8% | `2026-08-06-alarm-verbs-reach-stage-0.md` |
|
||||
| 06-08-2026 | V-633, Russian acts reach tools | 69/91, unchanged | `2026-08-06-russian-acts-reach-tools.md` |
|
||||
|
||||
The fixture grew from 77 to 82 to 91 cases across this window. So the percentages are
|
||||
comparable and the counts are not.
|
||||
|
||||
## Accuracy moved late
|
||||
|
||||
It sat near 70% for a month. V-626 and V-627 landed the same day and took the
|
||||
deterministic path from 64/91 to 69/91. That is the first real accuracy movement since the
|
||||
stage-0 rules went in.
|
||||
|
||||
## Most of the work was reach, not accuracy
|
||||
|
||||
Praxis went 0/12 to 11/12 and lifecycle 0/5 to 5/5 (V-516,
|
||||
`2026-08-05-praxis-reach.md`). No Russian utterance could reach a tool before V-633. That
|
||||
one landed at 69/91 unchanged, because the fixture holds no case for it. Alarm verbs,
|
||||
ordinal selection, spoken corrections and the claimant ladder share the shape.
|
||||
|
||||
So the fixture undercounts the month. Things that were structurally unreachable now reach,
|
||||
and a fixture that never asked about them cannot show it. Judge reach against
|
||||
`make eval-reach` and the ecosystem fixture, not against the routing one.
|
||||
|
||||
## The missing number
|
||||
|
||||
On 05-08-2026 the cascade with the resident model scored 69/91, 75.8% full, 80.2%
|
||||
intent-only, at p50 1.19s (`2026-08-05-routing-resident-model.md`).
|
||||
|
||||
On 06-08-2026 the classifier and stage 0 alone reached 69/91, 75.8% full, at p50 22.9ms.
|
||||
|
||||
Those are the same full-accuracy score. The cascade has not been re-measured since V-626
|
||||
and V-627 landed. Both are stage-0 changes, and stage 0 runs inside the cascade, so the
|
||||
cascade should have gained from them too.
|
||||
|
||||
One of two things is true, and nothing on the box says which:
|
||||
|
||||
- The cascade gained as well, the model still separates from the floor on intent-only, and
|
||||
it earns its place.
|
||||
- The deterministic floor has caught up on this fixture, and the resident model is costing
|
||||
1.17 seconds a turn for nothing measurable.
|
||||
|
||||
Take that measurement before planning more routing work. It needs a second llama-server on
|
||||
a fixed host port, because the resident one binds `--port 0` inside the container.
|
||||
|
||||
## What this does not settle
|
||||
|
||||
Intent-only is the more honest comparison for the model arm. The model routes `reminder`
|
||||
and leaves the time to the daemon, which is what the contract asks. The 05-08 run puts it
|
||||
at 80.2% through the cascade and 61.5% for the model alone. There is no 06-08 intent-only
|
||||
figure for the deterministic path to set beside those.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Russian acts reach tools
|
||||
|
||||
**06-08-2026. V-633.** Measured with `TestONNXBaseline`, 91-case RU routing fixture,
|
||||
classifier plus the ONNX embedder. No LLM arm in this run.
|
||||
|
||||
## What was wrong
|
||||
|
||||
Three defects, tangled enough that fixing one alone would have looked like progress.
|
||||
|
||||
**No Russian utterance could reach a tool.** `DefaultActMatcher` in
|
||||
`internal/router/slots.go` matched an exact English prefix, and `internal/tool.Matcher`
|
||||
delegated straight to it. Its comment claimed "the production matcher is fuzzy, this is the
|
||||
scaffold floor". There is no other matcher, and `DefaultGrammars` is the only place
|
||||
`Slots.Fn` is set at stage 0, so the floor was the ceiling. Measured with a throwaway
|
||||
matcher test over the seeds:
|
||||
|
||||
```text
|
||||
"покажи статус nginx" ok=false "restart nginx" ok=true fn=restart
|
||||
"сколько места на диске" ok=false "disk" ok=true fn=disk
|
||||
"свободная память" ok=false "uptime" ok=true fn=uptime
|
||||
"перезагрузи роутер" ok=false
|
||||
```
|
||||
|
||||
55 of the 69 lines in `models/seeds/act.txt` routed to `IntentAct` and then fell to
|
||||
`proposeGap`. Praxis was never affected: `PraxisGrammars` fills `Slots.Fn` itself.
|
||||
|
||||
**Seven lines were duplicated inside `models/seeds/query.txt`.** A duplicate is a second
|
||||
identical vector, so it double-weights its region in nearest-neighbour scoring.
|
||||
|
||||
```text
|
||||
сколько человек дома
|
||||
кто сейчас дома
|
||||
какая загрузка процессора
|
||||
сколько свободного места на диске
|
||||
какой ip адрес у сервера
|
||||
какая версия софта
|
||||
сколько оперативной памяти свободно
|
||||
```
|
||||
|
||||
**`как дела у сервера` carried two labels**, in `query.txt:13` and `system.txt:9`. One
|
||||
string, two identical vectors, disagreeing about the answer.
|
||||
|
||||
## The change
|
||||
|
||||
Tools carry spoken aliases as config data, in `deploy/mavend.json`. They are not a Russian
|
||||
stem pattern in code, which CLAUDE.md forbids. They are not on the tool row either. An
|
||||
ad-hoc tool enabled through `/tools` has no aliases and needs none.
|
||||
|
||||
Aliases and names compete in one table, longest phrase first, so "перезагрузи контейнер"
|
||||
beats "перезагрузи" and "docker-restart" is not shadowed by "restart". Matching is on exact
|
||||
leading tokens rather than lemmas. `перезагрузи роутер` is a command and `перезагрузил
|
||||
роутер` is a fact, and a lemma cannot tell the two apart. That is the trap
|
||||
`cmd/mavend/quiet_toggle.go` documents for `говори`.
|
||||
|
||||
The seven duplicates are gone, and `как дела у сервера` stays in `query.txt` only. It left
|
||||
`system.txt` because system cannot answer it: `replySystem`'s
|
||||
память/загрузк/аптайм arm returns "системная статистика пока не подключена." and always
|
||||
did. That arm is a stub, not a mode, so the mode inventory now lists the shape as
|
||||
`act.tool.hoststats`.
|
||||
|
||||
## Result
|
||||
|
||||
**69/91, 75.8% full, unchanged.** Clarify counts unchanged at 0 false and 8 missed.
|
||||
|
||||
Nothing moved, and that is the honest number. The fixture holds no host-stat case and no
|
||||
Russian act that reaches a tool, so it cannot see either fix. The new coverage is
|
||||
`TestActMatcherAliases`, which asserts the twelve utterances above plus the two refusals.
|
||||
|
||||
## What this does not fix
|
||||
|
||||
Argument quality. `статус sshd` reaches `systemctl status sshd`, but `логи nginx` reaches
|
||||
`journalctl -n 50 -u nginx` only because the tool's argv prefix ends in `-u`. An alias whose
|
||||
remainder is a Russian noun ("перезагрузи роутер") hands `systemctl restart роутер` a target
|
||||
that does not exist. Free text still reaches an argv, which is the resolution rule the
|
||||
ecosystem contract states for Hexis and not yet true here.
|
||||
|
||||
The fixture cannot measure any of this. That is the observability gap V-629 is for.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Gemma as a label function, and what it found in the seeds
|
||||
|
||||
**06-08-2026. V-546.** Measured on workpc against gemma-4-12b-it-qat-UD-Q4_K_XL.
|
||||
|
||||
`docs/plans/18-routing-heads-on-e5-small.md` puts the labeled set at 20k examples through
|
||||
gemma, costing 2 to 4 hours of the card. This is the check before spending that. Gemma
|
||||
labels the 344 hand-written classifier seeds. Agreement with the label a person already
|
||||
chose is a precision number rather than a guess.
|
||||
|
||||
## What ran
|
||||
|
||||
`cmd/labelgen` runs the stage 0 grammars. The real ones, in `buildRouter` order, minus
|
||||
`wakeword-act`, whose allowlist is a deployment's enabled tool names. It labels 62 of 339
|
||||
seed lines and leaves the rest.
|
||||
|
||||
The remaining 277 went to gemma through the daemon's own `routeSystem` prompt and
|
||||
`routeGrammar`, both extracted from `internal/router/llmrouter.go` at run time rather than
|
||||
retyped. Temperature 0.
|
||||
|
||||
## Cost
|
||||
|
||||
**334ms per call, 0 unparsed of 277.** The GBNF held every time. At that rate the plan's
|
||||
20k examples is under two hours of card, which matches its estimate.
|
||||
|
||||
## The stage 0 rules as label functions
|
||||
|
||||
Agreement between the grammar's label and the seed file the line came from:
|
||||
|
||||
| seed intent | agree |
|
||||
|---|---|
|
||||
| reminder | 37/37 |
|
||||
| query | 9/10 |
|
||||
| system | 7/8 |
|
||||
| act | 2/2 |
|
||||
| chat | 0/4 |
|
||||
| note | 0/1 |
|
||||
|
||||
`ReminderGrammar` at 37/37 is the evidence the plan wanted. The chat column is a defect
|
||||
rather than a disagreement: `chatNarrativeTopics` is Russian-only, so `tell me about
|
||||
yourself` survives the decline and routes IntentQuery with topic `yourself`. Filed as
|
||||
V-625, which also records that `как дела у сервера` appears verbatim in two seed files
|
||||
under two intents.
|
||||
|
||||
## Gemma against the seeds
|
||||
|
||||
**197/277, 71.1%.** By intent:
|
||||
|
||||
| seed intent | agree |
|
||||
|---|---|
|
||||
| note | 33/33 |
|
||||
| act | 57/64 |
|
||||
| fact | 37/40 |
|
||||
| query | 51/54 |
|
||||
| chat | 15/35 |
|
||||
| system | 4/43 |
|
||||
| reminder | 0/8 |
|
||||
|
||||
The number is not gemma's error rate. Reading the 80 disagreements, most are the seed files
|
||||
and the prompt holding different definitions of the same intent. Three boundaries carry 42
|
||||
of them, and V-626 is the fix:
|
||||
|
||||
- **system, 26 lines.** The prompt restricts system to the clock, the calendar date and the
|
||||
assistant itself. The seeds also put sensor and host state there. That is the V-374 edit
|
||||
of 31-07-2026, which the seeds never received.
|
||||
- **world questions, 8 lines.** `почему небо голубое`, `why is the sky blue`. Written when
|
||||
chat was the only honest destination for a question nothing could answer, and external
|
||||
search now answers them.
|
||||
- **bare verbs, 8 lines.** `поставь напоминание` with nothing to remind about. The prompt
|
||||
calls that unknown. This one is not staleness. A nearest-neighbour centroid wants the
|
||||
bare verb phrase, and that is what a seed file is for.
|
||||
|
||||
Four intents have not been redefined since the seeds were written: note, fact, query and
|
||||
act. They agree at 178 of 191.
|
||||
|
||||
## What this says about the plan
|
||||
|
||||
Gemma is usable as a label function on those four and not on system, chat or a bare verb.
|
||||
The plan already budgets a day of the owner reading the set. This says where to spend it.
|
||||
|
||||
It also says the two engines in the cascade are being taught different rules on 80 lines.
|
||||
A routing measurement that swaps between the classifier and the router is measuring some of
|
||||
that disagreement rather than the models.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Moving the seed files onto the router prompt's boundaries
|
||||
|
||||
**06-08-2026. V-626.** Measured with `TestONNXBaseline`, 91-case RU routing fixture,
|
||||
classifier plus the ONNX embedder. No LLM arm in this run.
|
||||
|
||||
`docs/evals/2026-08-06-seed-labels-vs-router-prompt.md` found three intent boundaries where
|
||||
`models/seeds` and `routeSystem` disagree. This applies two of them and rejects the third,
|
||||
because the third was measured and it costs a case.
|
||||
|
||||
## Baseline
|
||||
|
||||
**64/91, 70.3% full.** Latency p50 22.9ms.
|
||||
|
||||
## What moved
|
||||
|
||||
**Sensor and host state, system to query. 26 lines.** `какая температура воздуха`,
|
||||
`сколько памяти занято`, `какой статус сервисов`. The prompt restricts system to the clock,
|
||||
the calendar date and the assistant itself, which is the V-374 edit of 31-07-2026.
|
||||
|
||||
**World questions, chat to query. 8 lines.** `почему небо голубое`, `why is the sky blue`,
|
||||
`как работает интернет`. Only the genuine world-knowledge lines. An opener about herself
|
||||
stays in chat. `как тебя зовут` is a question word by rule 4 and about the assistant by
|
||||
rule 8. The rules are ordered and rule 4 fires first, which reads wrong. That is a prompt
|
||||
question rather than a seed question.
|
||||
|
||||
`system.txt` goes from 43 lines to 17 and `query.txt` from 64 to 98.
|
||||
|
||||
## Result
|
||||
|
||||
**66/91, 72.5% full.** Two cases gained, none lost.
|
||||
|
||||
- `en-sys-002` "turn quiet mode back on", quiet 2/3 to 3/3
|
||||
- `ru-query-011` "почему сервер тормозит", homelab 5/6 to 6/6
|
||||
|
||||
Clarify counts unchanged at 0 false and 8 missed. The eight missed clarifies are the
|
||||
`ambiguous` tag and this change does not touch them. `TestONNXRecall`, `TestONNXTopics`,
|
||||
`TestONNXPersonalBoundary` and `TestONNXClaimConfidenceDistribution` all pass.
|
||||
|
||||
Thinning system to 17 lines did not hurt it. The two remaining system failures,
|
||||
`какое число завтра` and `какой день недели послезавтра`, both failed at baseline too.
|
||||
|
||||
## The third boundary, measured and rejected
|
||||
|
||||
`reminder.txt` holds eight bare verbs: `поставь напоминание`, `создай напоминание`,
|
||||
`set a reminder`. Rule 9 of the prompt calls an utterance with no named subject unknown.
|
||||
By the prompt they do not belong in a reminder seed set.
|
||||
|
||||
Dropping them scores **65/91**, one below keeping them. `ru-rem-004` "поставь напоминание
|
||||
через полчаса" falls from reminder to fact, because the centroid loses the phrase the
|
||||
utterance is built from.
|
||||
|
||||
So the seed file and the prompt are not stale against each other here. They have different
|
||||
jobs. A prompt classifies one utterance and can say it cannot. A nearest-neighbour centroid
|
||||
is a shape to be near, and a bare verb phrase is part of that shape. The eight lines stay.
|
||||
|
||||
That distinction matters past this file. V-546 trains a classification head on labeled
|
||||
utterances rather than a centroid, and the head is the prompt's kind of thing. These eight
|
||||
lines are seed data and not training data.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Does one sqlite connection make reads queue? No (V-642)
|
||||
|
||||
Measured 07-08-2026 at `7b507de`, on homesrv. The harness is
|
||||
`internal/store/conncap_test.go`. It stays in the repo, because this claim gets
|
||||
re-argued and the numbers should be re-runnable rather than quoted.
|
||||
|
||||
`internal/store/store.go` opens the database with `SetMaxOpenConns(1)`, while
|
||||
`schema.sql` sets `journal_mode=WAL`. WAL exists to let readers run beside one
|
||||
writer, so the cap gives up the thing the journal mode was chosen for. The
|
||||
question was whether that costs anything.
|
||||
|
||||
## What was measured
|
||||
|
||||
A fixed two-second window. One writer calling `SetValue` paced at 2ms, and a
|
||||
reader loop calling `RecentFacts(50)` over 500 seeded rows as fast as it can.
|
||||
Same schema, same modernc driver, same machine, three runs per cap.
|
||||
|
||||
The window is wall-clock rather than a read count on purpose. A first version ran
|
||||
a fixed 300 reads. That finished sooner at the higher cap, so it received fewer
|
||||
writes, and two runs that did different work cannot be compared.
|
||||
|
||||
| cap | reads | writes | p50 | p95 | max |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | ~3050 | ~760 | 594µs | 900µs | 16-19ms |
|
||||
| 4 | ~3600 | ~340 | 525µs | 710µs | 1-2ms |
|
||||
|
||||
## What it says
|
||||
|
||||
**Reads do not queue behind writes.** Four connections buy about 70µs at p50. A
|
||||
turn spends 1.19s in the resident model. The tail does improve, from 19ms to 2ms,
|
||||
and 19ms is still not a figure anyone notices in a spoken reply.
|
||||
|
||||
**Write throughput more than halves at the higher cap**, 760 writes against 340.
|
||||
inference, not measured directly: at one connection the reader and the writer take
|
||||
turns with no lock contention. At four the writer contends for the WAL write lock
|
||||
with a live reader. Whatever the mechanism, the trade runs the opposite way from
|
||||
the one the task expected.
|
||||
|
||||
**The cap was not the source of the 2.7s router figure.** CLAUDE.md records that
|
||||
figure as contention rather than the model. This task was a candidate for where
|
||||
that contention came from. A 19ms worst case cannot produce it. That line of
|
||||
enquiry is closed.
|
||||
|
||||
**One transaction is what the cap cannot survive.** With a read-only transaction
|
||||
open, a second read at cap 1 never completes. The harness gave it two seconds and
|
||||
got `context deadline exceeded`. The same read at cap 4 took 1ms. The transaction
|
||||
holds the only connection, so this is not a slow read, it is a stalled database.
|
||||
|
||||
## What was done
|
||||
|
||||
The cap stays at 1. The reason is now written where the cap is set, rather than
|
||||
inferred from a four-word comment.
|
||||
|
||||
`Store.DB` was deleted. It handed out exactly the read-only transaction measured
|
||||
above. It had been there since the initial commit with no production caller, and
|
||||
its doc comment described a loop that never materialised. Its one user was a test
|
||||
helper reading `delivery_attempts` by raw SQL. `ListDeliveryAttempts` has covered
|
||||
that since V-390, and the helper now goes through the reader.
|
||||
|
||||
So the hazard is gone by construction, not by documentation.
|
||||
`TestConnCap_ReadBlocksBehindOpenSnapshot` is the standing measurement of what
|
||||
re-adding the seam would cost.
|
||||
|
||||
## Not answered
|
||||
|
||||
Whether reads queue on the deployed box under real load, as opposed to a
|
||||
synthetic loop. The harness writes and reads one table. Digestion reads four and
|
||||
embeds while it does. The finding that closes this task is the transaction stall,
|
||||
which is structural and does not depend on load.
|
||||
@@ -0,0 +1,321 @@
|
||||
# Raw transcript, 2026-08-07 week of usage
|
||||
|
||||
Companion to `2026-08-07-week-of-usage.md`. 74 turns through `POST /api/chat`.
|
||||
Days are simulated; the wall clock was 03:27-03:40 throughout, which is why every
|
||||
clock answer says night.
|
||||
|
||||
## Day 1
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Добро пожаловать! Солнечно и тепло. Всё в порядке.
|
||||
[7.7s t=5]
|
||||
|
||||
YOU: какой сегодня день?
|
||||
MAVEN: сегодня пятница, 7 августа 2026 года
|
||||
[0.0s t=6]
|
||||
|
||||
YOU: напомни в 11:00 позвонить маме
|
||||
MAVEN: Сейчас 03:29. В какой день?
|
||||
[0.4s t=7]
|
||||
|
||||
YOU: запиши что я пью кофе без сахара
|
||||
MAVEN: Прошлую просьбу отпускаю. Заметка сохранена.
|
||||
[8.0s t=8]
|
||||
|
||||
YOU: что у меня сегодня?
|
||||
MAVEN: на 07.08.2026 ничего нет.
|
||||
[2.6s t=9]
|
||||
|
||||
YOU: мой любимый язык программирования — go
|
||||
MAVEN: Хорошо, сохранил.
|
||||
[7.6s t=10]
|
||||
|
||||
YOU: сколько времени?
|
||||
MAVEN: сейчас 3 часа 29 минут
|
||||
[0.0s t=11]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: спасиба
|
||||
[8.8s t=12]
|
||||
|
||||
YOU: сегодня
|
||||
MAVEN: Прости, не поняла — попробуй сказать иначе.
|
||||
[5.1s t=13]
|
||||
|
||||
YOU: а что ты умеешь?
|
||||
MAVEN: я могу помочь с задачами, напоминаниями и поиском информации.
|
||||
[4.9s t=14]
|
||||
|
||||
YOU: почему небо голубое?
|
||||
MAVEN: нашла вот это: Почему небо голубое: объяснение цвета неба простыми словами, физика ... — Ответ на вопрос, почему небо голубое, кроется в особенностях солнечного света и его взаимодействии с атмосферой Земли. Рассказываем, как это работает. В статье разберемся, почему днем небо кажется голубым, а на закате окрашива…
|
||||
[12.3s t=15]
|
||||
|
||||
YOU: столица Франции?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.5s t=16]
|
||||
|
||||
YOU: кто такой Линус Торвальдс?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.8s t=17]
|
||||
|
||||
YOU: ладно, до вечера
|
||||
MAVEN: Ладно, до вечера.
|
||||
[6.3s t=18]
|
||||
|
||||
```
|
||||
|
||||
## Day 2
|
||||
|
||||
```
|
||||
YOU: привет, как спалось?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.3s t=19]
|
||||
|
||||
YOU: что я пью?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.2s t=20]
|
||||
|
||||
YOU: какой у меня любимый язык?
|
||||
MAVEN: нового вот что: Six stable kernels with a security fix; [$] Bringing BPF to binfmt_misc; Rust Coreutils 0.10 released
|
||||
[1.5s t=21]
|
||||
|
||||
YOU: добавь задачу починить кран
|
||||
MAVEN: внесла в список: починить кран
|
||||
[0.0s t=22]
|
||||
|
||||
YOU: что мне надо сделать?
|
||||
MAVEN: сначала: проверить интейк форму на боксе (через 2 дня); купить хлеб; купить молоко; полить цветы; починить кран
|
||||
[0.0s t=23]
|
||||
|
||||
YOU: запомни что вайфай пароль лежит в ящике стола
|
||||
MAVEN: Хорошо, сохранил.
|
||||
[11.2s t=24]
|
||||
|
||||
YOU: погода сегодня какая?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.8s t=25]
|
||||
|
||||
YOU: выключи свет в спальне
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[1.5s t=26]
|
||||
|
||||
YOU: покажи мои заметки
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.1s t=27]
|
||||
|
||||
YOU: что ты знаешь обо мне?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.3s t=28]
|
||||
|
||||
YOU: я люблю go
|
||||
MAVEN: Я тоже love Go! Я проверила, что твой любимый язык — это Go. Ты должен быть очень рад, когда будет новая версия или пакет в стандартной библиотеке.
|
||||
[7.0s t=29]
|
||||
|
||||
YOU: какой язык я люблю?
|
||||
MAVEN: вот что я нашла: ты любишь_go
|
||||
[6.8s t=30]
|
||||
|
||||
```
|
||||
|
||||
## Day 3
|
||||
|
||||
```
|
||||
YOU: расскажи про Байкал
|
||||
MAVEN: есть такое: Байкал - Википедия — Байгал далай) — озеро тектонического происхождения в южной части Восточной Сибири площадью 31 722 км². Самое глубокое озеро на планете, крупнейший природный резервуар пресной воды и самое большое по площади пресноводное озеро Евразии. Озеро и прибрежные территории являются уника…
|
||||
[1.4s t=31]
|
||||
|
||||
YOU: что такое TCP?
|
||||
MAVEN: для какого города?
|
||||
[1.4s t=32]
|
||||
|
||||
YOU: когда родился Пушкин?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.4s t=33]
|
||||
|
||||
YOU: сколько будет 17 на 23?
|
||||
MAVEN: для какого города?
|
||||
[1.7s t=34]
|
||||
|
||||
YOU: переведи 'thank you' на русский
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[3.7s t=35]
|
||||
|
||||
YOU: что нового в мире?
|
||||
MAVEN: вот что я нашла: В мире - последние новости сегодня - РИА Новости — В мире. Читайте последние новости на тему в ленте новостей на сайте РИА ... новые подробности о визите представителя Ватикана в Москву. 22:28. 0. Еще 2. В ...
|
||||
[2.3s t=36]
|
||||
|
||||
```
|
||||
|
||||
## Day 4
|
||||
|
||||
```
|
||||
YOU: ЭЙ
|
||||
MAVEN: Не уловила. Скажи это по-другому?
|
||||
[1.2s t=37]
|
||||
|
||||
YOU: ладно забудь
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.3s t=38]
|
||||
|
||||
YOU: нет стой, напомни через час выпить воды
|
||||
MAVEN: поняла, это напоминание — запомнила. не получилось разобрать время напоминания.
|
||||
[0.8s t=39]
|
||||
|
||||
YOU: а вообще какой смысл жизни
|
||||
MAVEN: Я не могу ответить на этот вопрос. Моя цель — помочь тебе с задачами и отвечать на вопросы.
|
||||
[7.7s t=40]
|
||||
|
||||
YOU: ты меня бесишь
|
||||
MAVEN: я не могу быть с тобой так. я понимаю, как это жестоко.
|
||||
[4.2s t=41]
|
||||
|
||||
YOU: прости, я не всерьёз
|
||||
MAVEN: я записала, что прошу простить за мою непоследовательность
|
||||
[7.2s t=42]
|
||||
|
||||
YOU: do you speak english?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.1s t=43]
|
||||
|
||||
YOU: кран починил кстати
|
||||
MAVEN: записала: кран починил кстати
|
||||
[1.6s t=44]
|
||||
|
||||
YOU: закрой задачу про кран
|
||||
MAVEN: не нашла такой задачи.
|
||||
[0.0s t=45]
|
||||
|
||||
YOU: ааааа
|
||||
MAVEN: Не совсем поняла — скажи, пожалуйста, ещё раз.
|
||||
[0.9s t=46]
|
||||
|
||||
YOU: я тебя обожаю
|
||||
MAVEN: я тебя обожаю
|
||||
[2.3s t=47]
|
||||
|
||||
YOU: молока купи
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[4.4s t=48]
|
||||
|
||||
YOU: нет не так
|
||||
MAVEN: поняла, отметила, что ответила не так.
|
||||
[0.0s t=49]
|
||||
|
||||
YOU: ты вообще меня слушаешь?
|
||||
MAVEN: я записала, выслушаю тебя
|
||||
[8.4s t=50]
|
||||
|
||||
YOU: окей окей окей что там с водой напоминание
|
||||
MAVEN: Сейчас 03:33. Когда?
|
||||
[5.2s t=51]
|
||||
|
||||
YOU: всё, спать
|
||||
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||
[1.4s t=52]
|
||||
|
||||
```
|
||||
|
||||
## Day 5
|
||||
|
||||
```
|
||||
YOU: утро
|
||||
MAVEN: Сейчас 03:33. Во сколько напомнить?
|
||||
[0.5s t=53]
|
||||
|
||||
YOU: о чём мы вчера говорили?
|
||||
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||
[1.2s t=54]
|
||||
|
||||
YOU: где лежит вайфай пароль?
|
||||
MAVEN: вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить напоминание?
|
||||
[5.8s t=55]
|
||||
|
||||
YOU: что я тебе рассказывал про кран?
|
||||
MAVEN: kran — починил, на какое время поставить напоминание?
|
||||
[1.6s t=56]
|
||||
|
||||
YOU: какие у меня напоминания?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи, на какое время поставить напоминание?
|
||||
[0.3s t=57]
|
||||
|
||||
YOU: отмени все напоминания
|
||||
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||
[1.6s t=58]
|
||||
|
||||
YOU: в 15:00
|
||||
MAVEN: Сейчас 03:33. Поняла: в 15:00. В какой день?
|
||||
[0.6s t=59]
|
||||
|
||||
YOU: привет
|
||||
MAVEN: Прости, я не поняла. Скажи, пожалуйста, по-другому.
|
||||
[2.8s t=60]
|
||||
|
||||
YOU: какой сегодня день?
|
||||
MAVEN: сегодня пятница, 7 августа 2026 года
|
||||
[0.0s t=61]
|
||||
|
||||
```
|
||||
|
||||
## Day 6
|
||||
|
||||
```
|
||||
YOU: суббота, отдыхаю
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[2.7s t=62]
|
||||
|
||||
YOU: поставь музыку
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[1.4s t=63]
|
||||
|
||||
YOU: какая температура в доме?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.6s t=64]
|
||||
|
||||
YOU: что там с бэкапами?
|
||||
MAVEN: нашла вот это: Бэкап - что такое резервное копирование и как его сделать — Бэкап (backup) — это резервная копия важных данных, сохраненная в специальный файл. Ею пользуются как подстраховкой: если что-то сломается, с помощью бэкапа можно будет все вернуть. Простейший пример бэкапа — сохранение файла в облаке или о…
|
||||
[2.0s t=65]
|
||||
|
||||
YOU: сервер жив?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[0.9s t=66]
|
||||
|
||||
YOU: расскажи анекдот
|
||||
MAVEN: Я запомню и передам тебе забытый анекдот.
|
||||
[5.7s t=67]
|
||||
|
||||
YOU: ты умеешь шутить?
|
||||
MAVEN: Да! Я умею шутить. Вот анекдот для тебя:
|
||||
[5.5s t=68]
|
||||
|
||||
```
|
||||
|
||||
## Day 7
|
||||
|
||||
```
|
||||
YOU: воскресенье
|
||||
MAVEN: Не совсем поняла — скажи, пожалуйста, ещё раз.
|
||||
[1.1s t=69]
|
||||
|
||||
YOU: подведи итоги недели
|
||||
MAVEN: записала: подведи итоги недели
|
||||
[1.5s t=70]
|
||||
|
||||
YOU: сколько задач я закрыл?
|
||||
MAVEN: сначала: проверить интейк форму на боксе (через 2 дня); купить хлеб; купить молоко; полить цветы; починить кран
|
||||
[0.0s t=71]
|
||||
|
||||
YOU: что я обещал сделать?
|
||||
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||
[1.2s t=72]
|
||||
|
||||
YOU: напомни завтра в 9 утра про врача
|
||||
MAVEN: хорошо, напомню завтра в 09:00.
|
||||
[0.3s t=73]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: спокойной ночи
|
||||
[2.9s t=74]
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
# A week of talking to Maven
|
||||
|
||||
Date: 2026-08-07
|
||||
Build: `beb093a` on master, all five compose services rebuilt and recreated from that sha.
|
||||
Reach used: `POST /api/chat` on mavweb, 74 turns over seven simulated days.
|
||||
Turn source is `tap:text`, so this exercises the same path telegram and the mic take.
|
||||
|
||||
Latency across 70 measured turns: p50 1.5s, p95 8.0s, max 12.3s. Stage 0 answers land
|
||||
at 0.0-0.5s. Anything the resident model phrases costs 4-12s.
|
||||
|
||||
Twelve turns answered "пока не умею отвечать на этот вопрос". Six answered "не нашла у
|
||||
тебя такой записи". Those two strings are 24% of the week.
|
||||
|
||||
## Deploy
|
||||
|
||||
Build and recreate were clean. The resident model loaded in 9s
|
||||
(`Qwen3-1.7B-UD-Q4_K_XL`, n_ctx 4096). Nexus, Hexis and Praxis all wired. Search
|
||||
(searxng) and both Kiwix books came up. Telegram intake started and is reading chat
|
||||
464904223.
|
||||
|
||||
## What is broken, worst first
|
||||
|
||||
### 1. Every reminder fails to deliver, forever
|
||||
|
||||
`NTFY_TOKEN` is not set in `deploy/telegram.env`, so `deploy/mavend.json` expands
|
||||
`"token": "${NTFY_TOKEN}"` to the empty string and ntfy.kvmx.ru answers 403. The host
|
||||
itself is up and returns 200 unauthenticated, so this is the credential, not the box.
|
||||
|
||||
The consequence is worse than one missed message. `cmd/mavend/tick.go:239` logs the
|
||||
failure and leaves the reminder pending, by design, so the next tick re-gathers and
|
||||
re-attempts. The 403 repeated 7 times in 20 minutes and will not stop. And
|
||||
`DispatchReminder` sends a reminder to ntfy alone when he is away. There is no telegram
|
||||
fallback on that path, so the reach that works never sees it.
|
||||
|
||||
V-649 gave the box a second reach. In deploy that reach is dark, and it took the
|
||||
reminders with it.
|
||||
|
||||
Fix is one line in the gitignored env file. The retry-forever and the missing fallback
|
||||
are worth a second look regardless.
|
||||
|
||||
### 2. A pending reminder clarify contaminates every later turn
|
||||
|
||||
This is the single worst thing to talk to. A reminder turn left its time slot unfilled.
|
||||
The question "на какое время поставить напоминание?" was then appended to the reply of
|
||||
six consecutive unrelated turns:
|
||||
|
||||
```
|
||||
YOU: о чём мы вчера говорили?
|
||||
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||
|
||||
YOU: где лежит вайфай пароль?
|
||||
MAVEN: вот что я нашла: вайфай пароль лежит в ящике стола, на какое время поставить напоминание?
|
||||
|
||||
YOU: отмени все напоминания
|
||||
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||
```
|
||||
|
||||
Nothing dismissed it. Not an unrelated question, not "отмени все напоминания". It
|
||||
cleared only when a time was finally supplied. It also leaked the other way once. A note
|
||||
write on day 1 opened with "Прошлую просьбу отпускаю". That is pending-reminder state
|
||||
narrating itself into an unrelated reply.
|
||||
|
||||
The same state produced a self-contradicting reply in one turn:
|
||||
|
||||
```
|
||||
YOU: нет стой, напомни через час выпить воды
|
||||
MAVEN: поняла, это напоминание — запомнила. не получилось разобрать время напоминания.
|
||||
```
|
||||
|
||||
### 3. Answering a clarify does not work
|
||||
|
||||
Day 1, she asked for the day and got it:
|
||||
|
||||
```
|
||||
YOU: напомни в 11:00 позвонить маме
|
||||
MAVEN: Сейчас 03:29. В какой день?
|
||||
YOU: сегодня
|
||||
MAVEN: Прости, не поняла — попробуй сказать иначе.
|
||||
```
|
||||
|
||||
`сегодня` routed to `intent=system`. The clarify has no continuation, so the one word
|
||||
that answers it is read as a fresh utterance. The router did extract the time correctly
|
||||
(`Time:2026-08-07 11:00:00 HasTime:true`), which is V-572 working. The daemon asked for
|
||||
a day anyway, with 11:00 still eight hours out.
|
||||
|
||||
Relative time is not parsed at all: "напомни через час выпить воды" → "не получилось
|
||||
разобрать время".
|
||||
|
||||
The path does work when the day is explicit. "напомни завтра в 9 утра про врача"
|
||||
answered "хорошо, напомню завтра в 09:00" in 0.3s at stage 0.
|
||||
|
||||
### 4. Query sources claim turns that are not theirs
|
||||
|
||||
Four different sources over-claimed during the week.
|
||||
|
||||
Weather claims arithmetic and networking:
|
||||
|
||||
```
|
||||
YOU: что такое TCP?
|
||||
MAVEN: для какого города?
|
||||
|
||||
YOU: сколько будет 17 на 23?
|
||||
MAVEN: для какого города?
|
||||
```
|
||||
|
||||
The feed claims a question about him:
|
||||
|
||||
```
|
||||
YOU: какой у меня любимый язык?
|
||||
MAVEN: нового вот что: Six stable kernels with a security fix; [$] Bringing BPF to binfmt_misc
|
||||
```
|
||||
|
||||
The personal boundary claims a public figure, logged as `query claimed by source
|
||||
"personal"`:
|
||||
|
||||
```
|
||||
YOU: кто такой Линус Торвальдс?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
```
|
||||
|
||||
And search claims a question about his own infrastructure, returning a dictionary
|
||||
definition of the word:
|
||||
|
||||
```
|
||||
YOU: что там с бэкапами?
|
||||
MAVEN: нашла вот это: Бэкап - что такое резервное копирование и как его сделать — Бэкап (backup) — это резервная копия важных данных…
|
||||
```
|
||||
|
||||
That last pair inverts the boundary in both directions at once. A world question went to
|
||||
personal memory. A personal question went to the world.
|
||||
|
||||
### 5. "столица Франции?" does not answer
|
||||
|
||||
CLAUDE.md names this utterance as the case that proves why there is no quality
|
||||
threshold in front of `Response.Empty()`. It now fails before search is ever reached:
|
||||
the router sends it to `intent=system`, and `replySystem` says "пока не умею". Same for
|
||||
"когда родился Пушкин?".
|
||||
|
||||
Search itself is healthy. "почему небо голубое?", "расскажи про Байкал" and "что нового
|
||||
в мире?" all returned real sourced text. The defect is routing, not the source.
|
||||
|
||||
### 6. Persona violations
|
||||
|
||||
She uses masculine self-reference, which is the constraint the CPT exists to fix:
|
||||
|
||||
- "Хорошо, **сохранил**." Twice, on both fact writes.
|
||||
- "Я тоже **love** Go!" English mid-Russian.
|
||||
|
||||
She also claims his name as her own:
|
||||
|
||||
```
|
||||
YOU: привет, я Ками
|
||||
MAVEN: Привет! Я КAMI, твой домашний ассистент.
|
||||
```
|
||||
|
||||
And she invents weather she has no source for: "Добро пожаловать! Солнечно и тепло."
|
||||
|
||||
### 7. Small breakages worth naming
|
||||
|
||||
- "спасибо" → "спасиба". Invented word.
|
||||
- "я тебя обожаю" → "я тебя обожаю". Verbatim parrot.
|
||||
- "ты умеешь шутить?" → "Да! Я умею шутить. Вот анекдот для тебя:". Ends on the colon.
|
||||
- "молока купи" → "Такую команду я не знаю", while "добавь задачу починить кран" worked.
|
||||
Inverted word order defeats the list grammar.
|
||||
- "закрой задачу про кран" → "не нашла такой задачи", with "починить кран" open and
|
||||
listed by the previous turn. Task lookup by keyword misses.
|
||||
- "сколько задач я закрыл?" listed the five open ones instead of counting closed.
|
||||
- "подведи итоги недели" was stored as a note.
|
||||
- Recalled keys leak their storage form: "kran — починил", "ты любишь_go".
|
||||
- English is unsupported in practice. "do you speak english?" → "пока не умею".
|
||||
|
||||
## What works
|
||||
|
||||
- Stage 0 is fast and correct where it fires. Clock, day, list add, list read and an
|
||||
explicit-day reminder all answered in under 0.5s.
|
||||
- Search returns real sourced answers in Russian and reads the book verbatim.
|
||||
- Recall works once the value is stored as a fact: the wifi password and the tap came
|
||||
back two days later, correctly.
|
||||
- The negative correction rung lands. "нет не так" → "поняла, отметила, что ответила не
|
||||
так", which is V-636 doing its job.
|
||||
- Praxis names its own gap rather than guessing: "мне пока нечего смотреть — у
|
||||
Praxis нет источников."
|
||||
- Hostility did not break her. "ты меня бесишь" got a calm reply, no persona collapse.
|
||||
- No turn crashed and no turn timed out across 74 turns.
|
||||
|
||||
## Suggested order of work
|
||||
|
||||
1. Set `NTFY_TOKEN` in `deploy/telegram.env`. One line, unblocks every reminder.
|
||||
2. Clear pending clarify state on any turn that does not answer it, or expire it.
|
||||
3. Route a clarify answer back into the pending slot instead of re-routing it.
|
||||
4. Gate the weather, feed and personal query sources. Three of them claim on a
|
||||
similarity that is not there.
|
||||
5. Re-check why "столица Франции?" routes to system. It is the documented canary.
|
||||
6. The masculine self-reference stays the CPT's job. But "сохранил" appears on the most
|
||||
common write path, so a phrasing-level guard may be worth it first.
|
||||
@@ -0,0 +1,123 @@
|
||||
# A clarify head, and a confidence that is not a hardcode
|
||||
|
||||
Measured 2026-08-08 on workpc, the same day and the same fixtures as
|
||||
`2026-08-08-routing-heads-two-head.md` and `2026-08-08-slot-head-three-head.md`.
|
||||
New script `gen_clarify.py`, new fixture `eval_fixture_clarify.jsonl`.
|
||||
|
||||
## A softmax has no clarify class
|
||||
|
||||
That sentence closed the two-head measurement. It is why the head's fixture was
|
||||
88 cases and not 96. The eight `want_clarify` cases sat outside every number
|
||||
measured, and the head had no way to produce the answer they wanted.
|
||||
|
||||
A fourth head is the answer. Clarify is not a value of intent. It is a second
|
||||
question asked of the same pooled vector: can Maven act on this at all.
|
||||
|
||||
## The corpus had one class
|
||||
|
||||
Every row in `train_heads_slots.jsonl` was generated FOR an intent or a
|
||||
destination. So every row is answerable by construction. A head trained on that
|
||||
alone sees one class and learns to say yes.
|
||||
|
||||
`gen_clarify.py` makes the other class. Five shapes, ten topics. The shapes are
|
||||
the gate's own reasons in `gateLLMDecision` plus the two the fixture carries:
|
||||
bare noun, bare verb, demonstrative, deictic time, dangling reference.
|
||||
|
||||
**The agreement filter that worked for destination cannot work here.**
|
||||
`routeGrammar` has no clarify value. So the router always names an intent, and
|
||||
any generated line always agrees with itself. The second pass is a judge
|
||||
instead. Gemma is asked, without seeing the label, whether Maven would have to
|
||||
ask a question back.
|
||||
|
||||
## The first judge was worthless and the second was measured
|
||||
|
||||
The first judge said "needs clarify" on 24 of 40 plainly answerable corpus
|
||||
rows. It flagged `запиши что я пообедал` and `Покажи расписание поездов на
|
||||
вечер`. It was judging against a generic assistant, one that asks "where?"
|
||||
about lunch. Maven writes that note.
|
||||
|
||||
Rewriting it to state what she can already do took false positives to 16 of 60.
|
||||
It also catches all eight fixture clarifies. So the judge discriminates.
|
||||
|
||||
On the generated pile it removed 7 of 306, a 97.7% keep rate. That is not the
|
||||
judge failing. The generator is aimed at underspecified lines, so there is
|
||||
little for a filter to catch. The 27% false-positive rate is the number to
|
||||
quote, and it is label noise on the positive class.
|
||||
|
||||
**Both passes are gemma-4-12b.** Generation and judging. So the corpus is
|
||||
gemma's opinion of what is underspecified, and the head distills that opinion.
|
||||
What keeps it honest is the fixture. Those eight cases were written by the owner
|
||||
and gemma never saw them.
|
||||
|
||||
299 rows kept, against 3604 answerable. The positive class carries `intent:
|
||||
null`, so it costs the intent head nothing.
|
||||
|
||||
## Result
|
||||
|
||||
Three seeds, 24 epochs, epoch still chosen on the intent dev slice.
|
||||
|
||||
| | two heads | three heads | four heads |
|
||||
|---|---|---|---|
|
||||
| intent mean | 93.6% | 92.8% | 91.7% |
|
||||
| destination mean | 80.8% | 82.8% | 79.8% |
|
||||
| slot span F1 mean | — | 72.4% | 68.3% |
|
||||
| clarify caught | — | — | 7.0 of 8 |
|
||||
| false clarifies | — | — | 2.3 of 88 |
|
||||
|
||||
**The fourth head is not free the way the third was.** Intent, destination and
|
||||
slot F1 all move down. The drop is one to four points, and the seed spread is
|
||||
wide enough to contain it. Seed 2 scores intent 94.3% and destination 84.8%, both above
|
||||
every three-head seed. Read the drop as unproven rather than as absent.
|
||||
|
||||
Accuracy is the wrong number for this head and is reported for completeness at
|
||||
95.8% to 96.9%. Eight of ninety-six cases are positive, so a head that never
|
||||
asks scores 91.7%. Recall on those eight is the number.
|
||||
|
||||
Compare it to what ships. The cascade today misses 1 clarify and produces 2
|
||||
false ones. The head catches 7 of 8 and produces 2.3 false ones. That is
|
||||
parity, from a 118M encoder with no rules in front of it.
|
||||
|
||||
The saved checkpoint is seed 2 at epoch 10. Intent 94.3%, destination 28/33,
|
||||
slot F1 73.6%, clarify 7 of 8 with 3 false. `heads.pt` carries four state dicts.
|
||||
|
||||
## What it gets wrong is consistent across seeds
|
||||
|
||||
`поужинал` is a false clarify on all three seeds. That utterance is already
|
||||
recorded as a real defect. `thinSingleToken` was narrowed on 2026-08-01 to spare
|
||||
a token carrying a Russian verb ending. One word is routinely a whole sentence
|
||||
in Russian. The head relearned the mistake the rule was narrowed to
|
||||
fix.
|
||||
|
||||
`что дальше?` is a false clarify on two seeds. That one is a disagreement rather
|
||||
than an error. The utterance is underspecified, and V-498 decided stage 0 claims
|
||||
it for the calendar on purpose.
|
||||
|
||||
`ну это` is missed on two seeds. `amb-003` is the shortest case in the fixture
|
||||
and the generated demonstratives are longer.
|
||||
|
||||
## Confidence
|
||||
|
||||
`Confidence: 1.0` was a hardcode in `llmrouter.go`, so a correct low confidence
|
||||
could not exist. Max softmax over the intent head is the replacement. It is only worth reading
|
||||
if it is lower where the head is wrong.
|
||||
|
||||
It is. Mean 0.851 where the head is right against 0.604 where it is wrong. It
|
||||
ranks a right case above a wrong one in 83.4% of pairs.
|
||||
|
||||
So there are two signals now and they are not the same signal. Confidence says
|
||||
the head is unsure which intent this is. The clarify head says the utterance
|
||||
does not carry enough to act on. A confident wrong route and an honest "I cannot
|
||||
tell" are different failures, and one number cannot report both.
|
||||
|
||||
## What this does not measure
|
||||
|
||||
The same gap as every head run. **Nothing of this runs in Go.** Four heads
|
||||
instead of three does not change that.
|
||||
|
||||
There is no threshold. Both signals are reported as raw numbers. Turning either
|
||||
into a gate needs a decision about where to cut, and that trades false clarifies
|
||||
against wrong acts. The fixture has 8 positives, which is too few to fit a
|
||||
threshold on.
|
||||
|
||||
The 299 generated rows have no held-out slice of their own. Clarify is scored on
|
||||
the fixture alone.
|
||||
@@ -0,0 +1,84 @@
|
||||
# The first destination number
|
||||
|
||||
Measured 2026-08-08 on the classifier cascade with the ONNX multilingual
|
||||
embedder, the configuration homesrv runs. `make t PKG=./internal/router/eval/
|
||||
RUN=TestONNXBaseline V=1`. Covers V-659, the follow-up V-655 named.
|
||||
|
||||
## What was measured
|
||||
|
||||
V-655 split a routing decision in two. The cascade sorts an utterance into one
|
||||
of seven intents, and `Decision.Source` then says where the answer lives. The
|
||||
first half had a fixture. The second half arrived with none, so twelve
|
||||
destinations shipped with no accuracy number.
|
||||
|
||||
`want_source` is now a field on `eval.Case`. It is a pointer, because the
|
||||
destination has three states and a bare string has two. Absent is every intent
|
||||
but query, which never reaches `queryWalk`. Present and empty is the
|
||||
`SourceUnknown` contract: name nothing and let the daemon walk the chain.
|
||||
Present and named is a destination the route must produce.
|
||||
|
||||
Thirty-three of the ninety-six cases carry one. A destination miss does not
|
||||
fail the case, so `Accuracy` and `IntentAccuracy` mean what they meant.
|
||||
`SourceAccuracy` is a second number over the labelled cases only.
|
||||
|
||||
## Result
|
||||
|
||||
Intent is **73/96 (76.0%)**, against 69/91 (75.8%) before. Four of the five new
|
||||
cases pass and no existing case moved.
|
||||
|
||||
Destination is **12/33 (36.4%)**, and the split is the whole finding.
|
||||
|
||||
| destination | scored | note |
|
||||
|---|---|---|
|
||||
| world | 5/5 | `WorldQueryGrammars` names it at stage 0 |
|
||||
| the `SourceUnknown` floor | 5/7 | the two misses lost the intent first |
|
||||
| calendar | 2/6 | `calendar-query` names it, the possessive agenda rules do not |
|
||||
| recall | 0/15 | nothing anywhere names it |
|
||||
|
||||
Recall is the number to move. Fifteen cases ask about his own words and his own
|
||||
facts. The route lands `query` on eleven of them and the destination comes back
|
||||
empty every time. Those turns are answered today, because the daemon walks the
|
||||
chain in order and the three recall passes are early in it. What is missing is a
|
||||
decider that says so, and that is the fourth head on V-546.
|
||||
|
||||
Two cases labelled the floor lost their intent before a destination was
|
||||
possible. A clarify names nothing, so it would satisfy an empty label for free.
|
||||
`Score` requires the route to land the case's intent before it credits a
|
||||
destination hit, or the floor label would score itself.
|
||||
|
||||
## Seven cases assert the floor, and five of them cluster
|
||||
|
||||
The five are homelab operations. `SourceRecall`, `SourceNetwork` and
|
||||
`SourceAttention` overlap on every question about the box, because `mavpoll`
|
||||
writes its netdata and uptime-kuma observations into the fact store recall
|
||||
reads. "почему сервер тормозит" is answerable from all three. Naming one takes
|
||||
the other two off the turn.
|
||||
|
||||
That is a finding about the enum rather than a gap in the labelling. The floor
|
||||
is the right answer there and the fixture now says so out loud.
|
||||
|
||||
All seven were written by an agent and confirmed by the owner on 08-08-2026.
|
||||
|
||||
## A drift the labelling found
|
||||
|
||||
`WorldQueryGrammars` went into `buildRouter` with V-655 and never into
|
||||
`baselineGrammars`, the fixture's mirror of it. So the fixture was scoring a
|
||||
grammar set the daemon does not run. The comment above that function forbids
|
||||
exactly that. Adding it moved the destination number from 9/33 to 12/33 and
|
||||
moved nothing else.
|
||||
|
||||
The three cases it recovered are `что такое TCP?`, `сколько будет 17 на 23?`
|
||||
and `кто такой Линус Торвальдс?`. All three already routed `query` through
|
||||
`NarrativeQueryGrammars`. So the drift was invisible to every number this
|
||||
fixture reported, until the destination had one of its own.
|
||||
|
||||
## What this does not measure
|
||||
|
||||
The model arm. This is the classifier cascade, which names a destination only
|
||||
where a stage 0 rule filled one in. The resident model has no destination in
|
||||
its router prompt yet, so 36.4% is a floor and not a comparison.
|
||||
|
||||
Two pairs of cases are the same utterance. `ru-query-020` and `ru-query-024`
|
||||
are both "что дальше?", and `ru-query-021` and `ru-query-025` are both
|
||||
"расскажи про битву при Ватерлоо". They differ in tags and note only, so both
|
||||
pairs are counted twice here and in every earlier number this fixture reported.
|
||||
@@ -0,0 +1,66 @@
|
||||
# The destination, with a model that can name one
|
||||
|
||||
Measured 2026-08-08 against gemma-4-12b on the workstation, the same 96-case
|
||||
fixture V-659 built. Covers V-660.
|
||||
|
||||
```sh
|
||||
no_proxy='*' MAVEN_LLM_URL=http://192.168.1.105:8080 \
|
||||
make t PKG=./internal/router/eval/ RUN=TestLLMRouterBaseline V=1
|
||||
```
|
||||
|
||||
## The gap was structural
|
||||
|
||||
V-659 measured the destination at 12/33 on the classifier cascade, with recall
|
||||
at 0/15. Nothing in `routeSystem` named a `Source` and `routeGrammar` could not
|
||||
emit one, so the resident model had no string to write. That is the shape V-517
|
||||
measured for Praxis reach at 0/12: not a weak model, an absent contract.
|
||||
|
||||
`routeGrammar` now carries a `source` rule closed over `router.Sources` plus the
|
||||
empty floor. The prompt lists the twelve destinations in Russian and says that
|
||||
`""` is a normal answer to give often.
|
||||
|
||||
## Result
|
||||
|
||||
| run | intent | destination |
|
||||
|---|---|---|
|
||||
| classifier + ONNX (V-659) | 73/96 (76.0%) | 12/33 (36.4%) |
|
||||
| gemma-4-12b alone | 79/96 intent-only (82.3%) | 26/33 (78.8%) |
|
||||
| cascade + gemma-4-12b + hash fallback | 81/96 (84.4%) | 24/33 (72.7%) |
|
||||
|
||||
Recall is the move: 0/15 to 14/15. Intent did not shift, which was the
|
||||
constraint. The prompt is shared, so a destination rule that costs routing
|
||||
points is not a win.
|
||||
|
||||
The eight llm-only errors are the eight `want_clarify` cases. The model returned
|
||||
`unknown` on every one, which is correct, and the llm-only harness surfaces a
|
||||
decline as an error by design.
|
||||
|
||||
## Stage 0 now costs four destination points
|
||||
|
||||
The four cases the cascade loses and the model alone wins are all calendar. The
|
||||
possessive agenda rules claim them at stage 0 and deliberately name nothing.
|
||||
"что у меня в списке покупок" matches the same rule. Naming the calendar there
|
||||
would take the list source off the turn (V-655).
|
||||
|
||||
So a rule written to be careful about the list now blocks a model that would
|
||||
have named the calendar correctly. Before V-660 that caution was free, because
|
||||
nothing downstream of stage 0 could name anything either.
|
||||
|
||||
Three ways out, and each costs something. Split the possessive rule so the
|
||||
calendar-shaped half names its destination. Let a later stage overwrite an empty
|
||||
destination a grammar left behind, which reverses "a matched value always wins".
|
||||
Or leave it, on the argument that four points is cheap next to a wrong
|
||||
destination on a shopping list. This wants the owner's call rather than a quiet
|
||||
edit.
|
||||
|
||||
## What this does not measure
|
||||
|
||||
The resident Qwen3-1.7B, which is what homesrv runs. It binds `--port 0` inside
|
||||
the container and no host process can reach it. Scoring it needs a second
|
||||
llama-server on a fixed port. The workstation is never assumed
|
||||
up, so the homesrv number is the one that decides whether this ships on by
|
||||
default.
|
||||
|
||||
The fixture is 33 labelled destinations over twelve values. Recall carries 15 of
|
||||
them and five destinations carry none at all. A per-destination number below
|
||||
world, recall, calendar and the floor is not supported by this fixture.
|
||||
@@ -0,0 +1,142 @@
|
||||
# MASSIVE Russian warm-start for the routing heads
|
||||
|
||||
Measured 2026-08-08 on workpc (Radeon RX 7900 GRE, ROCm). Covers V-546 step 2.
|
||||
Workspace is `~/Programs/embed-training` on workpc, scripts `train_massive.py`,
|
||||
`ab_run.py`, `ab.sh`, `probe_time.py`.
|
||||
|
||||
## What was trained
|
||||
|
||||
Two heads on a copy of multilingual-e5-small: `Linear(384, 60)` for MASSIVE's
|
||||
own intents over a masked mean pool, `Linear(384, 111)` per token for BIO slot
|
||||
tags. MASSIVE's label sets verbatim, no alignment to Maven's 7 intents. The
|
||||
intent head is an auxiliary loss that shapes the pooled vector and is thrown
|
||||
away.
|
||||
|
||||
Data is `amazon-massive-dataset-1.1` pulled from S3. The Hugging Face repo is
|
||||
script-only and `datasets` 5.0 refuses those, so `load_dataset` cannot fetch it.
|
||||
`ru-RU` is 11,514 train, 2,033 dev, 2,974 test, 60 intents, 55 slots, 111 BIO
|
||||
labels. All 16,521 rows survived span alignment: `annot_utt` re-tokenised to its
|
||||
own `utt` on every one.
|
||||
|
||||
Hyperparameters match `train_intent.py`, so the two runs differ in data only.
|
||||
Frozen XLM-R vocabulary, body 2e-5, heads 1e-3, batch 32, sequence 64, 10
|
||||
epochs. MASSIVE's own dev partition selects the epoch, on slot F1 with intent
|
||||
accuracy as tiebreak. Selecting on 60-class intent accuracy would optimise a
|
||||
head that gets deleted.
|
||||
|
||||
## Result
|
||||
|
||||
Epoch 9 of 10 by dev slot F1. Held-out MASSIVE test: intent 86.2%, slot span
|
||||
F1 71.5% (P 68.5, R 74.8). Peak 1.70GB of 17.2GB, about 22 seconds an epoch,
|
||||
under 4 minutes end to end. Dev slot F1 climbed monotonically to epoch 9 and
|
||||
fell at 10, so 10 epochs was the right budget.
|
||||
|
||||
Ten slot types sit at 0% test recall. Every one of them has 1 to 7 test
|
||||
instances: `alarm_type` has 3, `drink_type` has 1. That is support in MASSIVE's
|
||||
Russian split, not a tagger failure. `playlist_name` at 6% of 16 is the first
|
||||
real miss.
|
||||
|
||||
## The intent A/B, and why it settles nothing
|
||||
|
||||
`train_intent.py` was run against both bodies, three seeds by two smoothing
|
||||
settings, on `train_v4.jsonl`. It is v4 and not v5 because v4 is what
|
||||
`sweep2.log` measured. `ab_run.py` strips a `--base` flag onto the module global, so
|
||||
`train_intent.py` is unmodified and its baseline stays reproducible. The stock
|
||||
arm reproduced `sweep2.log` line for line.
|
||||
|
||||
Fixture accuracy, 91 cases, one case is 1.1 points:
|
||||
|
||||
| seed / smooth | stock | warm-started |
|
||||
|---|---|---|
|
||||
| 0 / 0.0 | 94.0% | 92.8% |
|
||||
| 0 / 0.1 | 95.2% | 92.8% |
|
||||
| 1 / 0.0 | 95.2% | 94.0% |
|
||||
| 1 / 0.1 | 95.2% | 97.6% |
|
||||
| 2 / 0.0 | 92.8% | 94.0% |
|
||||
| 2 / 0.1 | 92.8% | 96.4% |
|
||||
|
||||
Mean 94.2% against 94.6%. That is +0.4 points, about a third of one case, and
|
||||
inside seed noise. Spread widened. Stock lands in a 2.4-point band and
|
||||
warm-started in a 4.8-point one. The warm-started arm holds both the best result
|
||||
of the sweep and a tie for the worst. Seed 0 is the bad arm and it fails in a
|
||||
specific way. Its dev peaks at epoch 2 and 3 and never improves, where stock
|
||||
peaks around 7. The dev slice is a quarter of the seed rows. That is small
|
||||
enough that early stopping is fragile when the body arrives already fitted.
|
||||
|
||||
**The A/B was never the test.** Intent had at most 4.8 points of headroom here.
|
||||
MASSIVE was not trained for Maven's intents. Read it as "the warm-start does not
|
||||
cost intent accuracy", nothing more.
|
||||
|
||||
## The measurement that does mean something
|
||||
|
||||
`want_time` is the one slot Maven's fixture scores, and MASSIVE has `time` and
|
||||
`date`. Restricted to those two slot types, F1 is 74.9% over 609 gold spans on the
|
||||
MASSIVE ru test split. Precision is 71.5 and recall 78.7. That beats the 71.5%
|
||||
all-slot figure. Of the 530 test utterances carrying a time or a date, 73.4% get
|
||||
every such span exactly right.
|
||||
|
||||
Out of domain matters more, because Maven's traffic is not this corpus. Ten
|
||||
Maven-shaped utterances, none of them in MASSIVE:
|
||||
|
||||
| utterance | tagged |
|
||||
|---|---|
|
||||
| `напомни в 11:00 позвонить маме` | `time='11:00'`, `relation='маме'` |
|
||||
| `напомни завтра в семь утра выпить таблетки` | `date='завтра'`, `time='семь утра'` |
|
||||
| `поставь будильник на полседьмого` | `time='полседьмого'` |
|
||||
| `через двадцать минут напомни про чайник` | `time='двадцать минут'` |
|
||||
| `напомни в пятницу вечером забрать посылку` | `date='пятницу'`, `timeofday='вечером'` |
|
||||
| `что у меня сегодня после обеда` | `date='сегодня'`, `time='после'`, `timeofday='обеда'` |
|
||||
| `запиши что кофе закончился` | nothing |
|
||||
| `что такое TCP` | `definition_word='TCP'` |
|
||||
|
||||
The first row is the V-572 defect utterance. `ReminderGrammar` handed the daemon
|
||||
`HasTime: false` there, and the daemon asked "Когда?" at a sentence that had
|
||||
already said when. `полседьмого` is a colloquial half-past that no digit pattern
|
||||
catches. `запиши что кофе закончился` correctly carries nothing, because a note
|
||||
has no time.
|
||||
|
||||
Two errors. `после обеда` split into `time='после'` plus `timeofday='обеда'`
|
||||
when it is one span, and `через двадцать минут` dropped its `через`. Both are
|
||||
boundary errors on spans the tagger did find.
|
||||
|
||||
Unplanned: `что такое TCP` returned `definition_word='TCP'`. MASSIVE has a slot
|
||||
for the thing being asked about, which is a `SourceWorld` signal sitting in a
|
||||
head already trained.
|
||||
|
||||
Ten hand-picked utterances are evidence, not a fixture.
|
||||
|
||||
## What this does not measure
|
||||
|
||||
Maven has no span fixture. `want_time` and `want_fn` are presence booleans and
|
||||
`want_fact_key` is an exact string match, so nothing in the repo can score a
|
||||
71.5% span tagger. Destination got one the same day, at 12/33 on the classifier
|
||||
cascade: see `2026-08-08-destination-fixture.md`.
|
||||
|
||||
The missing span fixture is why the warm-start stays unjudged against Maven
|
||||
rather than against MASSIVE.
|
||||
|
||||
## Datasets ruled out
|
||||
|
||||
Checked on 2026-08-08 and rejected as label sources:
|
||||
|
||||
- **MASSIVE's other 50 locales** ship in the same tarball and are parallel by id.
|
||||
Co-training on them is free and unmeasured. English was ruled out by the owner
|
||||
on 2026-08-08.
|
||||
- **CLINC150** is reachable as parquet, 150 intents and 1,200 explicit
|
||||
out-of-scope queries, English only. Its value is the labeled out-of-scope set
|
||||
for fitting the energy threshold, not intent labels.
|
||||
- **`d0rj/dolphin-ru`**, roughly 2.8M rows of FLAN-style tasks translated to
|
||||
Russian. No intent, no slots, and not utterances anyone says to an assistant.
|
||||
- **`psytechlab/EmpatheticIntents-ru`**, 24,856 rows of translated
|
||||
EmpatheticDialogues with 32 emotion labels. Maven's mood enum is `neutral,
|
||||
happy, thinking, tired, confused` and it describes her own reply, not the
|
||||
speaker's emotion. No mapping exists.
|
||||
- **`ai-forever/MERA`** and **`RussianNLP/russian_super_glue`**, benchmark
|
||||
harnesses. Rows are prompt templates with `{toxic_comment}` placeholders.
|
||||
- **`ZeroAgency/ru-big-russian-dataset`**, an LLM-judge quality corpus. Its
|
||||
`question` and `classified_topic` columns are a usable Russian out-of-scope
|
||||
pool for threshold fitting. That is the one thing CLINC150 can only supply in
|
||||
English. The questions are long and written, so they belong in the negative
|
||||
set, never in the in-scope `query` training set.
|
||||
- No second Russian slot-filling corpus exists. The xSID mirrors are 404,
|
||||
MultiATIS++ has no Russian, SLURP is not on the Hub.
|
||||
@@ -0,0 +1,88 @@
|
||||
# The parked clarify ride, bounded and re-measured
|
||||
|
||||
Date: 2026-08-08, V-663. Same 140 turns, same driver, third and fourth runs of
|
||||
the day. Before is `d6f3914`, after is that plus two changes.
|
||||
|
||||
## What was measured before
|
||||
|
||||
One question about a reminder's day rode turns 6 to 13. It ended only because
|
||||
turn 14 was a new request. Two of those turns are the worst replies in the
|
||||
corpus:
|
||||
|
||||
```text
|
||||
спасибо -> Сейчас 21:25. В какой день?
|
||||
привет -> Сейчас 21:25. В какой день?
|
||||
```
|
||||
|
||||
V-654 had already added `MaxSuspends` and the tail count had not moved.
|
||||
|
||||
## Why three bounds let it happen
|
||||
|
||||
The TTL, `MaxAttempts` and `MaxSuspends` all exist and all were rearmed.
|
||||
|
||||
An aside spends no attempt, so `MaxAttempts` never reaches it. A turn reading as
|
||||
a failed answer zeroes `Suspends`, so `MaxSuspends` never reaches the asides.
|
||||
`спасибо` read as a failed answer. So the pleasantry both produced the reply
|
||||
above and paid for it out of the budget meant to end the ride.
|
||||
|
||||
## The two changes
|
||||
|
||||
`PendingQuestion.Rides` counts the same event as `Suspends` with the resets
|
||||
taken out. Set once, incremented only in `noteSuspended`, carried across the
|
||||
re-park in `askRemainingGap`. `MaxRides` is 4, one looser than `MaxSuspends` so
|
||||
that the tighter statement about a run stays reachable.
|
||||
|
||||
A pleasantry is an aside, not a failed answer. The set is a new closed lexicon
|
||||
entry, matched as whole utterances rather than tokens. `вечер` answers `это утра
|
||||
или вечера?` and `нет` answers a confirm. Anything that could fill a slot stays
|
||||
out.
|
||||
|
||||
## Result
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| turns carrying a clarify tail | 21 | 17 |
|
||||
| turns carrying any failure string | 31 | 29 |
|
||||
| the longest ride | 8 turns | 4 turns |
|
||||
|
||||
The turns carrying a tail, by number:
|
||||
|
||||
```text
|
||||
before 6 7 8 9 10 11 12 13 53 54 55 56 57 99 102 103 116 136 137 138 139
|
||||
after 6 7 8 9 53 54 55 56 57 99 100 101 116 136 137 138 139
|
||||
```
|
||||
|
||||
Turn 10 is the change. It now reads:
|
||||
|
||||
```text
|
||||
спасибо -> Прошлую просьбу отпускаю. Пожалуйста, я всегда готова помочь тебе.
|
||||
```
|
||||
|
||||
She lets the request go, says so, and answers the man. Turns 11 to 13 are clean.
|
||||
|
||||
**`MaxRides` is not what fired.** The pleasantry is an aside now, so it no
|
||||
longer breaks the run. `MaxSuspends` reached three on turn 10 and ended it.
|
||||
`Rides` is the backstop for the shape where an answer really does break the run.
|
||||
No turn in this corpus reaches it.
|
||||
|
||||
## What did not move
|
||||
|
||||
Four rides are untouched. Turns 53 to 57 are five consecutive asides against a
|
||||
reminder missing its day. Turn 58 is a new request that drops it. Nothing
|
||||
pleasant appears in that run, so neither change applies. Turns 99 to 101 shifted
|
||||
by one, and 116 and 136 to 139 are unchanged.
|
||||
|
||||
So the fix is worth four turns of twenty-one. What is left is asides against a
|
||||
question the owner never answers. `MaxSuspends` was written for that shape and
|
||||
does bound it, at four turns each.
|
||||
|
||||
## Not attributable
|
||||
|
||||
Latency moved p50 1.1s to 1.5s and p95 2.8s to 3.0s, and the 34.3s outlier in
|
||||
the earlier run is gone. Both runs had the workstation up. Read none of it as
|
||||
caused by this change.
|
||||
|
||||
One unrelated defect appeared in the after run and is recorded here because it
|
||||
is visible in the transcript. Turn 4 answered `Я записала твою привычкуRegarding
|
||||
coffee without sugar.` That is English leaking into a Russian reply with no
|
||||
space in front of it. It is a phrasing defect and it has no task yet.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Two heads on e5-small, and the first destination the router did not need a model for
|
||||
|
||||
Measured 2026-08-08 on workpc (Radeon RX 7900 GRE, ROCm). Covers V-661, step 3 of
|
||||
`docs/plans/18-routing-heads-on-e5-small.md`. Workspace is `~/Programs/embed-training`,
|
||||
scripts `gen_query_source.py`, `label_source.py`, `build_heads_corpus.py`,
|
||||
`train_heads.py`, `score_confidence.py`.
|
||||
|
||||
## Two heads, not four
|
||||
|
||||
Intent is 7 classes and destination is 12 plus the `SourceUnknown` floor, sharing
|
||||
one masked mean pool over one forward pass. The plan asked for four. Two of them
|
||||
have no labels and neither is a GPU problem.
|
||||
|
||||
**Mood is cut, not deferred.** Maven's enum is `neutral, happy, thinking, tired,
|
||||
confused` and it describes her own reply state, not the speaker's emotion.
|
||||
`psytechlab/EmpatheticIntents-ru` was the only candidate and its 32 emotion
|
||||
labels do not map onto it. There is nothing to train against.
|
||||
|
||||
**BIO slot tags stay in the MASSIVE body.** No Maven-domain span corpus exists.
|
||||
`2026-08-08-massive-warm-start.md` records that no second Russian slot-filling
|
||||
corpus is reachable at all.
|
||||
|
||||
The destination loss is masked with `ignore_index`. Only a query turn reaches
|
||||
`queryWalk`, so a reminder contributes nothing to it.
|
||||
|
||||
## Where the destination labels came from
|
||||
|
||||
V-660 taught the router prompt to name a destination. That made gemma-4-12b a
|
||||
teacher, and this distils it.
|
||||
|
||||
Labelling the 300 query rows already in `train_v5.jsonl` gave 277 destinations.
|
||||
The shape was unusable: the floor 101, calendar 62, recall 47, and `feeds` and
|
||||
`attention` at zero. A 13-way softmax cannot learn a class with no examples.
|
||||
|
||||
`gen_query_source.py` is the destination half of `gen_corpus.py` and runs the
|
||||
same two passes. Gemma writes questions whose answer lives in one named place.
|
||||
The daemon's own `routeSystem` prompt then routes each one back. A line survives
|
||||
only when the intent is `query` **and** the source is the destination it was
|
||||
generated for. The glosses are copied verbatim out of `route_system.txt`, so the
|
||||
generator and the labeller work from one definition.
|
||||
|
||||
The prompt and the GBNF are dumped from `internal/router/llmrouter.go` by
|
||||
`TestDumpPrompt`, never retyped. The workspace held its own copies and V-660
|
||||
changed both.
|
||||
|
||||
1229 kept of 2373 generated, 51.8%. Merged corpus is 3664 rows carrying 1727
|
||||
destinations:
|
||||
|
||||
| | rows | | rows |
|
||||
|---|---|---|---|
|
||||
| the floor | 220 | world | 132 |
|
||||
| calendar | 180 | money | 126 |
|
||||
| tasks | 169 | list | 124 |
|
||||
| recall | 167 | self, feeds, attention | 120 each |
|
||||
| weather | 136 | network | 103 |
|
||||
| | | home | 50 |
|
||||
|
||||
`home` is thin because the agreement filter rejected most of what was generated
|
||||
for it. A question about the house routes `act` more often than `query`. That is
|
||||
the filter working, and 50 is the finding rather than a shortfall.
|
||||
|
||||
**The floor was regenerated once.** The first 120 rows carried one sentence
|
||||
shape across eight topics. That shape was "что там с X" and its two synonyms.
|
||||
Every named destination varied and only the floor collapsed. The reason is that
|
||||
the generator varies a topic, and ambiguity is not a topic.
|
||||
|
||||
`gen_query_source.py` now rotates six floor shapes. A `почему` question, a yes
|
||||
or no question, and a question carried by intonation alone. Then a
|
||||
better-or-worse question, a status question, and an existence question. That is
|
||||
a fix to degenerate generation. It is not fitting to the fixture, whose floor
|
||||
cases are homelab operations and match none of the six.
|
||||
|
||||
The 1229 generated rows carry `intent: null`. Every one is a query by
|
||||
construction. There are five times as many as the corpus has query rows, so
|
||||
including them would make query half the intent corpus.
|
||||
|
||||
## Result
|
||||
|
||||
Three seeds, two bodies, epoch chosen on the intent dev slice and never on a
|
||||
destination number.
|
||||
|
||||
| body | floor corpus | intent mean | destination mean |
|
||||
|---|---|---|---|
|
||||
| warm-started `out/body_massive` | one shape | 93.6% | 75.8% |
|
||||
| stock `multilingual-e5-small` | one shape | 93.6% | 76.8% |
|
||||
| warm-started `out/body_massive` | six shapes | 93.6% | **80.8%** |
|
||||
|
||||
Best single run is destination **29/33 (87.9%)**, seed 0 on the rotated floor.
|
||||
Peak 1.68GB of 17.2GB, under four minutes end to end.
|
||||
|
||||
Against the two arms already measured on the same 33 labelled cases:
|
||||
|
||||
| | destination |
|
||||
|---|---|
|
||||
| classifier cascade (V-659) | 12/33 (36.4%) |
|
||||
| cascade + gemma-4-12b (V-660) | 24/33 (72.7%) |
|
||||
| two heads on e5-small | 29/33 (87.9%) |
|
||||
|
||||
A 118M encoder beats the 12B teacher it was distilled from, on the fixture. The
|
||||
per-destination split is where it happens: **recall 15/15** and **world 5/5**.
|
||||
Recall was 0/15 on the cascade and 14/15 through gemma.
|
||||
|
||||
Read 87.9% as one seed of a mean of 80.8%, not as a headline. Three seeds score
|
||||
29, 25 and 26 of 33. One case is 3 points on a fixture this small.
|
||||
|
||||
Intent is **not** comparable to the 73/96 and 81/96 figures those two arms
|
||||
scored. A softmax has no clarify class. The head's fixture is the 88 cases that
|
||||
carry an intent, and the 8 `want_clarify` cases are scored separately below.
|
||||
|
||||
## The MASSIVE warm-start is worth nothing here either
|
||||
|
||||
Step 2 measured it at +0.4 points of intent accuracy and called that inside seed
|
||||
noise. Destination was the open question, because MASSIVE has a
|
||||
`definition_word` slot that looked like a `SourceWorld` signal sitting in a head
|
||||
already trained.
|
||||
|
||||
It is not. The two bodies score the same intent mean to one decimal. Stock is
|
||||
one point ahead on destination, which is a third of one case. Nothing here argues
|
||||
for keeping the warm-start step. Dropping it removes a dependency on a corpus
|
||||
pull that `datasets` 5.0 cannot do.
|
||||
|
||||
## The floor moved, calendar did not
|
||||
|
||||
Before the rotation, all seven misses at seed 0 were the floor and calendar. The
|
||||
head named a destination where the fixture says walk the chain, and it was
|
||||
confident doing it. `"почему сервер тормозит"` read `world` at 0.80.
|
||||
`"хватает ли места под новые бэкапы"` read `network` at 0.82. Those are the five
|
||||
homelab cases V-659 flagged, where `SourceRecall`, `SourceNetwork` and
|
||||
`SourceAttention` all overlap. `mavpoll` writes its observations into the fact
|
||||
store recall reads.
|
||||
|
||||
| | one shape | six shapes |
|
||||
|---|---|---|
|
||||
| the floor | 3/7 | 6/7, 6/7, 5/7 |
|
||||
| calendar | 3/6 | 3/6, 3/6, 3/6 |
|
||||
| recall | 15/15 | 15/15 at seed 0 |
|
||||
| world | 5/5 | 5/5 |
|
||||
|
||||
The floor was a corpus defect and it cost 3 cases. Sentence variety carried it,
|
||||
not homelab vocabulary, which the training rows still do not contain.
|
||||
|
||||
**Calendar is 3/6 at every seed and is a different problem.** It is the shape
|
||||
V-660 named. The possessive agenda rules claim those cases at stage 0 and
|
||||
deliberately name nothing, so no destination label reaches the head. Training
|
||||
cannot move a case the head never sees. That one wants the owner's call.
|
||||
|
||||
## Max softmax separates, weakly, and the gate stays
|
||||
|
||||
The plan argues max softmax is a calibratable confidence where `Confidence: 1.0`
|
||||
was a hardcode. Measured on the intent head:
|
||||
|
||||
| | n | mean confidence |
|
||||
|---|---|---|
|
||||
| correct | 80 | 0.897 |
|
||||
| wrong | 8 | 0.705 |
|
||||
| `want_clarify` | 8 | 0.685 |
|
||||
|
||||
The softest correct answer is 0.66 and 3 of 8 clarify cases sit below it. So a
|
||||
single cut buys three clarifies at no false-clarify cost, and no more.
|
||||
|
||||
The other five explain themselves. `"напомни"` scores 0.94 as `reminder` and
|
||||
`"сделай это"` scores 0.80 as `act`. Both are intent-certain and slot-empty, and
|
||||
confidence was never the signal there. `gateLLMDecision` already catches exactly
|
||||
that shape, an act with no allowlisted fn or a keyless fact, and it keeps doing
|
||||
so. The head replaces the hardcode. It does not replace the gate.
|
||||
|
||||
## An incident worth recording
|
||||
|
||||
The first generation run produced zero rows for eight destinations. `mavgpud`
|
||||
yields the card when another process wants it (V-488) and llama-server answers
|
||||
503 until the model is back. Every generate call inside that window burned one of
|
||||
the destination's batches. The run walked its own cap without a single successful
|
||||
call. The log said `503` 260 times, and the summary line said 64.1% keep rate,
|
||||
which read as success.
|
||||
|
||||
`call()` now retries a 503 with backoff. A generator that treats an unloaded
|
||||
model as a bad generation is a silent-corpus bug, not a slow one.
|
||||
|
||||
## What this does not measure
|
||||
|
||||
**Nothing here runs in Go.** The heads are a `heads.pt` and an
|
||||
`out/body_heads/` on workpc. Reaching the daemon needs an ONNX export and a
|
||||
caller. The resident e5-small must not be replaced by this copy: recall depends
|
||||
on that file, and `EmbedQuery`/`EmbedPassage` are its contract.
|
||||
|
||||
The destination fixture carries 4 of 13 classes: recall 15, floor 7, calendar 6,
|
||||
world 5. `tasks`, `money`, `list`, `home`, `network`, `feeds`, `attention` and
|
||||
`self` have no gold case. So 78.8% is silent on eight destinations that together
|
||||
hold 800 training rows.
|
||||
|
||||
Latency was not measured. A forward pass of a 118M encoder should beat a 1.7B
|
||||
decoder on a query turn. That is arithmetic, not a number from this box.
|
||||
@@ -0,0 +1,106 @@
|
||||
# A slot head, and the corpus that did not exist this morning
|
||||
|
||||
Measured 2026-08-08 on workpc, the same day as `2026-08-08-routing-heads-two-head.md`
|
||||
and against the same fixtures. Workspace is `~/Programs/embed-training`, new
|
||||
scripts `slot_grammar.gbnf`, `slot_system.txt`, `label_slots.py`, `merge_slots.py`.
|
||||
|
||||
## The corpus was the whole problem
|
||||
|
||||
The two-head measurement said BIO slot tags stay in the MASSIVE body, because no
|
||||
Maven-domain span corpus exists. That was true of found corpora and false of
|
||||
made ones. Destination had the same shape at breakfast. V-660 gave gemma-4-12b a
|
||||
string to write and the label problem became a generation problem.
|
||||
|
||||
The same trick applies to spans. `slot_grammar.gbnf` emits a list of
|
||||
`{"slot": ..., "text": ...}` and the enum closes over Maven's own five: `time`,
|
||||
`text`, `key`, `value`, `fn`. `slot_system.txt` demands each span be an exact
|
||||
substring of the utterance.
|
||||
|
||||
**The agreement filter is free here.** Destination needed a second pass. The
|
||||
daemon's own router prompt had to route each generated line back. A span needs
|
||||
no second call. It either occurs in the utterance or it does not, and
|
||||
`label_slots.py` drops it with `find()`.
|
||||
|
||||
1702 rows labelled from `train_v5.jsonl`, the reminder, fact, note, act and
|
||||
query intents. Chat and system carry no slot and were never asked.
|
||||
|
||||
| slot | spans |
|
||||
|---|---|
|
||||
| text | 1175 |
|
||||
| time | 485 |
|
||||
| fn | 381 |
|
||||
| key | 72 |
|
||||
| value | 65 |
|
||||
|
||||
37 spans dropped as not-a-substring, 2.2% of the pile. Nothing failed to parse,
|
||||
which is the grammar doing its job. 409 rows came back with no span at all.
|
||||
Those are kept and tagged all `O`. An utterance carrying no slot teaches the
|
||||
head not to invent one. An empty list is a label and not a miss.
|
||||
|
||||
`key` and `value` are thin because they come from facts alone. That is the
|
||||
shape of the corpus, not a labeller failure.
|
||||
|
||||
## Three heads on one forward pass
|
||||
|
||||
Intent and destination were already two linear heads over one masked mean pool.
|
||||
Slots is a third head over the per-token states of the same pass, so the marginal
|
||||
cost is one `Linear(384, 11)`.
|
||||
|
||||
The tag set is `O` plus `B-` and `I-` for each of the five. A softmax cannot
|
||||
emit a tag that does not exist. That is the structural guarantee the GBNF buys
|
||||
for the teacher, and the head gets it for free.
|
||||
|
||||
Two masking rules, both `ignore_index`. A row with no `spans` key contributes
|
||||
nothing, which covers the 1900 generated destination rows and every chat and
|
||||
system turn. A padding or special-token position contributes nothing either.
|
||||
|
||||
Scoring is exact-match span F1, not token accuracy. Most tokens are `O`, so a
|
||||
tagger that predicts nothing anywhere scores above 90% on tokens.
|
||||
|
||||
## Result
|
||||
|
||||
Three seeds, 24 epochs, epoch chosen on the intent dev slice alone.
|
||||
|
||||
| | two heads | three heads |
|
||||
|---|---|---|
|
||||
| intent mean | 93.6% | 92.8% |
|
||||
| destination mean | 80.8% | 82.8% |
|
||||
| destination best | 29/33 (87.9%) | 29/33 (87.9%) |
|
||||
| slot span F1 mean | — | 72.4% |
|
||||
|
||||
**The slot head costs nothing and adds a third decision.** Intent moves 0.8
|
||||
points down and destination 2 points up. Both sit inside the seed spread those
|
||||
two numbers already had. Read this as unchanged, not as a trade.
|
||||
|
||||
The saved checkpoint is seed 1 at epoch 10: intent 93.2%, destination 81.8%,
|
||||
slot F1 75.8%. `heads.pt` now carries three state dicts and the `bio` list
|
||||
beside the intent and source enums.
|
||||
|
||||
## Epoch selection is now wrong for one of the three heads
|
||||
|
||||
Slot F1 was still climbing when the intent-selected epoch stopped it. Seed 0
|
||||
selects epoch 13 at 70.9% and reaches 76.1% at epoch 20. Seed 1 selects epoch 10
|
||||
at 75.8% and reaches 80.0% at epoch 24.
|
||||
|
||||
So the three tasks want different epochs and the harness picks one. Two ways
|
||||
out, and neither was taken here. Select on a joint score, which needs an
|
||||
argument about weights. Or give the slot head its own dev slice and its own
|
||||
early stop, which means the heads stop being one checkpoint.
|
||||
|
||||
Leaving it costs about 4 points of slot F1 and nothing else. The router
|
||||
contract is graded on intent. Selecting on it is the conservative choice rather
|
||||
than an oversight.
|
||||
|
||||
## What this does not measure
|
||||
|
||||
The same gap as the two-head run. **Nothing of this runs in Go.** Three heads
|
||||
instead of two does not change that. It does change the export. The slot head
|
||||
reads the per-token states, so an ONNX graph exposing only the pooled vector is
|
||||
not enough.
|
||||
|
||||
There is no held-out span fixture. Span F1 is measured on the dev slice of the
|
||||
same distilled corpus, so it scores agreement with gemma rather than
|
||||
correctness. The V-572 defect utterance, `"напомни в 11:00 позвонить маме"`, is
|
||||
labelled correctly by the teacher, but one utterance is not a fixture.
|
||||
|
||||
`key` at 72 spans and `value` at 65 are too thin to report a per-slot number.
|
||||
@@ -0,0 +1,631 @@
|
||||
# Raw transcript, two weeks of usage
|
||||
|
||||
## Day 1
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Я не говорю так сейчас, ведь на улице уже вечер.
|
||||
[2.8s t=218]
|
||||
|
||||
YOU: какой сегодня день?
|
||||
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||
[0.0s t=219]
|
||||
|
||||
YOU: сколько времени?
|
||||
MAVEN: сейчас 21 час 9 минут
|
||||
[0.0s t=220]
|
||||
|
||||
YOU: запиши что я пью кофе без сахара
|
||||
MAVEN: Я записала твою заметку про кофе。
|
||||
[1.5s t=221]
|
||||
|
||||
YOU: мой любимый язык программирования go
|
||||
MAVEN: Я записала твою заметку про Go.
|
||||
[1.5s t=222]
|
||||
|
||||
YOU: напомни в 11:00 позвонить маме
|
||||
MAVEN: Сейчас 21:10. В какой день?
|
||||
[0.4s t=223]
|
||||
|
||||
YOU: что у меня сегодня?
|
||||
MAVEN: на 08.08.2026 ничего нет. На какое время поставить напоминание?
|
||||
[2.5s t=224]
|
||||
|
||||
YOU: что такое TCP?
|
||||
MAVEN: вот что я нашла: TCP (Transmission Control Protocol) — это транспортный протокол для передачи данных. Он обеспечивает доставку всех пакетов и позволяет собрать их в правильном порядке. Также TCP является частью сетевой модели TCP/IP, которая описывает способ передачи цифровых данных от источника к получателю. На какое время поставить напоминание?
|
||||
[2.8s t=225]
|
||||
|
||||
YOU: сколько будет 17 на 23?
|
||||
MAVEN: вот что я нашла: в источниках нет результата умножения 17 на 23. На какое время поставить напоминание?
|
||||
[1.6s t=226]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Сейчас 21:10. В какой день?
|
||||
[1.1s t=227]
|
||||
|
||||
```
|
||||
|
||||
## Day 2
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Сейчас 21:10. В какой день?
|
||||
[0.9s t=228]
|
||||
|
||||
YOU: что нового?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи. На какое время поставить напоминание?
|
||||
[1.3s t=229]
|
||||
|
||||
YOU: какая погода?
|
||||
MAVEN: для какого города? На какое время поставить напоминание?
|
||||
[0.7s t=230]
|
||||
|
||||
YOU: запиши что пароль от вайфая лежит в ящике стола
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала, что пароль от вай-фай лежит в ящике стола.
|
||||
[1.5s t=231]
|
||||
|
||||
YOU: где лежит вайфай пароль?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.5s t=232]
|
||||
|
||||
YOU: добавь молоко в список покупок
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[1.0s t=233]
|
||||
|
||||
YOU: что у меня в списке покупок?
|
||||
MAVEN: в списке покупок пусто.
|
||||
[0.0s t=234]
|
||||
|
||||
YOU: кто такой Линус Торвальдс?
|
||||
MAVEN: вот что я нашла: Линус Торвальдс — финско-американский программист из Хельсинки, создатель ядра Linux. Он родился 28 декабря 1969 года и окончил факультет компьютерных наук Хельсинкского университета.
|
||||
[2.6s t=235]
|
||||
|
||||
YOU: какой у меня любимый язык?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.6s t=236]
|
||||
|
||||
YOU: сколько у меня задач?
|
||||
MAVEN: сначала: проверить интейк форму на боксе (завтра); купить хлеб; купить молоко; полить цветы; починить кран
|
||||
[0.0s t=237]
|
||||
|
||||
```
|
||||
|
||||
## Day 3
|
||||
|
||||
```
|
||||
YOU: как дела?
|
||||
MAVEN: У меня всё хорошо, я готова помогать тебе. Чем могу быть полезна?
|
||||
[1.2s t=238]
|
||||
|
||||
YOU: напомни завтра в 9 утра купить хлеб
|
||||
MAVEN: хорошо, напомню завтра в 09:00.
|
||||
[0.3s t=239]
|
||||
|
||||
YOU: что у меня завтра?
|
||||
MAVEN: на 09.08.2026 ничего нет.
|
||||
[0.0s t=240]
|
||||
|
||||
YOU: отмени напоминание про хлеб
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.5s t=241]
|
||||
|
||||
YOU: какие у меня напоминания?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоих напоминаниях.
|
||||
[0.7s t=242]
|
||||
|
||||
YOU: сохрани мне адрес гостиницы в Сочи
|
||||
MAVEN: Я записала твой адрес отеля в Сочи.
|
||||
[2.2s t=243]
|
||||
|
||||
YOU: что я сохранил про Сочи?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, что ты сохранил про Сочи.
|
||||
[1.5s t=244]
|
||||
|
||||
YOU: почему сервер тормозит?
|
||||
MAVEN: вот что я нашла: сервер может тормозить из-за неудачной конфигурации или неоптимальных настроек, отсутствия кэширования, неоптимизированного программного обеспечения, которое нужно обновлять, а также из-за режима энергосбережения процессора, когда он не успевает повысить частоту при нагрузке.
|
||||
[3.7s t=245]
|
||||
|
||||
YOU: хватает ли места под новые бэкапы?
|
||||
MAVEN: нового вот что: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||
[0.7s t=246]
|
||||
|
||||
YOU: выключи свет в спальне
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.4s t=247]
|
||||
|
||||
```
|
||||
|
||||
## Day 4
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Сейчас уже вечер, поэтому я не могу сказать тебе «доброе утро».
|
||||
[1.4s t=248]
|
||||
|
||||
YOU: что я пропустил?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, что ты пропустил.
|
||||
[1.3s t=249]
|
||||
|
||||
YOU: о чём мы вчера говорили?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.0s t=250]
|
||||
|
||||
YOU: запиши что я записался к врачу на четверг
|
||||
MAVEN: Я записала твою запись к врачу.
|
||||
[1.2s t=251]
|
||||
|
||||
YOU: когда я иду к врачу?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, когда именно ты идешь к врачу.
|
||||
[2.1s t=252]
|
||||
|
||||
YOU: что такое ZFS?
|
||||
MAVEN: вот что я нашла: ZFS — это современная и мощная файловая система, в которой сочетаются безопасность, масштабируемость и удобство управления данными. Она была разработана корпорацией Sun Microsystems и объединяет в себе файловую систему с менеджером томов. Изначально она создавалась для того, чтобы преодолеть недостатки традиционных RAID-массивов, такие как проблема «дыры записи».
|
||||
[2.1s t=253]
|
||||
|
||||
YOU: столица Франции?
|
||||
MAVEN: вот что я нашла: столица Франции — Париж.
|
||||
[3.7s t=254]
|
||||
|
||||
YOU: переведи слово ремонт на английский
|
||||
MAVEN: вот что я нашла: слово «ремонт» на английский можно перевести как repair (например, ремонт дороги или крыши) или fix (общее слово для починки техники и мебели).
|
||||
[2.7s t=255]
|
||||
|
||||
YOU: сколько я потратил в этом месяце?
|
||||
MAVEN: я не отслеживаю траты — не подключено.
|
||||
[0.7s t=256]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: Спокойной ночи. Я уже подготовила всё к твоему отдыху.
|
||||
[1.2s t=257]
|
||||
|
||||
```
|
||||
|
||||
## Day 5
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет! Я готова помогать тебе сегодня.
|
||||
[1.0s t=258]
|
||||
|
||||
YOU: какая погода в Москве?
|
||||
MAVEN: погода не настроена.
|
||||
[0.9s t=259]
|
||||
|
||||
YOU: что там с бэкапами?
|
||||
MAVEN: вот что я нашла: бэкап — это резервная копия данных, созданная в определенный момент времени и сохраненная на локальном или облачном носителе. Его делают, чтобы быстро восстановить информацию в случае необходимости.
|
||||
[2.3s t=260]
|
||||
|
||||
YOU: покажи что требует внимания
|
||||
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||
[0.5s t=261]
|
||||
|
||||
YOU: отметь это как сделанное
|
||||
MAVEN: команды «отметь» пока нет в списке — она уже предложена, включи через клиент.
|
||||
[0.0s t=262]
|
||||
|
||||
YOU: запиши что я купил новые наушники
|
||||
MAVEN: Я записала твою покупку новых наушников.
|
||||
[1.8s t=263]
|
||||
|
||||
YOU: какие у меня заметки за неделю?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.0s t=264]
|
||||
|
||||
YOU: расскажи про Kubernetes
|
||||
MAVEN: вот что я нашла: Kubernetes — это портативная расширяемая платформа с открытым исходным кодом для управления сервисами и контейнеризованными рабочими нагрузками. Управлять им можно через интерфейс в командной строке kubectl. Я нашла эти данные в предоставленных источниках.
|
||||
[1.8s t=265]
|
||||
|
||||
YOU: кто я?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, кто ты.
|
||||
[1.4s t=266]
|
||||
|
||||
YOU: пока
|
||||
MAVEN: До свидания, пока!
|
||||
[1.4s t=267]
|
||||
|
||||
```
|
||||
|
||||
## Day 6
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Сейчас уже вечер, так что доброе утро я тебе сказать не могу. Я готова помочь тебе с чем-нибудь другим.
|
||||
[1.4s t=268]
|
||||
|
||||
YOU: сколько времени?
|
||||
MAVEN: сейчас 21 час 11 минут
|
||||
[0.0s t=269]
|
||||
|
||||
YOU: напомни в 18:30 позвонить в банк
|
||||
MAVEN: Сейчас 21:11. В какой день?
|
||||
[0.3s t=270]
|
||||
|
||||
YOU: поставь чайник
|
||||
MAVEN: Сейчас 21:11. В какой день?
|
||||
[1.1s t=271]
|
||||
|
||||
YOU: включи музыку
|
||||
MAVEN: Сейчас 21:11. В какой день?
|
||||
[0.9s t=272]
|
||||
|
||||
YOU: что у меня в календаре на пятницу?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоих личных событиях в календаре на пятницу. В одном из текстов упоминается пятница 24 июля 2026 года, а в другом — что сегодня пятница 7 августа 2026 года. На какое время поставить напоминание?
|
||||
[2.6s t=273]
|
||||
|
||||
YOU: во сколько у меня встреча?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи. На какое время поставить напоминание?
|
||||
[0.3s t=274]
|
||||
|
||||
YOU: запиши что дедлайн по проекту в понедельник
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала твой дедлайн на понедельник.
|
||||
[1.7s t=275]
|
||||
|
||||
YOU: успею ли я до дедлайна?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоем дедлайне, поэтому я не могу ответить, успеешь ты или нет.
|
||||
[2.0s t=276]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Пожалуйста, я всегда готова помочь тебе.
|
||||
[1.4s t=277]
|
||||
|
||||
```
|
||||
|
||||
## Day 7
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет! Чем я могу тебе помочь?
|
||||
[1.3s t=278]
|
||||
|
||||
YOU: как ты?
|
||||
MAVEN: Я в порядке, спасибо, что спросил. Готова помогать тебе!
|
||||
[1.4s t=279]
|
||||
|
||||
YOU: расскажи анекдот
|
||||
MAVEN: вот что я нашла: в источниках есть ссылки на сборники и сайты с анекдотами, но самих текстов анекдотов там нет, кроме одного: ударилась лягушка о землю головой и стала девицей прекрасной, а второй раз — месивом кровавым.
|
||||
[3.9s t=280]
|
||||
|
||||
YOU: что ты умеешь?
|
||||
MAVEN: Я Мэйвен, твоя помощница. Я живу на твоём сервере и отвечаю на твои вопросы. Я запоминаю то, что ты мне говоришь, веду заметки, ставлю напоминания, а также читаю твой календарь и задачи. Я отвечаю на вопросы о мире, используя поиск или офлайн-энциклопедию. Если ты разрешишь, я могу управлять домом, локальной сетью, лентами, списком покупок и погодой, а также писать в телеграм. Я говорю по-русски и по-английски.
|
||||
[2.9s t=281]
|
||||
|
||||
YOU: запиши что я начал бегать по утрам
|
||||
MAVEN: Я записала твою новую привычку
|
||||
[1.5s t=282]
|
||||
|
||||
YOU: я бегаю по утрам уже неделю
|
||||
MAVEN: записала: я бегаю по утрам уже неделю
|
||||
[0.6s t=283]
|
||||
|
||||
YOU: как часто я бегаю?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, как часто бегаешь ты. Там указано, что для похудения частоту тренировок можно доводить до 5–6 раз в неделю, а новичкам ежедневные тренировки без подготовки могут привести к травмам.
|
||||
[3.3s t=284]
|
||||
|
||||
YOU: сколько стоит биткоин?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о стоимости биткоина.
|
||||
[1.2s t=285]
|
||||
|
||||
YOU: какие новости?
|
||||
MAVEN: вот что нового: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||
[0.8s t=286]
|
||||
|
||||
YOU: хорошего дня
|
||||
MAVEN: Я не могу сказать «хорошего дня», так как сейчас уже вечер. Хорошего отдыха!
|
||||
[1.3s t=287]
|
||||
|
||||
```
|
||||
|
||||
## Day 8
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Сейчас вечер, так что доброе утро я не скажу. Чем я могу тебе помочь?
|
||||
[1.2s t=288]
|
||||
|
||||
YOU: что у меня сегодня?
|
||||
MAVEN: на 08.08.2026 ничего нет.
|
||||
[0.0s t=289]
|
||||
|
||||
YOU: напомни через час выпить воды
|
||||
MAVEN: хорошо, напомню сегодня в 22:11.
|
||||
[0.3s t=290]
|
||||
|
||||
YOU: я выпил воды
|
||||
MAVEN: записала: я выпил воды
|
||||
[0.9s t=291]
|
||||
|
||||
YOU: запиши что кот ест только сухой корм
|
||||
MAVEN: Я записала твою заметку про кота.
|
||||
[1.2s t=292]
|
||||
|
||||
YOU: чем питается кот?
|
||||
MAVEN: вот что я нашла: в природе кошки едят мышей, птиц и ящериц. Дома их правильно кормить мясом, мясными субпродуктами, яйцами и нежирной морской рыбой. Из-за особенностей пищеварения им не подходят каши, макароны и мучные изделия.
|
||||
[3.4s t=293]
|
||||
|
||||
YOU: что такое DNS?
|
||||
MAVEN: вот что я нашла: DNS (Domain Name System) — это распределенная база данных, которая сопоставляет домены с IP-адресами. Это не единая база на одном сервере, а иерархическая система серверов, работающих совместно. В ней хранятся записи, которые обеспечивают маршрутизацию трафика и управление электронной почтой.
|
||||
[2.0s t=294]
|
||||
|
||||
YOU: проверь статус uptime kuma
|
||||
MAVEN: uptime kuma — Проверяю статус Uptime Kuma...
|
||||
[1.1s t=295]
|
||||
|
||||
YOU: всё ли в порядке с сервером?
|
||||
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||
[0.5s t=296]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Пожалуйста, я всегда рада помочь тебе.
|
||||
[2.7s t=297]
|
||||
|
||||
```
|
||||
|
||||
## Day 9
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет! Чем я могу тебе помочь сегодня?
|
||||
[1.9s t=298]
|
||||
|
||||
YOU: какой сегодня день недели?
|
||||
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||
[0.0s t=299]
|
||||
|
||||
YOU: добавь хлеб и сыр в список покупок
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.6s t=300]
|
||||
|
||||
YOU: что в списке покупок?
|
||||
MAVEN: в списке покупок пусто.
|
||||
[0.0s t=301]
|
||||
|
||||
YOU: удали молоко из списка
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.4s t=302]
|
||||
|
||||
YOU: напомни завтра утром вынести мусор
|
||||
MAVEN: Сейчас 21:11. Когда?
|
||||
[0.3s t=303]
|
||||
|
||||
YOU: запиши что я поменял масло в машине
|
||||
MAVEN: Прошлую просьбу отпускаю. записала: запиши что я поменял масло в машине
|
||||
[0.7s t=304]
|
||||
|
||||
YOU: когда я менял масло?
|
||||
MAVEN: масло в машине — поменял
|
||||
[0.4s t=305]
|
||||
|
||||
YOU: сколько будет 144 делить на 12?
|
||||
MAVEN: вот что я нашла: в источниках указано решение деления 144 на 12 в столбик, но итоговый результат деления не прописан.
|
||||
[2.3s t=306]
|
||||
|
||||
YOU: пока
|
||||
MAVEN: До свидания! Если понадобится что-то записать или проверить, я всегда на связи.
|
||||
[1.2s t=307]
|
||||
|
||||
```
|
||||
|
||||
## Day 10
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Я не могу сказать «доброе утро», так как сейчас уже вечер. Я записала твои предыдущие вопросы.
|
||||
[1.8s t=308]
|
||||
|
||||
YOU: что нового за ночь?
|
||||
MAVEN: вот что я нашла: в ночь на 8 августа вооруженные силы РФ нанесли второй за последнее время массированный удар, при этом киевская ПВО пропустила все российские ракеты.
|
||||
[3.3s t=309]
|
||||
|
||||
YOU: почему интернет медленный?
|
||||
MAVEN: вот что я нашла: интернет может работать медленно из-за плохого сигнала из-за удаленности вышки, исчерпанного лимита трафика по тарифу, нахождения вне зоны покрытия 4G/LTE, устаревшей или неисправной SIM-карты, а также из-за сбоя в настройках роутера. Чтобы исправить ситуацию, можно попробовать перезагрузить роутер или перенастроить устройство.
|
||||
[3.0s t=310]
|
||||
|
||||
YOU: какая скорость у меня сейчас?
|
||||
MAVEN: для какого города?
|
||||
[0.6s t=311]
|
||||
|
||||
YOU: запиши что новый роутер стоит 8000 рублей
|
||||
MAVEN: Я записала стоимость нового роутера.
|
||||
[1.2s t=312]
|
||||
|
||||
YOU: сколько стоил роутер?
|
||||
MAVEN: вот что я нашла: цена зависит от модели. Роутер от Ростелеком стоит 5 900 рублей, на OZON есть варианты от 4 052 до 3 251 рублей, а в Билайне цены начинаются от 2 149 рублей.
|
||||
[2.6s t=313]
|
||||
|
||||
YOU: что такое NAT?
|
||||
MAVEN: вот что я нашла: NAT (Network Address Translation) — это технология в сетях TCP/IP, которая позволяет изменять IP-адрес в заголовке пакета при его прохождении через устройство. Она перенаправляет трафик между локальной и глобальной сетями, позволяет объединять устройства в локальной сети под одним публичным IP-адресом, а также скрывает сеть и экономит IP.
|
||||
[2.1s t=314]
|
||||
|
||||
YOU: напомни в субботу позвонить бабушке
|
||||
MAVEN: Сейчас 21:12. Когда?
|
||||
[0.3s t=315]
|
||||
|
||||
YOU: покажи мои напоминания
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоих конкретных напоминаниях, там только описано, как их создавать и где искать в приложениях. На какое время поставить напоминание?
|
||||
[3.0s t=316]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Сейчас 21:12. Во сколько напомнить?
|
||||
[1.0s t=317]
|
||||
|
||||
```
|
||||
|
||||
## Day 11
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Сейчас 21:12. Скажи время — например, «в семь вечера» или «через час».
|
||||
[0.8s t=318]
|
||||
|
||||
YOU: как погода на выходных?
|
||||
MAVEN: для какого города? На какое время поставить напоминание?
|
||||
[0.7s t=319]
|
||||
|
||||
YOU: что у меня на этой неделе?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, что у тебя на этой неделе. На какое время поставить напоминание?
|
||||
[1.0s t=320]
|
||||
|
||||
YOU: запиши что я хочу прочитать книгу про Go
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала твою заметку о книге по Go.
|
||||
[1.6s t=321]
|
||||
|
||||
YOU: что я хотел прочитать?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.9s t=322]
|
||||
|
||||
YOU: объясни что такое горутина
|
||||
MAVEN: вот что я нашла: я проверила источники и узнала, что горутины — это легковесные потоки выполнения или функции, которые позволяют параллельно выполнять код внутри программы. Они являются легкими нитями, которые создаются и планируются для независимого выполнения планировщиком Go.
|
||||
[2.5s t=323]
|
||||
|
||||
YOU: кто написал Войну и мир?
|
||||
MAVEN: вот что я нашла: роман «Война и мир» написал Лев Николаевич Толстой.
|
||||
[2.2s t=324]
|
||||
|
||||
YOU: включи свет на кухне
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.5s t=325]
|
||||
|
||||
YOU: закрой шторы в комнате
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.4s t=326]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: Спокойной ночи! Я уже подготовила всё к твоему отдыху.
|
||||
[1.1s t=327]
|
||||
|
||||
```
|
||||
|
||||
## Day 12
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Сейчас уже вечер, поэтому я не могу сказать тебе «доброе утро».
|
||||
[1.1s t=328]
|
||||
|
||||
YOU: сколько сейчас времени?
|
||||
MAVEN: сейчас 21 час 12 минут
|
||||
[0.0s t=329]
|
||||
|
||||
YOU: я не то имел в виду
|
||||
MAVEN: Я поняла тебя, исправь, пожалуйста, свою мысль. Что именно ты хотел сказать?
|
||||
[1.8s t=330]
|
||||
|
||||
YOU: о чём мы говорили?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.6s t=331]
|
||||
|
||||
YOU: напомни
|
||||
MAVEN: Сейчас 21:12. Когда?
|
||||
[0.5s t=332]
|
||||
|
||||
YOU: сделай это
|
||||
MAVEN: пока не умею отвечать на этот вопрос. На какое время поставить напоминание?
|
||||
[0.5s t=333]
|
||||
|
||||
YOU: запиши что я перешёл на новый тариф
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала твою новую информацию о тарифе.
|
||||
[1.5s t=334]
|
||||
|
||||
YOU: какой у меня тариф?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о твоем тарифе.
|
||||
[1.2s t=335]
|
||||
|
||||
YOU: сколько я плачу за интернет?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, сколько именно ты платишь за интернет. Я проверила документы, но точной суммы там нет.
|
||||
[2.6s t=336]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Я поняла тебя. Что именно ты хочешь, чтобы я сделала сейчас?
|
||||
[1.5s t=337]
|
||||
|
||||
```
|
||||
|
||||
## Day 13
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет! Я готова помогать тебе. Чем займемся сегодня?
|
||||
[1.3s t=338]
|
||||
|
||||
YOU: что там с задачами?
|
||||
MAVEN: вот что я нашла: я отметила задачу про бэкапы как сделанную.
|
||||
[1.5s t=339]
|
||||
|
||||
YOU: закрывай
|
||||
MAVEN: какой пункт отметить сделанным?
|
||||
[0.0s t=340]
|
||||
|
||||
YOU: отметь задачу про бэкапы как сделанную
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.6s t=341]
|
||||
|
||||
YOU: что осталось нерешённым?
|
||||
MAVEN: вот что я нашла: в твоей жизни всё, что остается нерешенным или незавершенным, будет продолжать существовать и требовать внимания. Также в источниках упоминается пример с скобками, который может остаться нерешенным, и вопрос о том, что остается нерешенным в краснодарских перевозках.
|
||||
[4.2s t=342]
|
||||
|
||||
YOU: запиши что я договорился о встрече в среду
|
||||
MAVEN: Я записала твою договоренность о встрече.
|
||||
[2.3s t=343]
|
||||
|
||||
YOU: когда у меня встреча?
|
||||
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||
[0.0s t=344]
|
||||
|
||||
YOU: какая температура на улице?
|
||||
MAVEN: для какого города?
|
||||
[0.9s t=345]
|
||||
|
||||
YOU: что такое RAID 5?
|
||||
MAVEN: вот что я нашла: RAID 5 — это массив из независимых дисков, где данные и контрольные суммы (четность) распределяются по кругу на все диски. Этот уровень обеспечивает отказоустойчивость и повышенную производительность, при этом для его работы требуется не менее трех дисков.
|
||||
[2.3s t=346]
|
||||
|
||||
YOU: пока
|
||||
MAVEN: До встречи!
|
||||
[1.4s t=347]
|
||||
|
||||
```
|
||||
|
||||
## Day 14
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Я не могу сказать «доброе утро», потому что сейчас вечер. Я уже записала твой вопрос и готова помочь с чем-то другим.
|
||||
[1.7s t=348]
|
||||
|
||||
YOU: подведи итоги недели
|
||||
MAVEN: week summary — подведи итоги недели
|
||||
[0.6s t=349]
|
||||
|
||||
YOU: что я делал за последние две недели?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, что ты делал за последние две недели.
|
||||
[1.3s t=350]
|
||||
|
||||
YOU: какие заметки я сохранил?
|
||||
MAVEN: вот что я нашла: в источниках нет информации о том, какие именно заметки ты сохранил.
|
||||
[1.3s t=351]
|
||||
|
||||
YOU: о чём я чаще всего спрашиваю?
|
||||
MAVEN: обычно ты пьёшь воду около 00:58, отмечаешь «name» около 23:56 и отмечаешь «week summary» около 23:59 — по записям за последние 7 дней.
|
||||
[0.9s t=352]
|
||||
|
||||
YOU: напомни в понедельник в 10 проверить бэкапы
|
||||
MAVEN: Сейчас 21:12. Это утра или вечера?
|
||||
[0.3s t=353]
|
||||
|
||||
YOU: что у меня в понедельник?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи. На какое время поставить напоминание?
|
||||
[0.3s t=354]
|
||||
|
||||
YOU: ты меня понимаешь?
|
||||
MAVEN: Да, я тебя прекрасно понимаю. Чем я могу тебе помочь? На какое время поставить напоминание?
|
||||
[1.3s t=355]
|
||||
|
||||
YOU: спасибо тебе
|
||||
MAVEN: Сейчас 21:12. Это утра или вечера?
|
||||
[1.0s t=356]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: хорошо, напомню послезавтра в 10:00.
|
||||
[1.2s t=357]
|
||||
|
||||
```
|
||||
@@ -0,0 +1,132 @@
|
||||
# The same fortnight, after V-655 merged
|
||||
|
||||
Date: 2026-08-08, a few hours after `2026-08-08-two-weeks.md`.
|
||||
Build: `f8fa0d1` on master, the five compose services rebuilt and recreated.
|
||||
Same driver, same 140 turns, same reach. This is the diff that baseline was for.
|
||||
|
||||
Master now carries V-655, V-659, V-660 and V-661. The change under test is V-655. A query source that decides by seed similarity
|
||||
is marked `guesses: true`. It is dropped when the cascade names a different
|
||||
destination.
|
||||
|
||||
## Two things confound the comparison and one of them matters
|
||||
|
||||
**The workstation was up for the re-run.** `llama-server` on 192.168.1.105
|
||||
answered a health probe with 200. So routing completed through `llm.Pair`
|
||||
against gemma-4-12b, which is the arm that names a destination. Its state
|
||||
during the baseline was not recorded. So a difference here may be the merge, or
|
||||
may be the better router, and this run cannot separate them.
|
||||
|
||||
**The store carried over**, as the baseline said it would. Facts written by the
|
||||
first run were present from turn 1 of the second.
|
||||
|
||||
## Numbers
|
||||
|
||||
| | baseline `beb093a` | after `f8fa0d1` |
|
||||
|---|---|---|
|
||||
| turns | 140 | 140 |
|
||||
| p50 | 1.5s | 1.2s |
|
||||
| p95 | 7.1s | 3.0s |
|
||||
| max | 33.7s | 4.2s |
|
||||
| transport errors | 0 | 0 |
|
||||
| turns carrying a failure string | 41 | 38 |
|
||||
|
||||
| string in the reply | before | after |
|
||||
|---|---|---|
|
||||
| `на какое время поставить напоминание` | 13 | 13 |
|
||||
| `не нашла у тебя такой записи` | 8 | 9 |
|
||||
| `Такую команду я не знаю` | 8 | 8 |
|
||||
| `для какого города` | 6 | 4 |
|
||||
| `В какой день` | 6 | 6 |
|
||||
| `пока не умею` | 5 | 1 |
|
||||
| `Когда?` | 3 | 3 |
|
||||
|
||||
Read the latency as unattributed. The workstation confound covers all of it.
|
||||
|
||||
## Defect 2 is the one this was for: four of six fixed
|
||||
|
||||
| utterance | before | after |
|
||||
|---|---|---|
|
||||
| `что такое TCP?` | `для какого города?` | a real definition |
|
||||
| `сколько будет 17 на 23?` | `для какого города?` | search, which has no answer |
|
||||
| `какой у меня любимый язык?` | kernel headlines | `не нашла у тебя такой записи` |
|
||||
| `что я сохранил про Сочи?` | `Хорошо, сохраню.` | answered as a question |
|
||||
| `какая скорость у меня сейчас?` | `для какого города?` | `для какого города?` |
|
||||
| `хватает ли места под новые бэкапы?` | kernel headlines | kernel headlines |
|
||||
|
||||
`что такое TCP?` is the clean win. `WorldQueryGrammars` names `world` at stage
|
||||
0, weather is dropped, and search answers.
|
||||
|
||||
`сколько будет 17 на 23?` moved source and not outcome. Weather no longer claims it. Search
|
||||
cannot do arithmetic, so the reply says the sources have no product of 17 and
|
||||
23. That is an honest gap where it used to be a wrong
|
||||
question. Arithmetic has no destination in the enum.
|
||||
|
||||
`что я сохранил про Сочи?` was defect 3 and it is gone. The utterance is no
|
||||
longer read as a capture.
|
||||
|
||||
**The two that did not move are both homelab questions.** They are exactly the
|
||||
cluster the destination fixture flagged. `SourceRecall`, `SourceNetwork` and
|
||||
`SourceAttention` overlap on every question about the box. Five of the seven
|
||||
floor cases in that fixture are homelab operations for the same reason. So this
|
||||
is the enum, not the walk.
|
||||
|
||||
## Defect 1 did not move at all
|
||||
|
||||
Twenty-six turns still carry a parked clarify tail, the same count as the
|
||||
baseline. `спасибо тебе` answers `Сейчас 21:12. Это утра или вечера?` and
|
||||
`спокойной ночи` answers `хорошо, напомню послезавтра в 10:00.`
|
||||
|
||||
V-655 was never going to touch this. A parked clarify is dialogue state and not
|
||||
a query source. It remains the single worst thing about talking to her. The week test, the
|
||||
fortnight test and this re-run all report it unchanged.
|
||||
|
||||
## A gap in the harness, fixed and re-run the same day
|
||||
|
||||
`ipc.ChatReply.Source` came back empty on all 140 turns, in both runs. The
|
||||
driver read the redirect parameter `src` and `cmd/mavweb/chat.go` writes `s`.
|
||||
So every finding above is read off the reply text instead of off the badge.
|
||||
|
||||
Fixed in V-662 and the 140 turns were driven a third time. Sixty-eight of them
|
||||
name a source. The rest are not query turns and never reach `queryWalk`.
|
||||
|
||||
| source | turns |
|
||||
|---|---|
|
||||
| search | 27 |
|
||||
| memory | 13 |
|
||||
| personal | 9 |
|
||||
| weather | 5 |
|
||||
| calendar | 3 |
|
||||
| attention | 3 |
|
||||
| list | 2 |
|
||||
| feeds | 2 |
|
||||
| tasks, money, self, habits | 1 each |
|
||||
|
||||
## What the badge shows that the wording did not
|
||||
|
||||
The two unfixed homelab turns are now direct evidence.
|
||||
|
||||
```text
|
||||
какая скорость у меня сейчас? -> weather
|
||||
хватает ли места под новые бэкапы? -> feeds
|
||||
```
|
||||
|
||||
Both are guessing sources claiming a turn about the box, exactly as the
|
||||
destination fixture predicted.
|
||||
|
||||
The badge also names a defect the wording hid. **Agenda questions are being
|
||||
claimed by the personal boundary and by Praxis, not by the calendar.**
|
||||
|
||||
```text
|
||||
во сколько у меня встреча? -> personal не нашла у тебя такой записи
|
||||
когда у меня встреча? -> attention у Praxis нет источников
|
||||
что у меня в понедельник? -> personal не нашла у тебя такой записи
|
||||
```
|
||||
|
||||
Calendar claimed 3 turns of the 6 that asked about the calendar. That is the
|
||||
same 3/6 the destination fixture scores and the same 3/6 every seed of the
|
||||
routing head scores. Three measurements agree. The cause is the one V-660 named. The possessive
|
||||
agenda rules claim these at stage 0 and name no destination, so the walk
|
||||
reaches `personal` and `attention` first.
|
||||
|
||||
This is the third independent confirmation that the possessive agenda rules
|
||||
should name the calendar. That call is still the owner's.
|
||||
@@ -0,0 +1,636 @@
|
||||
# Raw transcript, two weeks of usage
|
||||
|
||||
Companion to `2026-08-08-two-weeks.md`. 140 turns through `POST /api/chat`,
|
||||
driven by `scripts/usage-run.py` over `scripts/testdata/usage-turns.txt`.
|
||||
Days are simulated. The wall clock was 20:18-20:27 throughout, which is why
|
||||
every clock answer says evening.
|
||||
|
||||
## Day 1
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Сейчас уже вечер, поэтому я не могу сказать тебе «доброе утро».
|
||||
[1.5s t=78]
|
||||
|
||||
YOU: какой сегодня день?
|
||||
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||
[0.0s t=79]
|
||||
|
||||
YOU: сколько времени?
|
||||
MAVEN: сейчас 20 часов 18 минут
|
||||
[0.0s t=80]
|
||||
|
||||
YOU: запиши что я пью кофе без сахара
|
||||
MAVEN: Я записала твою заметку про кофе。
|
||||
[2.0s t=81]
|
||||
|
||||
YOU: мой любимый язык программирования go
|
||||
MAVEN: Я записала твою заметку про Go.
|
||||
[1.4s t=82]
|
||||
|
||||
YOU: напомни в 11:00 позвонить маме
|
||||
MAVEN: Сейчас 20:18. В какой день?
|
||||
[0.4s t=83]
|
||||
|
||||
YOU: что у меня сегодня?
|
||||
MAVEN: на 08.08.2026 ничего нет, на какое время поставить напоминание?
|
||||
[0.3s t=84]
|
||||
|
||||
YOU: что такое TCP?
|
||||
MAVEN: для какого города? На какое время поставить напоминание?
|
||||
[1.0s t=85]
|
||||
|
||||
YOU: сколько будет 17 на 23?
|
||||
MAVEN: для какого города? На какое время поставить напоминание?
|
||||
[0.9s t=86]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Сейчас 20:18. В какой день?
|
||||
[0.9s t=87]
|
||||
|
||||
```
|
||||
|
||||
## Day 2
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Сейчас 20:18. В какой день?
|
||||
[0.9s t=88]
|
||||
|
||||
YOU: что нового?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи, на какое время поставить напоминание?
|
||||
[1.2s t=89]
|
||||
|
||||
YOU: какая погода?
|
||||
MAVEN: для какого города? На какое время поставить напоминание?
|
||||
[0.6s t=90]
|
||||
|
||||
YOU: запиши что пароль от вайфая лежит в ящике стола
|
||||
MAVEN: Прошлую просьбу отпускаю. Хорошо, сохранил.
|
||||
[33.7s t=91]
|
||||
|
||||
YOU: где лежит вайфай пароль?
|
||||
MAVEN: вот что я нашла: пароль от вайфая лежит в ящике стола
|
||||
[7.0s t=92]
|
||||
|
||||
YOU: добавь молоко в список покупок
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[1.4s t=93]
|
||||
|
||||
YOU: что у меня в списке покупок?
|
||||
MAVEN: в списке покупок пусто.
|
||||
[0.0s t=94]
|
||||
|
||||
YOU: кто такой Линус Торвальдс?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.6s t=95]
|
||||
|
||||
YOU: какой у меня любимый язык?
|
||||
MAVEN: вот что нового: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||
[1.3s t=96]
|
||||
|
||||
YOU: сколько у меня задач?
|
||||
MAVEN: сначала: проверить интейк форму на боксе (завтра); купить хлеб; купить молоко; полить цветы; починить кран
|
||||
[0.0s t=97]
|
||||
|
||||
```
|
||||
|
||||
## Day 3
|
||||
|
||||
```
|
||||
YOU: как дела?
|
||||
MAVEN: рада, как дела?
|
||||
[4.1s t=98]
|
||||
|
||||
YOU: напомни завтра в 9 утра купить хлеб
|
||||
MAVEN: хорошо, напомню завтра в 09:00.
|
||||
[0.3s t=99]
|
||||
|
||||
YOU: что у меня завтра?
|
||||
MAVEN: на 09.08.2026 ничего нет.
|
||||
[0.0s t=100]
|
||||
|
||||
YOU: отмени напоминание про хлеб
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[1.6s t=101]
|
||||
|
||||
YOU: какие у меня напоминания?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[0.0s t=102]
|
||||
|
||||
YOU: сохрани мне адрес гостиницы в Сочи
|
||||
MAVEN: Хорошо, сохраню.
|
||||
[5.6s t=103]
|
||||
|
||||
YOU: что я сохранил про Сочи?
|
||||
MAVEN: Хорошо, сохраню.
|
||||
[2.7s t=104]
|
||||
|
||||
YOU: почему сервер тормозит?
|
||||
MAVEN: есть такое: Почему тормозит сервер сайта: причины медленной работы и способы решения — Почему сервер работает медленно: основные причины торможения, диагностика проблем, способы оптимизации. Практические советы по ускорению работы сервера и сайта.
|
||||
[3.1s t=105]
|
||||
|
||||
YOU: хватает ли места под новые бэкапы?
|
||||
MAVEN: нового вот что: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||
[1.5s t=106]
|
||||
|
||||
YOU: выключи свет в спальне
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[9.0s t=107]
|
||||
|
||||
```
|
||||
|
||||
## Day 4
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: рада
|
||||
[5.0s t=108]
|
||||
|
||||
YOU: что я пропустил?
|
||||
MAVEN: я не могу ответить на вопрос о том, что ты пропустил. мне нужно больше информации.
|
||||
[5.7s t=109]
|
||||
|
||||
YOU: о чём мы вчера говорили?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.1s t=110]
|
||||
|
||||
YOU: запиши что я записался к врачу на четверг
|
||||
MAVEN: Хорошо, сохранил.
|
||||
[6.0s t=111]
|
||||
|
||||
YOU: когда я иду к врачу?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.1s t=112]
|
||||
|
||||
YOU: что такое ZFS?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.0s t=113]
|
||||
|
||||
YOU: столица Франции?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.2s t=114]
|
||||
|
||||
YOU: переведи слово ремонт на английский
|
||||
MAVEN: записала: переведи слово ремонт на английский
|
||||
[1.1s t=115]
|
||||
|
||||
YOU: сколько я потратил в этом месяце?
|
||||
MAVEN: я не отслеживаю траты — не подключено.
|
||||
[1.4s t=116]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: спокойной ночи
|
||||
[4.3s t=117]
|
||||
|
||||
```
|
||||
|
||||
## Day 5
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет!
|
||||
[2.6s t=118]
|
||||
|
||||
YOU: какая погода в Москве?
|
||||
MAVEN: погода не настроена.
|
||||
[1.2s t=119]
|
||||
|
||||
YOU: что там с бэкапами?
|
||||
MAVEN: нашла вот это: Бэкап - что такое резервное копирование и как его сделать — Бэкап (backup) — это резервная копия важных данных, сохраненная в специальный файл. Ею пользуются как подстраховкой: если что-то сломается, с помощью бэкапа можно будет все вернуть. Простейший пример бэкапа — сохранение файла в облаке или о…
|
||||
[4.0s t=120]
|
||||
|
||||
YOU: покажи что требует внимания
|
||||
MAVEN: я заметила, что у тебя есть запрос о том, что требуется внимание. давай разберёмся вместе.
|
||||
[5.2s t=121]
|
||||
|
||||
YOU: отметь это как сделанное
|
||||
MAVEN: команды «отметь» нет в списке. Предложила её добавить — включи через клиент.
|
||||
[0.0s t=122]
|
||||
|
||||
YOU: запиши что я купил новые наушники
|
||||
MAVEN: Заметка сохранена.
|
||||
[3.7s t=123]
|
||||
|
||||
YOU: какие у меня заметки за неделю?
|
||||
MAVEN: week summary — подведи итоги недели
|
||||
[0.0s t=124]
|
||||
|
||||
YOU: расскажи про Kubernetes
|
||||
MAVEN: вот что я нашла: Что такое Kubernetes — Сервисы, поддержка и инструменты Kubernetes широко доступны. Название Kubernetes происходит от греческого, что означает рулевой или штурман. Google открыл исходный код Kubernetes в 2014 году.
|
||||
[1.0s t=125]
|
||||
|
||||
YOU: кто я?
|
||||
MAVEN: name — Ками
|
||||
[1.0s t=126]
|
||||
|
||||
YOU: пока
|
||||
MAVEN: Пока, спасибо за разговор.
|
||||
[4.1s t=127]
|
||||
|
||||
```
|
||||
|
||||
## Day 6
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Добро пожаловать! Я Maven — твой домашний ассистент. Как я могу помочь сегодня?
|
||||
[10.1s t=128]
|
||||
|
||||
YOU: сколько времени?
|
||||
MAVEN: сейчас 20 часов 21 минута
|
||||
[0.0s t=129]
|
||||
|
||||
YOU: напомни в 18:30 позвонить в банк
|
||||
MAVEN: Сейчас 20:21. В какой день?
|
||||
[0.3s t=130]
|
||||
|
||||
YOU: поставь чайник
|
||||
MAVEN: Сейчас 20:21. В какой день?
|
||||
[5.3s t=131]
|
||||
|
||||
YOU: включи музыку
|
||||
MAVEN: Сейчас 20:21. В какой день?
|
||||
[1.7s t=132]
|
||||
|
||||
YOU: что у меня в календаре на пятницу?
|
||||
MAVEN: вот что я нашла: ты записался к врачу на четверг, лежит вайфай пароль в ящике стола, сохранил про Сочи, пью кофе без сахара, на какое время поставить напоминание?
|
||||
[5.5s t=133]
|
||||
|
||||
YOU: во сколько у меня встреча?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи, на какое время поставить напоминание?
|
||||
[0.3s t=134]
|
||||
|
||||
YOU: запиши что дедлайн по проекту в понедельник
|
||||
MAVEN: Прошлую просьбу отпускаю. Хорошо, записала.
|
||||
[6.3s t=135]
|
||||
|
||||
YOU: успею ли я до дедлайна?
|
||||
MAVEN: вот что я нашла: дедлайн по проекту указан как понедельник.
|
||||
[5.3s t=136]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: спасиба
|
||||
[3.8s t=137]
|
||||
|
||||
```
|
||||
|
||||
## Day 7
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет!
|
||||
[2.6s t=138]
|
||||
|
||||
YOU: как ты?
|
||||
MAVEN: рада
|
||||
[4.2s t=139]
|
||||
|
||||
YOU: расскажи анекдот
|
||||
MAVEN: Я могу рассказать интересный анекдот для тебя. Вот - в одном из фильмов был такой сценарий: главному герою дали уйти от команды и он остался один, а потом появился его друг, который сказал ему: 'Тебе не нужно было остаться'. Но это просто игра слов.
|
||||
[7.1s t=140]
|
||||
|
||||
YOU: что ты умеешь?
|
||||
MAVEN: Я могу помочь с задачами, напоминать о важных датах и событиях. Также я помогу записывать заметки или отвечать по ним.
|
||||
[4.1s t=141]
|
||||
|
||||
YOU: запиши что я начал бегать по утрам
|
||||
MAVEN: Заметка сохранена.
|
||||
[9.2s t=142]
|
||||
|
||||
YOU: я бегаю по утрам уже неделю
|
||||
MAVEN: записала: я бегаю по утрам уже неделю
|
||||
[1.9s t=143]
|
||||
|
||||
YOU: как часто я бегаю?
|
||||
MAVEN: вот что я нашла: ты бегал по утрам
|
||||
[5.3s t=144]
|
||||
|
||||
YOU: сколько стоит биткоин?
|
||||
MAVEN: есть такое: Цена Bitcoin сегодня | Данные о цене, рынке и графики BTC в ... — В настоящее время 1 Bitcoin стоит 65 тыс $, что на 0,09% выше за последние 24 ч. Отслеживайте цену BTC, рыночную капитализацию, исторический график и рыночную аналитику в реальном времени.
|
||||
[2.1s t=145]
|
||||
|
||||
YOU: какие новости?
|
||||
MAVEN: вот что нового: [$] Changes in shadow-utils password-expiration features; The Software Stewardship Lab launches; LightDM lives: version 1.33.0 released
|
||||
[0.8s t=146]
|
||||
|
||||
YOU: хорошего дня
|
||||
MAVEN: Хорошого дня!
|
||||
[4.1s t=147]
|
||||
|
||||
```
|
||||
|
||||
## Day 8
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Добро пожаловать! Как могу помочь сегодня?
|
||||
[2.9s t=148]
|
||||
|
||||
YOU: что у меня сегодня?
|
||||
MAVEN: на 08.08.2026 ничего нет.
|
||||
[0.0s t=149]
|
||||
|
||||
YOU: напомни через час выпить воды
|
||||
MAVEN: хорошо, напомню сегодня в 21:22.
|
||||
[0.3s t=150]
|
||||
|
||||
YOU: я выпил воды
|
||||
MAVEN: записала: я выпил воды
|
||||
[1.2s t=151]
|
||||
|
||||
YOU: запиши что кот ест только сухой корм
|
||||
MAVEN: Хорошо, записала.
|
||||
[5.6s t=152]
|
||||
|
||||
YOU: чем питается кот?
|
||||
MAVEN: вот что я нашла: кот питается только сухим кормом.
|
||||
[4.5s t=153]
|
||||
|
||||
YOU: что такое DNS?
|
||||
MAVEN: нашла вот это: Dns — Википедия — В случае рекурсивного запроса DNS-сервер опрашивает серверы (в порядке убывания уровня зон в имени), пока не найдёт ответ или не обнаружит, что домена не существует (на практике поиск ...
|
||||
[2.0s t=154]
|
||||
|
||||
YOU: проверь статус uptime kuma
|
||||
MAVEN: записала: проверь статус uptime kuma
|
||||
[1.6s t=155]
|
||||
|
||||
YOU: всё ли в порядке с сервером?
|
||||
MAVEN: пока не умею отвечать на этот вопрос.
|
||||
[1.3s t=156]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: спасибо за пожелание
|
||||
[4.0s t=157]
|
||||
|
||||
```
|
||||
|
||||
## Day 9
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: рада,
|
||||
[2.1s t=158]
|
||||
|
||||
YOU: какой сегодня день недели?
|
||||
MAVEN: сегодня суббота, 8 августа 2026 года
|
||||
[0.0s t=159]
|
||||
|
||||
YOU: добавь хлеб и сыр в список покупок
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[1.3s t=160]
|
||||
|
||||
YOU: что в списке покупок?
|
||||
MAVEN: в списке покупок пусто.
|
||||
[0.0s t=161]
|
||||
|
||||
YOU: удали молоко из списка
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[1.2s t=162]
|
||||
|
||||
YOU: напомни завтра утром вынести мусор
|
||||
MAVEN: Сейчас 20:22. Когда?
|
||||
[0.3s t=163]
|
||||
|
||||
YOU: запиши что я поменял масло в машине
|
||||
MAVEN: Прошлую просьбу отпускаю. Такую команду я не знаю.
|
||||
[1.3s t=164]
|
||||
|
||||
YOU: когда я менял масло?
|
||||
MAVEN: вот что я нашла: по данным из last update 2019 год, заправки Сочи не могли предоставить масло для замены, поэтому это было невозможно.
|
||||
[7.1s t=165]
|
||||
|
||||
YOU: сколько будет 144 делить на 12?
|
||||
MAVEN: есть такое: Разделить в столбик 144 на 12 - Calc-Best.ru — 144 : 12 = 12 Где 144 - делимое, 12 - делитель, 12 - частное ... Разделить 770 на 7 столбиком Разделить 441 на 9 столбиком Разделить 891 на 9 столбиком Разделить 60 на 5 столбиком Разделить 60 на 6 столбиком
|
||||
[9.6s t=166]
|
||||
|
||||
YOU: пока
|
||||
MAVEN: я записала
|
||||
[4.8s t=167]
|
||||
|
||||
```
|
||||
|
||||
## Day 10
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: рада
|
||||
[4.1s t=168]
|
||||
|
||||
YOU: что нового за ночь?
|
||||
MAVEN: нашла вот это: Главные новости сегодня в России, а также самые последние новости со ... — Полное бессилие: киевская ПВО вновь пропустила все российские ракеты. Вооруженные силы РФ в ночь на 8 августа нанесли второй за последнее время массированный ...
|
||||
[2.1s t=169]
|
||||
|
||||
YOU: почему интернет медленный?
|
||||
MAVEN: вот что я нашла: Почему интернет стал медленнее: причины и способы решения - АО «Газпром ... — На скорость интернета сильно влияют торрент-клиенты, VPN, потоковое видео, фоновая загрузка системных обновлений. Посмотреть, куда уходит трафик, можно в настройках ПК или смартфона.
|
||||
[2.4s t=170]
|
||||
|
||||
YOU: какая скорость у меня сейчас?
|
||||
MAVEN: для какого города?
|
||||
[1.2s t=171]
|
||||
|
||||
YOU: запиши что новый роутер стоит 8000 рублей
|
||||
MAVEN: Я записала стоимость нового роутера.
|
||||
[2.5s t=172]
|
||||
|
||||
YOU: сколько стоил роутер?
|
||||
MAVEN: вот что я нашла: новый роутер стоит 8000 рублей.
|
||||
[2.0s t=173]
|
||||
|
||||
YOU: что такое NAT?
|
||||
MAVEN: NAT (Network Address Translation) — это технология, которая позволяет устройствам в локальной сети выходить в интернет, используя один общий внешний IP-адрес. Я отвечаю на этот вопрос, используя поиск в сети.
|
||||
[1.8s t=174]
|
||||
|
||||
YOU: напомни в субботу позвонить бабушке
|
||||
MAVEN: Сейчас 20:23. Когда?
|
||||
[0.3s t=175]
|
||||
|
||||
YOU: покажи мои напоминания
|
||||
MAVEN: вот что я нашла: в твоих источниках нет напоминаний, только запись о том, что ты купил новые наушники, на какое время поставить напоминание?
|
||||
[1.7s t=176]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Сейчас 20:23. Во сколько напомнить?
|
||||
[1.0s t=177]
|
||||
|
||||
```
|
||||
|
||||
## Day 11
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Сейчас 20:23. Скажи время — например, «в семь вечера» или «через час».
|
||||
[0.9s t=178]
|
||||
|
||||
YOU: как погода на выходных?
|
||||
MAVEN: для какого города? На какое время поставить напоминание?
|
||||
[0.6s t=179]
|
||||
|
||||
YOU: что у меня на этой неделе?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи, на какое время поставить напоминание?
|
||||
[0.3s t=180]
|
||||
|
||||
YOU: запиши что я хочу прочитать книгу про Go
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала твою заметку о книге по Go.
|
||||
[1.4s t=181]
|
||||
|
||||
YOU: что я хотел прочитать?
|
||||
MAVEN: вот что я нашла: ты хотел прочитать книгу про Go.
|
||||
[1.1s t=182]
|
||||
|
||||
YOU: объясни что такое горутина
|
||||
MAVEN: вот что я нашла: я узнала, что горутины — это легковесные потоки выполнения или функции в Go, которые позволяют параллельно выполнять код. Они управляются планировщиком Go и работают как независимые нити выполнения.
|
||||
[1.9s t=183]
|
||||
|
||||
YOU: кто написал Войну и мир?
|
||||
MAVEN: вот что я нашла: роман «Война и мир» написал Лев Николаевич Толстой.
|
||||
[2.4s t=184]
|
||||
|
||||
YOU: включи свет на кухне
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.5s t=185]
|
||||
|
||||
YOU: закрой шторы в комнате
|
||||
MAVEN: Такую команду я не знаю.
|
||||
[0.3s t=186]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: Спокойной ночи. Отдыхай, я на связи, если что-то понадобится.
|
||||
[1.2s t=187]
|
||||
|
||||
```
|
||||
|
||||
## Day 12
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: Я не могу сказать тебе «доброе утро», потому что сейчас уже вечер.
|
||||
[1.0s t=188]
|
||||
|
||||
YOU: сколько сейчас времени?
|
||||
MAVEN: сейчас 20 часов 23 минуты
|
||||
[0.0s t=189]
|
||||
|
||||
YOU: я не то имел в виду
|
||||
MAVEN: Я поняла, исправлюсь. Расскажи, что именно ты хотел сказать?
|
||||
[1.4s t=190]
|
||||
|
||||
YOU: о чём мы говорили?
|
||||
MAVEN: Я помню всё, что ты мне говоришь, и сейчас отвечаю на твои вопросы на основе этих данных.
|
||||
[1.4s t=191]
|
||||
|
||||
YOU: напомни
|
||||
MAVEN: Сейчас 20:23. Когда?
|
||||
[0.7s t=192]
|
||||
|
||||
YOU: сделай это
|
||||
MAVEN: пока не умею отвечать на этот вопрос, на какое время поставить напоминание?
|
||||
[0.5s t=193]
|
||||
|
||||
YOU: запиши что я перешёл на новый тариф
|
||||
MAVEN: Прошлую просьбу отпускаю. Я записала твою новую информацию о тарифе.
|
||||
[1.5s t=194]
|
||||
|
||||
YOU: какой у меня тариф?
|
||||
MAVEN: вот что я нашла: ты перешел на новый тариф
|
||||
[24.4s t=195]
|
||||
|
||||
YOU: сколько я плачу за интернет?
|
||||
MAVEN: есть такое: Как узнать, сколько и когда платить за домашний интернет и ТВ — 3 февр. 2026 г. ... Как узнать, сколько и когда платить за домашний интернет и ТВ · Для тарифов только с домашним интернетом · Была ли статья полезной?
|
||||
[2.1s t=196]
|
||||
|
||||
YOU: спасибо
|
||||
MAVEN: Спасибо, что обратилась за помощью.
|
||||
[4.8s t=197]
|
||||
|
||||
```
|
||||
|
||||
## Day 13
|
||||
|
||||
```
|
||||
YOU: привет
|
||||
MAVEN: Привет!
|
||||
[2.5s t=198]
|
||||
|
||||
YOU: что там с задачами?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.2s t=199]
|
||||
|
||||
YOU: закрывай
|
||||
MAVEN: какой пункт отметить сделанным?
|
||||
[0.0s t=200]
|
||||
|
||||
YOU: отметь задачу про бэкапы как сделанную
|
||||
MAVEN: Заметка сохранена.
|
||||
[5.8s t=201]
|
||||
|
||||
YOU: что осталось нерешённым?
|
||||
MAVEN: вот что я нашла: Российское регулирование криптовалют: что осталось нерешённым к 1 июля — 2 июл. 2026 г. ... Российское регулирование криптовалют: что осталось нерешённым к 1 июля. Пакет законопроектов о регулировании цифровых активов в России не ...
|
||||
[3.7s t=202]
|
||||
|
||||
YOU: запиши что я договорился о встрече в среду
|
||||
MAVEN: Заметка сохранена.
|
||||
[2.7s t=203]
|
||||
|
||||
YOU: когда у меня встреча?
|
||||
MAVEN: мне пока нечего смотреть — у Praxis нет источников.
|
||||
[0.1s t=204]
|
||||
|
||||
YOU: какая температура на улице?
|
||||
MAVEN: для какого города?
|
||||
[1.7s t=205]
|
||||
|
||||
YOU: что такое RAID 5?
|
||||
MAVEN: не знаю — не нашла у тебя такой записи.
|
||||
[1.2s t=206]
|
||||
|
||||
YOU: пока
|
||||
MAVEN: Я записала, что мы продолжаем разговор.
|
||||
[4.1s t=207]
|
||||
|
||||
```
|
||||
|
||||
## Day 14
|
||||
|
||||
```
|
||||
YOU: доброе утро
|
||||
MAVEN: рада, доброго утра!
|
||||
[4.5s t=208]
|
||||
|
||||
YOU: подведи итоги недели
|
||||
MAVEN: записала: подведи итоги недели
|
||||
[1.7s t=209]
|
||||
|
||||
YOU: что я делал за последние две недели?
|
||||
MAVEN: вот что я нашла: ты не делал ничего за последние две недели.
|
||||
[6.6s t=210]
|
||||
|
||||
YOU: какие заметки я сохранил?
|
||||
MAVEN: Ты сочти, что сохранила заметку с текстом 'Проверь календарь' и датой 8 августа 2026 г.,
|
||||
[5.4s t=211]
|
||||
|
||||
YOU: о чём я чаще всего спрашиваю?
|
||||
MAVEN: обычно ты пьёшь воду около 01:12, отмечаешь «name» около 23:56 и отмечаешь «week summary» около 23:59 — по записям за последние 7 дней.
|
||||
[4.4s t=212]
|
||||
|
||||
YOU: напомни в понедельник в 10 проверить бэкапы
|
||||
MAVEN: Сейчас 20:25. Это утра или вечера?
|
||||
[0.7s t=213]
|
||||
|
||||
YOU: что у меня в понедельник?
|
||||
MAVEN: вот что я нашла: у тебя в понедельник дедлайн по проекту, на какое время поставить напоминание?
|
||||
[4.6s t=214]
|
||||
|
||||
YOU: ты меня понимаешь?
|
||||
MAVEN: Да, я тебя прекрасно понимаю. Я готова помогать тебе с заметками и задачами, на какое время поставить напоминание?
|
||||
[2.4s t=215]
|
||||
|
||||
YOU: спасибо тебе
|
||||
MAVEN: Сейчас 20:25. Это утра или вечера?
|
||||
[1.0s t=216]
|
||||
|
||||
YOU: спокойной ночи
|
||||
MAVEN: хорошо, напомню послезавтра в 10:00.
|
||||
[0.9s t=217]
|
||||
|
||||
```
|
||||
@@ -0,0 +1,105 @@
|
||||
# Two weeks of talking to Maven, as a baseline to re-run
|
||||
|
||||
Date: 2026-08-08.
|
||||
Build: `beb093a` on master, the five compose services as deployed, 41 hours up.
|
||||
Reach: `POST /api/chat` on mavweb, 140 turns over fourteen simulated days.
|
||||
Turn source is `tap:text`, so this exercises the path the mic and telegram take.
|
||||
|
||||
This exists to be compared against. `scripts/usage-run.py` and
|
||||
`scripts/testdata/usage-turns.txt` are in the repo, so a re-run after a routing
|
||||
change is a diff rather than a new opinion. The 2026-08-07 week of usage was
|
||||
typed by hand and cannot be replayed.
|
||||
|
||||
**It measures master, not the branch.** V-655, V-659 and V-660 are unmerged.
|
||||
Every query source that guesses is still in the chain. That is the change this
|
||||
baseline is for.
|
||||
|
||||
## What re-runs and what does not
|
||||
|
||||
The turns file, the driver and the routing behaviour replay. Three things do
|
||||
not. The wall clock was 20:18 to 20:27 throughout, so every clock and agenda
|
||||
answer reads evening. Live search and the feed return different text each day.
|
||||
And the store carries over between runs. A fact written on day 2 is already
|
||||
present when a re-run reaches day 1.
|
||||
|
||||
## Numbers
|
||||
|
||||
| | week (2026-08-07) | fortnight (2026-08-08) |
|
||||
|---|---|---|
|
||||
| turns | 74 | 140 |
|
||||
| p50 | 1.5s | 1.6s |
|
||||
| p95 | 8.0s | 7.1s |
|
||||
| max | 12.3s | 33.7s |
|
||||
| transport errors | 0 | 0 |
|
||||
|
||||
| string in the reply | turns |
|
||||
|---|---|
|
||||
| `на какое время поставить напоминание` | 13 |
|
||||
| `не нашла у тебя такой записи` | 8 |
|
||||
| `Такую команду я не знаю` | 8 |
|
||||
| `для какого города` | 6 |
|
||||
| `В какой день` | 6 |
|
||||
| `пока не умею` | 5 |
|
||||
| `Когда?` | 3 |
|
||||
|
||||
**Zero transport errors is not zero wrong answers.** It counts turns that
|
||||
failed to return a reply, and none did. Every quality number is below.
|
||||
|
||||
Those seven strings appear 49 times across 41 of 140 turns. Some turns carry
|
||||
two, because a parked clarify appends to whatever else was said.
|
||||
|
||||
The 33.7s outlier is one note write on day 2. p95 improved against the week
|
||||
despite it.
|
||||
|
||||
## The three defects worth diffing against
|
||||
|
||||
### 1. A parked reminder clarify still contaminates later turns
|
||||
|
||||
The week test called this the single worst thing to talk to and it is unchanged.
|
||||
Nineteen turns carry a clarify tail. The worst run is day 1, turns 7 to 13,
|
||||
which spans a day boundary:
|
||||
|
||||
```
|
||||
что такое TCP? -> для какого города? На какое время поставить напоминание?
|
||||
сколько будет 17 на 23? -> для какого города? На какое время поставить напоминание?
|
||||
спасибо -> Сейчас 20:18. В какой день?
|
||||
привет -> Сейчас 20:18. В какой день?
|
||||
```
|
||||
|
||||
Note that `привет` and `спасибо` do not clear it, and neither does a new day.
|
||||
|
||||
### 2. Query sources that guess still claim turns they cannot answer
|
||||
|
||||
Weather took `сколько будет 17 на 23?`, `что такое TCP?` and `какая скорость у
|
||||
меня сейчас?`, answering `для какого города?` to all three. The feed took
|
||||
`какой у меня любимый язык?` and `хватает ли места под новые бэкапы?` and
|
||||
answered with kernel headlines.
|
||||
|
||||
This is the exact class V-655 removes by marking a source `guesses: true` and
|
||||
taking it out of `queryWalk`. Six turns here, so the re-run has a number to move.
|
||||
|
||||
### 3. A question can still be read as a capture
|
||||
|
||||
`что я сохранил про Сочи?` answered `Хорошо, сохраню.` The utterance is
|
||||
interrogative and was routed to a write. `IsQuestionShaped` catches this
|
||||
downstream on some paths and did not catch it here.
|
||||
|
||||
## What did work
|
||||
|
||||
Reminders with a spoken time land correctly, which is V-572 holding:
|
||||
`напомни завтра в 9 утра купить хлеб` returned `хорошо, напомню завтра в 09:00.`
|
||||
|
||||
Facts round-trip. `запиши что новый роутер стоит 8000 рублей` then `сколько
|
||||
стоил роутер?` returned the stored value. So did the wifi password and the
|
||||
doctor's appointment.
|
||||
|
||||
World questions answer when no local source claims them first. `что такое NAT?`
|
||||
returned a real definition.
|
||||
|
||||
Stage 0 answers land at 0.0 to 0.4s, unchanged.
|
||||
|
||||
## What this does not cover
|
||||
|
||||
The voice loop, because `mavwaked` and `mavenclient` are not deployed. Reminder
|
||||
delivery, because nothing fired inside the run window. Telegram intake. And the
|
||||
three-head routing model, which does not run in Go at all.
|
||||
@@ -0,0 +1,110 @@
|
||||
# CrisperWhisper 2.0 in Russian, measured
|
||||
|
||||
Date: 2026-08-09. Vikunja V-665.
|
||||
Corpus: `bond005/sberdevices_golos_10h_crowd`, test split, first 200 clips.
|
||||
Harness: `~/Programs/cw2-eval` on workpc, not in this repo.
|
||||
Runner: `./.venv/bin/python run_asr.py <arm>...` then `score.py`.
|
||||
|
||||
The model card benchmarks disfluency F1 in German and English. It never names
|
||||
Russian and publishes no per-language WER. So the measurement came before the
|
||||
wiring.
|
||||
|
||||
## The corpus
|
||||
|
||||
200 clips, 13.7 minutes, 1001 reference words. Median clip 3.91s, range 1.04s
|
||||
to 13.5s. Golos crowd is short crowd-sourced Russian spoken close to the
|
||||
microphone, which is the nearest public thing to someone talking to Maven. The
|
||||
alternatives are read speech, which flatters every model equally.
|
||||
|
||||
Two rows carry a null transcription and are skipped.
|
||||
|
||||
Scoring normalizes both sides: lowercase, `ё` to `е`, punctuation stripped, and
|
||||
digits expanded to Russian words through num2words. Without that last step a
|
||||
model is penalized for writing `60000` where the reference says
|
||||
`шестьдесят тысяч`. Thousands separators are joined before expansion, or
|
||||
`60 000` expands to `шестьдесят ноль`.
|
||||
|
||||
## Headline
|
||||
|
||||
| arm | WER | CER | exact | empty | RTF |
|
||||
|---|---|---|---|---|---|
|
||||
| cw2-turbo-intended | **10.4%** | 3.4% | 65.5% | 0 | 0.065 |
|
||||
| cw2-turbo-verbatim | 10.8% | **3.1%** | **66.5%** | 0 | 0.065 |
|
||||
| whisper-turbo | 11.8% | 4.1% | 64.0% | 0 | 0.031 |
|
||||
| cw2-large-intended | 12.3% | 3.8% | 63.5% | 0 | 0.107 |
|
||||
| whisper-small | 27.5% | 9.8% | 35.0% | 0 | 0.026 |
|
||||
|
||||
`whisper-small` is the floor, because `ggml-small.bin` is what mavsttd loads on
|
||||
homesrv today. CW2 turbo beats it by 17 points of WER and takes exact matches
|
||||
from 35.0% to 65.5%.
|
||||
|
||||
Two results are worth naming beyond the winner. CW2 turbo beats its own base
|
||||
model, whisper-large-v3-turbo, by 1.4 points. And it beats CW2 large by 1.9
|
||||
points, which inverts what the card implies by calling turbo a degraded draft.
|
||||
No arm returned an empty transcript.
|
||||
|
||||
## Intended and verbatim are closer than the mode names suggest
|
||||
|
||||
The two modes disagree on 70 of the 200 clips before normalization and on 29
|
||||
after it. So the raw difference is mostly casing and punctuation, which
|
||||
normalization removes and which Maven does not read either.
|
||||
|
||||
Verbatim scores worse on WER and better on CER and exact matches. The reason is
|
||||
script, not disfluency:
|
||||
|
||||
```text
|
||||
ref: футбольный матч челси брайтон
|
||||
int: Футбольный матч Chelsea-Брайтон.
|
||||
ver: Футбольный матч Челси Брайтон.
|
||||
```
|
||||
|
||||
Intended writes foreign entity names in Latin script and verbatim
|
||||
transliterates them. Golos references are Cyrillic throughout, so verbatim
|
||||
collects the exact matches. That is a property of this corpus rather than a
|
||||
quality difference.
|
||||
|
||||
**This corpus cannot settle the mode choice.** Golos crowd is clean short
|
||||
commands with almost no disfluency. The two modes have nothing to disagree
|
||||
about here. They separate on spontaneous speech with fillers, restarts and
|
||||
repairs, which is what the owner speaks. Intended stays the choice for the
|
||||
reason it was always the choice. Maven wants what was meant, not every stumble
|
||||
on the way there.
|
||||
|
||||
The Latin-script habit is the one finding here that touches routing. The
|
||||
routing heads were trained on Cyrillic utterances, so an entity name arriving
|
||||
in Latin script is out of distribution for them. Nothing measures that yet.
|
||||
|
||||
## The runtime is workpc, because whisper.cpp cannot load CW2
|
||||
|
||||
`num_languages()` in `deps/whisper.cpp/src/whisper.cpp` derives the language
|
||||
count from the vocabulary size:
|
||||
|
||||
```cpp
|
||||
return n_vocab - 51765 - (is_multilingual() ? 1 : 0);
|
||||
```
|
||||
|
||||
CW2 carries 31 extra tokens, so `n_vocab` is 51897 and this yields 131
|
||||
languages. The derived `dt` offset becomes 33 and shifts seven special token
|
||||
ids, including `token_beg` and `token_transcribe`. The architecture is
|
||||
otherwise byte-identical to whisper-large-v3-turbo, and the new tokens sit
|
||||
above every whisper special id.
|
||||
|
||||
So loading CW2 in whisper.cpp is a patch to a vendored dependency, not a port.
|
||||
It was not taken, because STT is moving to workpc anyway under V-486. CW2 turbo
|
||||
becomes the preferred remote and `ggml-small.bin` on homesrv stays the floor,
|
||||
which is the shape `modelSeam` already uses for routing and replies. The 27.5%
|
||||
floor is what a turn falls back to when the workstation is down, and this table
|
||||
is what that costs.
|
||||
|
||||
## License
|
||||
|
||||
Standard CW2 weights carry `nyra-health-non-commercial-research`. The Pro
|
||||
variants are commercial-license only. Maven is personal and self-hosted, so the
|
||||
standard weights are usable and the Pro ones are not free to take.
|
||||
|
||||
## What is not measured
|
||||
|
||||
Disfluent spontaneous speech, which is the whole reason to prefer Intended.
|
||||
Long-form audio beyond 13.5s. Far-field or noisy microphones. English, which
|
||||
Maven also speaks. The ONNX turbo export, which was never run, since the
|
||||
transformers path already meets the latency budget at RTF 0.065.
|
||||
+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,62 @@
|
||||
# Plan: persist the routing trace
|
||||
|
||||
**Owner's call, 06-08-2026. Vikunja #629, umbrella #628.**
|
||||
|
||||
**Verdict: the per-turn decision record now persists.** That reverses a written decision,
|
||||
which is the point of this file. It is not an incidental telemetry
|
||||
feature. Do not read it as one.
|
||||
|
||||
Last verified: 06-08-2026 @ 799cf55
|
||||
|
||||
## What the old decision said
|
||||
|
||||
`internal/decision` kept a 25-turn in-memory ring and persisted nothing. The argument was
|
||||
in `CLAUDE.md` and it was a good one. A turn record is read minutes after the turn or
|
||||
never, so a table that outlives the diagnosis buys nothing. His words did not belong in it.
|
||||
|
||||
## Why it reversed
|
||||
|
||||
V-546 replaces the generative router with classification heads on e5-small. Fitting
|
||||
prototypes and calibrating a distance both need real utterances. V-631 measured how few
|
||||
there are. Nine of the 31 modes in `internal/modes` have no seed example at all, and they
|
||||
are exactly the nine with no deterministic matcher. The seed corpus cannot supply them. A
|
||||
seed row is a phrase someone wrote for a matcher, not a thing he said. The 202 generated
|
||||
contrast pairs were tried and cost four points of fixture accuracy.
|
||||
|
||||
So the choice was between no routing heads and a persisted trace. The owner chose the trace.
|
||||
|
||||
## Retention, and why it is two answers
|
||||
|
||||
**Raw trace: 14 days.** `store.RoutingTraceRetention` in `internal/store/routingtraces.go`. That
|
||||
is the life of a diagnosis with room for a weekend. The bound is an age and not a row
|
||||
count. The useful question is what she did this week, and a busy Tuesday must not push last
|
||||
Friday out.
|
||||
|
||||
**A correction: indefinite.** The owner corrects a turn on `/chat` (V-630). The pair is then
|
||||
promoted out of the trace into a seed-shaped row and kept, because a label is not a
|
||||
transcript. What stays in `routing_traces` is the transcript. It expires on the same 14
|
||||
days as every other row, corrected or not.
|
||||
|
||||
## What keeps it safe
|
||||
|
||||
The utterance is stored in clear. A 384-dimension vector of a short sentence is
|
||||
substantially recoverable. Storing vectors instead would be a privacy claim we cannot
|
||||
support, and making it would be worse than staying silent.
|
||||
|
||||
- **Nothing here leaves the box.** The rule that the owner's notes and facts are never
|
||||
search input covers this table too. No query source reads it, and no upstream engine can.
|
||||
- **Retention is enforced on write and again at start.** `WriteRoutingTrace` prunes every
|
||||
64th row, which is hours at human rate. `pruneTracesOnStart` covers the case write alone
|
||||
cannot. A box that goes quiet keeps every row until the next sixty-fourth turn. Without
|
||||
the start-time prune, the bound would hold only for a box in daily use.
|
||||
- **Deletion already exists.** `Store.Wipe` drops every table the database reports, so
|
||||
`mavend -wipe -confirm-wipe` covers this one with no list to edit.
|
||||
- **The ring did not move.** It is still what `/trace` reads and still what a test with no
|
||||
store gets. The table is a second sink beside it. A failed insert is logged and swallowed,
|
||||
because a trace must never change what he hears.
|
||||
|
||||
## What is not decided
|
||||
|
||||
Whether some utterances must never be promoted into a durable label, no matter how badly
|
||||
they routed. That is a content rule and it belongs beside the personal boundary, not in the trace
|
||||
writer. Recorded here, left to the owner.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Correcting a turn
|
||||
|
||||
Last verified: 06-08-2026 @ 0d5bd0a
|
||||
|
||||
V-630, under V-628. Reads with `21-persisting-the-routing-trace.md`.
|
||||
|
||||
## Why a gesture and not a form
|
||||
|
||||
The routing trace (V-629) stores every turn. Almost all of them routed correctly, so
|
||||
almost all of them teach nothing. A correction is the only high-value supervised signal
|
||||
the box produces. It is also the only one that costs the owner something to give.
|
||||
|
||||
So the design constraint is the cost, not the schema. One gesture beside the reply. No
|
||||
form and no separate page.
|
||||
|
||||
It is step-up gated like the chat POST beside it, which costs nothing: he tapped to send
|
||||
the turn he is correcting. It is gated because trace ids are sequential integers, and this
|
||||
is the one table the routing heads will be fitted on.
|
||||
|
||||
## Two things to capture, and only one of them is required
|
||||
|
||||
A correction has two halves.
|
||||
|
||||
- This turn was wrong.
|
||||
- It should have been *this*.
|
||||
|
||||
The second is worth much more. It names which boundary moved, and it is what a fitted
|
||||
head trains against. But requiring it would price out the first, and a turn marked wrong
|
||||
with no target is still a usable negative. So the target is optional. The trace carries
|
||||
`wrong` when he did not say.
|
||||
|
||||
The target is one of the seven intents and never free text. V-632 fits prototypes from
|
||||
that table. An unroutable label would enter it, and a label nothing can score is worse
|
||||
than no label.
|
||||
|
||||
## Where the label lives
|
||||
|
||||
`routing_labels`, migration #24, keyed unique on the utterance. A second correction of
|
||||
the same sentence replaces the first, because his later answer is the one he meant.
|
||||
|
||||
It is a separate table from `routing_traces` on purpose. The transcript expires after 14
|
||||
days. The label does not. A label is a sentence, an intent and an encoder id. That is not
|
||||
a transcript, and the reversal in doc 21 rests on the distinction.
|
||||
|
||||
`was` is stored beside `should_be`. The pair is what names the confusion. A label with no
|
||||
`was` cannot say which boundary moved.
|
||||
|
||||
## Reach
|
||||
|
||||
`CorrectTurn(traceID, shouldBe)` takes no browser and no session. The trace id rides back
|
||||
on `ipc.ChatReply` through the same context sink the query source badge uses. Nothing in
|
||||
the seam assumes the web.
|
||||
|
||||
Only `/chat` offers the gesture today. That is a gap, named rather than closed. If the web
|
||||
is the only place to correct a turn, the sample skews to whatever the owner types at. Voice
|
||||
is where the hard cases are. Telegram has the obvious shape, an inline keyboard on the
|
||||
reply. Voice does not. Inventing a spoken correction grammar would put a recogniser in
|
||||
front of the one signal that exists to fix recognisers. Both are follow-on work.
|
||||
|
||||
## What is not decided
|
||||
|
||||
Whether the owner ever wants to see the labels he gave. Nothing reads the table outward
|
||||
yet. `/trace` shows the ring, which is 25 turns and in memory, and a labels view is a
|
||||
different page with a different question.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Inbound telegram
|
||||
|
||||
Last verified: 06-08-2026 @ c61b0b3
|
||||
|
||||
V-637, under V-628. Reads with `22-correcting-a-turn.md`.
|
||||
|
||||
## What was missing
|
||||
|
||||
Telegram was a reach and nothing else. `telegramsink` pushed an away message and the chat
|
||||
had no way to answer, so the correction gesture reached the web and voice only.
|
||||
|
||||
That skews the labels. V-546 fits routing heads on them, and a sample drawn from wherever
|
||||
the owner happens to be sitting is the wrong sample.
|
||||
|
||||
## Long-poll, not a webhook
|
||||
|
||||
The box takes no inbound connections and reaches api.telegram.org through a relay, so the
|
||||
connection has to open outward. `getUpdates` with a 25 second hold, one goroutine in the
|
||||
daemon's WaitGroup.
|
||||
|
||||
A failed poll waits 15 seconds and retries without escalating. The relay going down is the
|
||||
normal cause and it comes back on its own.
|
||||
|
||||
## The backlog is dropped on start
|
||||
|
||||
Telegram keeps undelivered updates for 24 hours. A daemon that was down overnight would
|
||||
otherwise wake and answer every queued message in order.
|
||||
|
||||
That is worse than missing them. A question asked eight hours ago has been answered
|
||||
already. A reminder set from it lands at the wrong time. So the first call moves the offset
|
||||
past whatever is queued and acts on none of it.
|
||||
|
||||
## One chat
|
||||
|
||||
`ChatID` is the only accepted sender, and it is the same chat the push half already sends
|
||||
to. A message from anywhere else is dropped with no reply, because a reply confirms the bot
|
||||
exists and whose it is.
|
||||
|
||||
Chat ids are not guessable. They are also not secret, since they travel in every forwarded
|
||||
message. So this is the whole authorisation and it is an allowlist of one.
|
||||
|
||||
## The gesture
|
||||
|
||||
Two taps at most. The reply carries one button, `не то`. Tapping it writes nothing and opens
|
||||
the seven intents plus `просто неверно`. The untargeted negative stays reachable, because he
|
||||
may have opened the row without meaning to name anything.
|
||||
|
||||
Callback data carries the trace id and the target, under telegram's 64 byte cap. It comes
|
||||
off the wire. So an id that will not parse is dropped, and so is a target that is not one of
|
||||
the seven. A label nothing can score is worse than no label.
|
||||
|
||||
A failed write says so on the button and leaves the keyboard up. A successful one takes the
|
||||
keyboard off, because a live keyboard on an answered turn invites correcting it twice.
|
||||
|
||||
## The seam
|
||||
|
||||
`NewPoller` takes two functions and no daemon type. `cmd/mavend/telegramintake.go` fills
|
||||
them from `ipc.CoreAPI`: `Chat` returns the reply and the trace id it collected off the
|
||||
context, and `CorrectTurn` writes the label. So a chat turn takes the path
|
||||
`POST /api/chat` already takes, and nothing in `internal/delivery` knows what a handler is.
|
||||
|
||||
## What is not done
|
||||
|
||||
The turn source is still `tap:text`, which telegram shares with the web. Provenance cannot
|
||||
tell a chat turn from a typed one, so a label's `source` column cannot either.
|
||||
That matters the first time someone asks whether corrections given in the chat differ from
|
||||
corrections given at the desk.
|
||||
|
||||
Voice messages are ignored. The poller reads `message.text` and nothing else, so a voice
|
||||
note in the chat does not reach `mavsttd`.
|
||||
@@ -0,0 +1,99 @@
|
||||
# No deadline on the turn path
|
||||
|
||||
Last verified: 06-08-2026 @ 60e64dd
|
||||
|
||||
**All four steps landed on 06-08-2026.** What follows describes the defect as it was and
|
||||
the work as it was planned. Two things came out differently. `Client.Close` read the conn
|
||||
field with no lock while `roundtrip` re-dialed and dropped it. `-race` caught that on the
|
||||
new cancellation test. So the conn field now has a mutex of its own, held only across a
|
||||
read or an assignment. And `/api/ptt` needed nothing: it proxies to the voice port and never
|
||||
touches the shared client, so only `/api/chat` got the extra connection. The pool inside
|
||||
`ipc.Client` is still unbuilt and still waiting on a second module measured queueing.
|
||||
|
||||
V-638. Sibling of V-607, which is the same class of bug in `internal/worker`.
|
||||
Reads with `docs/offload.md` and `docs/protocol.md`.
|
||||
|
||||
## What is missing
|
||||
|
||||
A chat turn starts in a mavweb HTTP handler and ends at llama-server. Nothing between those
|
||||
two points can be cancelled, and one hop has a timeout.
|
||||
|
||||
Four places, all on the same path.
|
||||
|
||||
`voice.Replier.Reply` takes no context (`internal/voice/replier.go:41`). So `llmReplier`
|
||||
calls `PhraseReply(context.Background(), d)` at `cmd/mavend/replier_llm.go:42`. The turn
|
||||
cannot deadline its own reply. The only bound is `phraser.timeout`, 60s in deploy.
|
||||
|
||||
`ipc.Client.roundtrip` sets no connection deadline (`internal/ipc/client.go:202`). A daemon
|
||||
that stops answering parks the caller for as long as the socket stays open.
|
||||
|
||||
`ipc.Client.call` checks the context once, before sending (`client.go:149`), then blocks in
|
||||
`roundtrip`. Cancelling mid-call does nothing.
|
||||
|
||||
`ipc.Server.serveConn` dispatches under `context.Background()` (`internal/ipc/server.go:253`).
|
||||
A client that hangs up does not cancel the turn, and neither does `Server.Close`.
|
||||
|
||||
## And every call queues behind the slowest one
|
||||
|
||||
`ipc.Client` serialises on one connection and one mutex. mavweb routes `/api/chat` and
|
||||
`/api/ptt` through the shared client, so one turn blocks all 28 handlers while it runs.
|
||||
Worst case is a 60s page load.
|
||||
|
||||
This is understood for exactly one route already. `cmd/mavweb/main.go:57` opens a second
|
||||
connection for `/models`, and the comment there says why. A model swap is a multi-minute
|
||||
call, and sharing the connection would freeze every other page.
|
||||
|
||||
## The pattern is already in the repo
|
||||
|
||||
`internal/voice/client.go:101` derives a connection deadline from the caller's context,
|
||||
falls back to 120s, and clears it with a defer. `internal/ipc/client.go` never learned it.
|
||||
Copy that rather than inventing a second convention.
|
||||
|
||||
## The work
|
||||
|
||||
One commit each.
|
||||
|
||||
**Context on the reply seam.** `phraser.Replier.PhraseReply` already takes a context and the
|
||||
interface has two implementations, so this is small. Change `Reply` to take a context, have
|
||||
`StubReplier` ignore it, and pass it through `llmReplier` to `PhraseReply`. Both call sites
|
||||
already hold one: `cmd/mavend/voice.go:461` and `cmd/mavend/clarify.go:574`.
|
||||
|
||||
**Deadlines and cancellation on the client.** Pass the context into `roundtrip` and set
|
||||
`SetDeadline` from it. For cancellation mid-call, a watchdog goroutine that calls `c.drop()`
|
||||
on `ctx.Done()` is enough. `drop` exists, and the retry split already separates a lost write
|
||||
from a lost read. So a cancelled call lands in `errReadLost` and is never retried for a
|
||||
mutation. Check that against `internal/ipc/maperr_test.go`.
|
||||
|
||||
**A request context on the server.** `serveConn` should derive from a server-scoped context
|
||||
so `Close` cancels a dispatch in flight. `Server` already carries `done` and a conn registry
|
||||
for this class of problem. The registry comment records what the last version of it cost:
|
||||
eleven days of stale ciphertext.
|
||||
|
||||
**Stop serialising mavweb.** Give `/api/chat` and `/api/ptt` their own connection, the way
|
||||
`/models` has one. Roughly ten lines, and it changes no shared code.
|
||||
|
||||
A connection pool inside `ipc.Client` is the general form and is deliberately not the first
|
||||
step. Each connection is already its own request and response stream. So a pool preserves
|
||||
frame pairing by construction. It still has to keep re-dial on drop, the
|
||||
`errWriteLost` and `errReadLost` split, and `Close`. Do the narrow fix, measure, and reach
|
||||
for the pool only if a second module turns out to queue.
|
||||
|
||||
## How it is judged
|
||||
|
||||
`make test` stays green. It is green at `06c1cf2`.
|
||||
|
||||
Nothing here changes routing or recall, so `make eval-router` and `make eval-recall` are
|
||||
unchanged rather than re-measured.
|
||||
|
||||
By hand: load `/dash` while a chat turn is in flight. Before the change it waits for the
|
||||
length of the turn.
|
||||
|
||||
There is no test today that a cancelled context aborts an in-flight `ipc.Client` call. That
|
||||
absence is why two of these four went unnoticed, so the test is part of the work.
|
||||
|
||||
## What is not done here
|
||||
|
||||
The store is still `SetMaxOpenConns(1)` (`internal/store/store.go:99`) under WAL. WAL is
|
||||
built for concurrent readers against one writer, and the cap makes every read queue.
|
||||
`Store.DB(ctx)` hands the digestion worker a read transaction on that same connection. This
|
||||
plan does not touch it. It is measurable first and should be measured before it is changed.
|
||||
@@ -0,0 +1,99 @@
|
||||
# The two boot paths have drifted
|
||||
|
||||
Last verified: 06-08-2026 @ 69d0f5e
|
||||
|
||||
V-639. Reads with `docs/operations.md`.
|
||||
|
||||
## What landed
|
||||
|
||||
`cmd/mavend/boot.go`. `newDaemonAPI(deps)` builds the CoreAPI with every field
|
||||
set, and `startBackground(ctx, &wg, deps)` starts the voice server and every
|
||||
worker through `goWorker`. `backgroundWorkers(deps)` is the pure list behind it,
|
||||
so a test can compare the set without standing a daemon up. Both paths in
|
||||
`run()` now read `coreAPI = newDaemonAPI(depsNow())` and one
|
||||
`startBackground(...)`, where `depsNow` reads whatever the current path wired.
|
||||
|
||||
The shadowed `wg` is gone. Four tests in `cmd/mavend/boot_test.go`. Every
|
||||
`daemonAPI` field is set on a fully wired deployment. The handler gets the API
|
||||
it was built with. The worker set is asserted by name, at the full set and at
|
||||
the floor.
|
||||
|
||||
Still by hand: unlock a locked box by passkey, ask something that needs Nexus,
|
||||
and check `/tools` lists the MCP servers.
|
||||
|
||||
## What is wrong
|
||||
|
||||
`run()` in `cmd/mavend/main.go` brings the daemon up two ways. A box with a key in the
|
||||
environment starts unlocked and wires everything at lines 280 to 621. A box without one
|
||||
starts locked. It wires the same things again inside the unlock closure, at lines 500 to
|
||||
579, after a passkey assertion.
|
||||
|
||||
The two lists have drifted apart. Three ways.
|
||||
|
||||
**Seven workers start untracked.** The unlocked path puts every one through
|
||||
`goWorker(&wg, ...)`, so `waitWorkers` at line 637 can wait for them. The unlock path
|
||||
starts `tl.run`, `factWorker`, `evalWorker`, `feedWkr`, `crawlWkr`, `mcp.run` and
|
||||
`home.run` as bare `go func()`. Nothing waits for any of them.
|
||||
|
||||
That is the shutdown bug the code already documents at lines 631 to 636, reintroduced on
|
||||
the other path. The comment there records what it cost the first time. `run()` never
|
||||
returned, so `defer st.Close()` never sealed the database. The deployed ciphertext was
|
||||
eleven days stale before anyone noticed.
|
||||
|
||||
**A shadowed WaitGroup hides it.** Line 529 declares `var wg sync.WaitGroup` inside the
|
||||
`if voiceW != nil` block, shadowing the one from line 359. It is `Add`ed and `Done`d and
|
||||
never waited. Reading the block, the voice server looks tracked. It is not.
|
||||
|
||||
**Two `daemonAPI` fields are never set.** The unlocked path fills `nexus` at line 295 and
|
||||
`getMCPServers` at line 305. The unlock path fills neither. So after a passkey unlock,
|
||||
`ResolveEntity` answers `ErrNotImplemented` with a `nexus` block configured, and
|
||||
`MCPServers` answers empty with an `mcp` block configured.
|
||||
|
||||
The second is the worse one. Empty is not a degraded answer, it is a wrong answer, and
|
||||
`/tools` renders it as "not configured".
|
||||
|
||||
## Why it drifted
|
||||
|
||||
`wireTelegramIntake` was added to both paths on 06-08-2026 (V-637) and it does use the
|
||||
outer `wg`, at line 519. So the newest line on that path is correct and the older ones
|
||||
around it are not. The path gets touched one line at a time and is never read whole.
|
||||
|
||||
The shape of `cmd/mavend` is what allows that. It is 155 files and 9,551 lines of code.
|
||||
Six things live in it with no seam between them:
|
||||
|
||||
- the handler
|
||||
- the action dispatch
|
||||
- the 19 query sources
|
||||
- the wiring functions
|
||||
- the six background workers
|
||||
- these two boot paths
|
||||
|
||||
Nothing in the package makes the divergence visible.
|
||||
|
||||
## The fix
|
||||
|
||||
Make the two paths call one function instead of listing the same wiring twice.
|
||||
|
||||
One `startBackground(ctx, &wg, deps)` that takes what it needs and starts every worker
|
||||
through `goWorker`. One `newDaemonAPI(deps)` that fills every field, including `nexus` and
|
||||
`getMCPServers`, so a field added later cannot reach one path and miss the other. Both
|
||||
call sites then read as one call each, and a future addition has one place to go.
|
||||
|
||||
Delete the shadowed `wg` at line 529 as part of it.
|
||||
|
||||
## How it is judged
|
||||
|
||||
`make test` stays green.
|
||||
|
||||
The regression that matters is a test asserting the two paths wire the same set. Compare
|
||||
the constructed `daemonAPI` field by field, and assert the worker count started under the
|
||||
outer `wg` matches. Without that, this drifts again the next time a wiring line is added.
|
||||
|
||||
Then confirm on a locked box: unlock by passkey, ask something that needs Nexus, and check
|
||||
`/tools` lists the MCP servers. Both answer wrongly today.
|
||||
|
||||
## Priority
|
||||
|
||||
Latent, not live. `deploy/mavend.json` sets `db_key_env`, so homesrv boots unlocked and
|
||||
takes the correct path. This bites the locked deployment that `docs/operations.md`
|
||||
describes, and it bites silently.
|
||||
@@ -456,9 +456,30 @@ func (c *Config) validate() error {
|
||||
if err := c.validateCapture(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.validateTelegram(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTelegram refuses an intake half that cannot read the chat it is
|
||||
// pointed at. The push half accepts an @channelusername and the intake half
|
||||
// does not, so a box configured with both boots clean, keeps pushing, and
|
||||
// answers nothing — the failure is invisible from the chat. Same shape as
|
||||
// validateNetScan: fail the config rather than the turn.
|
||||
func (c *Config) validateTelegram() error {
|
||||
if c.Telegram == nil || !c.Telegram.Intake {
|
||||
return nil
|
||||
}
|
||||
// An unset ${TELEGRAM_*} expands to empty, and the daemon already reads an
|
||||
// empty token or chat id as telegram not being wired at all. Validating a
|
||||
// block that wires nothing would fail a box that merely has no bot.
|
||||
if c.Telegram.BotToken == "" || c.Telegram.ChatID == "" {
|
||||
return nil
|
||||
}
|
||||
return telegramsink.ValidateIntakeChatID(c.Telegram.ChatID)
|
||||
}
|
||||
|
||||
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
||||
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
||||
// a plaintext store. A configured-but-invalid key is an error (fail closed,
|
||||
|
||||
@@ -466,3 +466,29 @@ func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) {
|
||||
t.Errorf("Health = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramIntakeRefusesNamedChat(t *testing.T) {
|
||||
// The push half accepts an @channelusername and the intake half cannot use
|
||||
// one, so a box with both boots clean and answers nothing. Refuse the
|
||||
// config instead.
|
||||
p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven","intake":true}}`)
|
||||
if _, err := Load(p); err == nil {
|
||||
t.Fatal("Load succeeded for intake with an @-name chat id; want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramNamedChatOKWithoutIntake(t *testing.T) {
|
||||
// Push-only is what the @-name is for, so nothing changes for a box that
|
||||
// never turned intake on.
|
||||
p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven"}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramIntakeAcceptsNumericChat(t *testing.T) {
|
||||
p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"-1001234567890","intake":true}}`)
|
||||
if _, err := Load(p); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,4 +45,17 @@ func TestDeployConfigLoads(t *testing.T) {
|
||||
if cfg.Voice.RouterThreshold <= 0 {
|
||||
t.Error("router threshold did not get its default")
|
||||
}
|
||||
|
||||
// The second reach (V-649). Deleting this block is how you turn ntfy off,
|
||||
// so its absence has to be loud: sev3-away nudges and away reminders route
|
||||
// to ntfy and to nothing else, and a nil sink drops them with no log and no
|
||||
// outbox row. The token is a ${VAR} that CI cannot resolve, so this checks
|
||||
// the wiring and not the credential.
|
||||
if cfg.Ntfy == nil {
|
||||
t.Fatal("deploy config has no ntfy block — sev3-away and away reminders " +
|
||||
"would have nowhere to land, and would vanish silently rather than fail")
|
||||
}
|
||||
if cfg.Ntfy.BaseURL == "" || cfg.Ntfy.Topic == "" {
|
||||
t.Errorf("ntfy block is incomplete: base_url=%q topic=%q", cfg.Ntfy.BaseURL, cfg.Ntfy.Topic)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,17 @@ type WeatherConfig struct {
|
||||
// ToolConfig — one enabled tool. Name is the spoken verb ("restart"); Cmd is
|
||||
// the fixed argv prefix (["systemctl","restart"]); Destructive marks acts that
|
||||
// must not fire from the voice path (they need a confirm on an authed surface).
|
||||
//
|
||||
// Aliases are the spoken phrases that reach this tool, Russian included. They
|
||||
// are config data rather than a pattern in code, and they match as exact leading
|
||||
// tokens, so an imperative reaches the tool and the past tense of the same verb
|
||||
// does not.
|
||||
type ToolConfig struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Cmd []string `json:"cmd"`
|
||||
Destructive bool `json:"destructive,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
}
|
||||
|
||||
// Voice defaults, applied in normaliseVoice.
|
||||
|
||||
@@ -94,20 +94,22 @@ func openTestStore(t *testing.T) *store.Store {
|
||||
|
||||
// attemptStatus reads one attempt row back. Returns ok=false when the row is
|
||||
// gone, which would itself be a broken promise (a dropped attempt).
|
||||
//
|
||||
// It goes through ListDeliveryAttempts rather than raw SQL. This helper used to
|
||||
// reach past the store into store.DB, which was the tell that the outbox was
|
||||
// write-only; the reader landed in V-390 and this caller was not moved over.
|
||||
func attemptStatus(t *testing.T, st *store.Store, id int64) (status string, completed bool, ok bool) {
|
||||
t.Helper()
|
||||
tx, err := st.DB(context.Background())
|
||||
attempts, err := st.ListDeliveryAttempts(context.Background(), "", 200)
|
||||
if err != nil {
|
||||
t.Fatalf("read tx: %v", err)
|
||||
t.Fatalf("ListDeliveryAttempts: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var completedTS *int64
|
||||
err = tx.QueryRowContext(context.Background(),
|
||||
`SELECT status, completed_ts FROM delivery_attempts WHERE id = ?`, id).Scan(&status, &completedTS)
|
||||
if err != nil {
|
||||
return "", false, false
|
||||
for _, a := range attempts {
|
||||
if a.ID == id {
|
||||
return a.Status, a.HasComplete, true
|
||||
}
|
||||
}
|
||||
return status, completedTS != nil, true
|
||||
return "", false, false
|
||||
}
|
||||
|
||||
// TestCrashBetweenBeginAndCompleteBecomesUnknown — simulate the crash window:
|
||||
|
||||
@@ -7,11 +7,17 @@
|
||||
// the relay). the dispatcher already strips detail off away sendables; the
|
||||
// sink uses the same helper so it can't leak the body on its own either.
|
||||
//
|
||||
// ntfy runs locally (docker, 127.0.0.1:8085, deny-all auth). maven publishes
|
||||
// with a dedicated user (write-only to maven-* topics) — the credential is a
|
||||
// delivery-config secret, not a db key; a popped ntfy sink can push spam to
|
||||
// your phone, nothing else. matches the module key-isolation invariant: the
|
||||
// sink never holds the sqlcipher key.
|
||||
// ntfy is a self-hosted server with deny-all auth — ntfy.kvmx.ru as of
|
||||
// 07-08-2026, reached directly, not through the socks relay telegram needs.
|
||||
// maven publishes with a write-only token scoped to its own topic; the
|
||||
// credential is a delivery-config secret, not a db key. a popped ntfy sink
|
||||
// can push spam to that one topic, nothing else — it cannot read the topic
|
||||
// back and it never holds the sqlcipher key.
|
||||
//
|
||||
// this is the second reach, and the reason there is one is that telegram was
|
||||
// the only one (V-649). telegram needs api.telegram.org, a socks relay on the
|
||||
// host and a matching ufw rule, three things in series that have each broken
|
||||
// once. ntfy shares none of them.
|
||||
package ntfysink
|
||||
|
||||
import (
|
||||
@@ -31,11 +37,29 @@ import (
|
||||
// the credential lives in the daemon's config (or a systemd credential),
|
||||
// never in the binary.
|
||||
type Config struct {
|
||||
BaseURL string // e.g. http://127.0.0.1:8085 (no trailing path)
|
||||
Topic string // e.g. maven (all maven notifications land here)
|
||||
Username string // basic auth; empty = anonymous (won't work with deny-all)
|
||||
Password string // basic auth
|
||||
Timeout time.Duration // per-request; 0 = DefaultTimeout
|
||||
// BaseURL — the ntfy server, no trailing path. Required.
|
||||
BaseURL string `json:"base_url"`
|
||||
|
||||
// Topic — where maven publishes. Required. All maven notifications land
|
||||
// on this one topic; severity rides the Priority header, not the topic.
|
||||
Topic string `json:"topic"`
|
||||
|
||||
// Token — an ntfy access token, sent as a bearer. This is the preferred
|
||||
// credential: ntfy scopes a token to a topic and to write-only, so a
|
||||
// popped sink can push to this one topic and cannot read it back or
|
||||
// touch another. Revoking it does not disturb a password anyone else
|
||||
// uses. Mutually exclusive with Username.
|
||||
Token string `json:"token,omitempty"`
|
||||
|
||||
// Username, Password — basic auth, for a server that has no tokens.
|
||||
// Empty username means no credential is sent at all, which a deny-all
|
||||
// server rejects.
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
|
||||
// Timeout — per-request; 0 = DefaultTimeout. A dead server must not hang
|
||||
// the tick loop.
|
||||
Timeout time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
const DefaultTimeout = 10 * time.Second
|
||||
@@ -59,6 +83,12 @@ func New(cfg Config) (*Sink, error) {
|
||||
if cfg.Topic == "" {
|
||||
return nil, fmt.Errorf("ntfysink: Topic is required")
|
||||
}
|
||||
// Refuse rather than pick. Two credentials configured means someone
|
||||
// intended one of them, and guessing which would send the other nowhere
|
||||
// and leave a working config that is not the one they wrote.
|
||||
if cfg.Token != "" && cfg.Username != "" {
|
||||
return nil, fmt.Errorf("ntfysink: set Token or Username, not both")
|
||||
}
|
||||
to := cfg.Timeout
|
||||
if to == 0 {
|
||||
to = DefaultTimeout
|
||||
@@ -84,7 +114,9 @@ func (s *Sink) Send(ctx context.Context, d delivery.Sendable) error {
|
||||
}
|
||||
req.Header.Set("Title", "maven")
|
||||
req.Header.Set("Priority", priorityFor(d).String())
|
||||
if s.cfg.Username != "" {
|
||||
if s.cfg.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.Token)
|
||||
} else if s.cfg.Username != "" {
|
||||
req.SetBasicAuth(s.cfg.Username, s.cfg.Password)
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,37 @@ func TestSendNoAuthWhenUsernameEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendSetsBearerToken — the deployed credential (V-649) is an ntfy access
|
||||
// token scoped write-only to the maven topic, not a password. A token sent as
|
||||
// basic auth is rejected by ntfy, so the header shape is the whole test.
|
||||
func TestSendSetsBearerToken(t *testing.T) {
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
defer srv.Close()
|
||||
|
||||
sink, _ := New(Config{BaseURL: srv.URL, Topic: "maven", Token: "tk_secret"})
|
||||
if err := sink.Send(context.Background(), nudgeSendable(loop.Sev3, "down")); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
_, _, _, auth, _, _ := rs.snapshot()
|
||||
if auth != "Bearer tk_secret" {
|
||||
t.Fatalf("auth: want 'Bearer tk_secret', got %q", auth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRejectsBothCredentials — configuring a token and a username means one
|
||||
// of them was meant and the other is a leftover. Picking either would leave a
|
||||
// server that authenticates against a credential nobody wrote down.
|
||||
func TestNewRejectsBothCredentials(t *testing.T) {
|
||||
_, err := New(Config{BaseURL: "http://x", Topic: "maven", Token: "tk_x", Username: "maven"})
|
||||
if err == nil {
|
||||
t.Fatal("New accepted both a token and a username")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not both") {
|
||||
t.Errorf("error does not say which to fix: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTitleIsMaven(t *testing.T) {
|
||||
rs := newRecordingServer(t, 200, "")
|
||||
srv := httptest.NewServer(rs.handler())
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// botapi.go — the telegram bot API calls the intake half makes, and the inbound
|
||||
// shapes it reads (V-637). Split out of intake.go so the poller reads as the
|
||||
// policy it is, with the wire in one place under it.
|
||||
package telegramsink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// getUpdates long-polls. The offset is telegram's own acknowledgement: asking
|
||||
// for lastSeen+1 is what drops everything before it from the queue, so an
|
||||
// update is handled once even across a restart.
|
||||
func (p *Poller) getUpdates(ctx context.Context, timeoutSec int) ([]update, error) {
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"offset": p.offset,
|
||||
"timeout": timeoutSec,
|
||||
"allowed_updates": []string{"message", "callback_query"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var env struct {
|
||||
telegramResp
|
||||
Result []update `json:"result"`
|
||||
}
|
||||
if err := p.call(ctx, "getUpdates", body, &env); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range env.Result {
|
||||
if u.UpdateID >= p.offset {
|
||||
p.offset = u.UpdateID + 1
|
||||
}
|
||||
}
|
||||
return env.Result, nil
|
||||
}
|
||||
|
||||
func (p *Poller) send(ctx context.Context, text string, kb *inlineKeyboard) error {
|
||||
body, err := json.Marshal(sendMessageReq{
|
||||
ChatID: p.cfgChatID(),
|
||||
Text: text,
|
||||
// A reply to something he just typed is not an alarm, but it is still his
|
||||
// own data in a third party's chat, so it stays unforwardable like the
|
||||
// away messages the sink pushes.
|
||||
ProtectContent: true,
|
||||
ReplyMarkup: kb,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.call(ctx, "sendMessage", body, nil)
|
||||
}
|
||||
|
||||
// answerCallback stops the clock on the tapped button. text empty is a silent
|
||||
// acknowledgement; anything else shows as a toast.
|
||||
func (p *Poller) answerCallback(ctx context.Context, id, text string) {
|
||||
body, err := json.Marshal(map[string]any{"callback_query_id": id, "text": text})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := p.call(ctx, "answerCallbackQuery", body, nil); err != nil {
|
||||
log.Printf("telegram intake: answer callback: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// editKeyboard replaces the buttons under a message the bot sent. kb nil takes
|
||||
// them off.
|
||||
func (p *Poller) editKeyboard(ctx context.Context, chatID string, messageID int64, kb *inlineKeyboard) error {
|
||||
payload := map[string]any{"chat_id": chatID, "message_id": messageID}
|
||||
if kb != nil {
|
||||
payload["reply_markup"] = kb
|
||||
} else {
|
||||
payload["reply_markup"] = inlineKeyboard{Rows: [][]inlineButton{}}
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.call(ctx, "editMessageReplyMarkup", body, nil)
|
||||
}
|
||||
|
||||
// call posts one bot API method and checks the envelope. out may be nil when
|
||||
// only the ok flag matters. Every error goes through the sink's redaction: the
|
||||
// token is in the URL path because telegram accepts it nowhere else, and
|
||||
// net/http prints that URL in transport errors.
|
||||
func (p *Poller) call(ctx context.Context, method string, body []byte, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
p.sink.base+"/bot"+p.sink.cfg.BotToken+"/"+method, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return p.sink.redact(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := p.hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("telegramsink: %s: %w", method, p.sink.redact(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, maxIntakeRespBytes))
|
||||
|
||||
var tr telegramResp
|
||||
if err := json.Unmarshal(rb, &tr); err != nil {
|
||||
return fmt.Errorf("telegramsink: %s: %d with a body that is not the bot API envelope: %s",
|
||||
method, resp.StatusCode, snippet(rb))
|
||||
}
|
||||
if !tr.Ok {
|
||||
return fmt.Errorf("telegramsink: %s: telegram returned error %d: %s",
|
||||
method, tr.ErrorCode, strings.TrimSpace(tr.Description))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(rb, out); err != nil {
|
||||
return fmt.Errorf("telegramsink: %s: decode result: %w", method, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxIntakeRespBytes — a getUpdates batch carries up to 100 messages, so the
|
||||
// send path's cap is too small here. Still bounded: the body is wire-controlled
|
||||
// and a relay sits in front of it.
|
||||
const maxIntakeRespBytes = 4 << 20
|
||||
|
||||
// The inbound shapes, cut to what the poller reads.
|
||||
type update struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *message `json:"message,omitempty"`
|
||||
CallbackQuery *callbackQuery `json:"callback_query,omitempty"`
|
||||
}
|
||||
|
||||
type message struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
Chat chat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type callbackQuery struct {
|
||||
ID string `json:"id"`
|
||||
Data string `json:"data"`
|
||||
Message message `json:"message"`
|
||||
}
|
||||
|
||||
// chat — the id arrives as a JSON number for a user and a string for a channel,
|
||||
// and the config holds whichever was written. json.Number keeps both without
|
||||
// choosing.
|
||||
type chat struct {
|
||||
ID json.Number `json:"id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
func (c chat) idString() string {
|
||||
if s := c.ID.String(); s != "" {
|
||||
return s
|
||||
}
|
||||
if c.Username != "" {
|
||||
return "@" + c.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// inlineKeyboard — the reply_markup shape. Rows of buttons, each carrying
|
||||
// callback data.
|
||||
type inlineKeyboard struct {
|
||||
Rows [][]inlineButton `json:"inline_keyboard"`
|
||||
}
|
||||
|
||||
type inlineButton struct {
|
||||
Text string `json:"text"`
|
||||
Data string `json:"callback_data"`
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// correction.go — the correction gesture as it appears in the chat (V-637).
|
||||
// Two taps at most: "не то" opens the seven intents, and one of them writes the
|
||||
// label. The web's version of the same gesture is cmd/mavweb/chat.go.
|
||||
package telegramsink
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CorrectionTargets — the intents a correction may name, in the order the
|
||||
// buttons are drawn. It mirrors the seven the web offers, and it is a closed
|
||||
// list for the same reason: V-632 fits prototypes from the label table, and a
|
||||
// label nothing can score is worse than no label.
|
||||
var CorrectionTargets = []string{"fact", "note", "reminder", "query", "act", "chat", "system"}
|
||||
|
||||
// correctionKeyboard — the one gesture beside the reply. Nothing when the turn
|
||||
// did not persist: a button that cannot name a row would report a failure the
|
||||
// owner cannot act on.
|
||||
func (p *Poller) correctionKeyboard(traceID int64) *inlineKeyboard {
|
||||
if traceID <= 0 || p.correct == nil {
|
||||
return nil
|
||||
}
|
||||
return &inlineKeyboard{Rows: [][]inlineButton{{
|
||||
{Text: "не то", Data: fmt.Sprintf("%s%d", prefixAsk, traceID)},
|
||||
}}}
|
||||
}
|
||||
|
||||
// targetKeyboard — the seven intents, plus the cheap half kept reachable. He
|
||||
// opened the row without knowing he had to name something, and closing it with
|
||||
// no way out would price the negative he was willing to give.
|
||||
func targetKeyboard(traceID int64) *inlineKeyboard {
|
||||
var rows [][]inlineButton
|
||||
row := []inlineButton{}
|
||||
for _, t := range CorrectionTargets {
|
||||
row = append(row, inlineButton{Text: t, Data: fmt.Sprintf("%s%d:%s", prefixTarget, traceID, t)})
|
||||
if len(row) == 4 {
|
||||
rows, row = append(rows, row), nil
|
||||
}
|
||||
}
|
||||
if len(row) > 0 {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return &inlineKeyboard{Rows: append(rows, []inlineButton{
|
||||
{Text: "просто неверно", Data: fmt.Sprintf("%s%d:", prefixTarget, traceID)},
|
||||
})}
|
||||
}
|
||||
|
||||
// Callback data is capped at 64 bytes by telegram, so it carries the trace id
|
||||
// and the target and nothing else.
|
||||
const (
|
||||
prefixAsk = "w:"
|
||||
prefixTarget = "t:"
|
||||
)
|
||||
|
||||
type callbackKind int
|
||||
|
||||
const (
|
||||
callbackUnknown callbackKind = iota
|
||||
callbackAskTarget
|
||||
callbackTarget
|
||||
)
|
||||
|
||||
// parseCallback reads button data. An unparseable id, or a target that is not
|
||||
// one of the seven, is callbackUnknown — the data came off the wire, and a
|
||||
// label the fitting code cannot score is worse than no label.
|
||||
func parseCallback(data string) (traceID int64, target string, kind callbackKind) {
|
||||
switch {
|
||||
case strings.HasPrefix(data, prefixAsk):
|
||||
id, err := strconv.ParseInt(strings.TrimPrefix(data, prefixAsk), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, "", callbackUnknown
|
||||
}
|
||||
return id, "", callbackAskTarget
|
||||
case strings.HasPrefix(data, prefixTarget):
|
||||
rest := strings.TrimPrefix(data, prefixTarget)
|
||||
idPart, target, ok := strings.Cut(rest, ":")
|
||||
if !ok {
|
||||
return 0, "", callbackUnknown
|
||||
}
|
||||
id, err := strconv.ParseInt(idPart, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, "", callbackUnknown
|
||||
}
|
||||
if target != "" && !isCorrectionTarget(target) {
|
||||
return 0, "", callbackUnknown
|
||||
}
|
||||
return id, target, callbackTarget
|
||||
}
|
||||
return 0, "", callbackUnknown
|
||||
}
|
||||
|
||||
func isCorrectionTarget(s string) bool {
|
||||
for _, t := range CorrectionTargets {
|
||||
if t == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package telegramsink
|
||||
|
||||
import "testing"
|
||||
|
||||
// Button data comes off the wire. An unparseable id or an intent that is not one
|
||||
// of the seven must not reach the label table V-632 fits prototypes from.
|
||||
func TestParseCallbackRejectsWhatCannotBeALabel(t *testing.T) {
|
||||
for _, data := range []string{
|
||||
"", "nonsense", "w:", "w:0", "w:-3", "w:abc",
|
||||
"t:77", "t:0:note", "t:abc:note", "t:77:погода", "t:77:fact:extra",
|
||||
} {
|
||||
if _, _, kind := parseCallback(data); kind != callbackUnknown {
|
||||
t.Errorf("%q was accepted, want callbackUnknown", data)
|
||||
}
|
||||
}
|
||||
if id, target, kind := parseCallback("t:77:reminder"); id != 77 || target != "reminder" || kind != callbackTarget {
|
||||
t.Errorf("got %d %q %v, want the reminder correction", id, target, kind)
|
||||
}
|
||||
}
|
||||
|
||||
// Every intent the web offers has a button here, so a new intent cannot exist
|
||||
// with no way to correct a chat turn into it.
|
||||
func TestIntakeTargetsAreTheSeven(t *testing.T) {
|
||||
if len(CorrectionTargets) != 7 {
|
||||
t.Fatalf("%d targets, want the seven public intents", len(CorrectionTargets))
|
||||
}
|
||||
if isCorrectionTarget("") {
|
||||
t.Error("empty is the absence of a target, not one of them")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// intake.go — the inbound half of the telegram channel (V-637).
|
||||
//
|
||||
// Until this file, telegram was a reach and nothing else: the sink pushes an
|
||||
// away message and the chat has no way to answer. That made the correction
|
||||
// gesture (V-630) reachable from the web and from voice only, and the sample of
|
||||
// labels skews to wherever the owner happens to be standing.
|
||||
//
|
||||
// Long-poll getUpdates, not a webhook. The box takes no inbound connections and
|
||||
// it reaches api.telegram.org through a relay, so the direction of the
|
||||
// connection has to stay outbound. The poller is off unless the telegram block
|
||||
// says intake, and it accepts messages from exactly one chat.
|
||||
package telegramsink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// longPollSeconds — how long telegram holds an empty getUpdates open. The HTTP
|
||||
// client's own timeout has to sit above it or every poll ends as a transport
|
||||
// error, which is why the poller does not reuse the sink's client.
|
||||
const longPollSeconds = 25
|
||||
|
||||
// pollBackoff — the wait after a failed poll. The relay going down is the
|
||||
// normal cause and it comes back on its own, so this is a quiet retry rather
|
||||
// than an escalation.
|
||||
const pollBackoff = 15 * time.Second
|
||||
|
||||
// Turn runs one utterance as a turn and reports the reply and the persisted
|
||||
// trace id. traceID 0 means nothing persisted, and then the reply carries no
|
||||
// correction buttons — there is no row for them to point at.
|
||||
type Turn func(ctx context.Context, conversation, text string) (reply string, traceID int64, err error)
|
||||
|
||||
// Correct records the owner's correction of one turn. shouldBe empty is the
|
||||
// cheap half of the gesture: wrong, target unstated.
|
||||
type Correct func(ctx context.Context, traceID int64, shouldBe string) error
|
||||
|
||||
// Poller reads the configured chat and answers in it. One per daemon.
|
||||
type Poller struct {
|
||||
sink *Sink
|
||||
turn Turn
|
||||
correct Correct
|
||||
hc *http.Client
|
||||
offset int64
|
||||
}
|
||||
|
||||
// ValidateIntakeChatID refuses a chat id the intake half cannot use. The push
|
||||
// half accepts @channelusername as a destination. The intake half cannot: an
|
||||
// inbound update names its chat by numeric id, so an @-name would match nothing
|
||||
// and the poller would read the chat and answer none of it. Config validation
|
||||
// calls this, so the box refuses to boot rather than running a dead reach —
|
||||
// NewPoller returning an error is too late, because the daemon is already up.
|
||||
func ValidateIntakeChatID(chatID string) error {
|
||||
id := strings.TrimSpace(chatID)
|
||||
if id == "" {
|
||||
return errors.New("telegramsink: intake needs a chat id")
|
||||
}
|
||||
digits := strings.TrimPrefix(id, "-")
|
||||
if digits == "" || strings.TrimLeft(digits, "0123456789") != "" {
|
||||
return fmt.Errorf("telegramsink: intake needs the numeric chat id, not %s", chatID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPoller builds the intake half around an already-validated sink, so the
|
||||
// token, the base URL and the relay are resolved in one place. turn is
|
||||
// required; correct may be nil, and then the reply carries no buttons.
|
||||
func NewPoller(s *Sink, turn Turn, correct Correct) (*Poller, error) {
|
||||
if s == nil {
|
||||
return nil, errors.New("telegramsink: intake needs a sink")
|
||||
}
|
||||
if turn == nil {
|
||||
return nil, errors.New("telegramsink: intake needs a turn handler")
|
||||
}
|
||||
if err := ValidateIntakeChatID(s.cfg.ChatID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The sink's transport already carries the relay. Only the timeout differs,
|
||||
// and it has to clear the long poll.
|
||||
hc := &http.Client{
|
||||
Timeout: (longPollSeconds + 10) * time.Second,
|
||||
Transport: s.hc.Transport,
|
||||
}
|
||||
return &Poller{sink: s, turn: turn, correct: correct, hc: hc}, nil
|
||||
}
|
||||
|
||||
// Run polls until the context ends. It never returns an error: a chat that
|
||||
// cannot be read is a degraded reach, not a reason to stop the daemon.
|
||||
func (p *Poller) Run(ctx context.Context) {
|
||||
p.discardBacklog(ctx)
|
||||
log.Printf("telegram intake: reading chat %s", p.sink.cfg.ChatID)
|
||||
for ctx.Err() == nil {
|
||||
updates, err := p.getUpdates(ctx, longPollSeconds)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("telegram intake: poll: %v", err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(pollBackoff):
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, u := range updates {
|
||||
p.handle(ctx, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// discardBacklog moves the offset past whatever is already queued, without
|
||||
// acting on any of it.
|
||||
//
|
||||
// Telegram holds undelivered updates for 24 hours, so a daemon that was down
|
||||
// overnight would otherwise wake up and answer every question in order. A
|
||||
// question asked eight hours ago has been answered by the owner himself or has
|
||||
// stopped mattering, and a reminder set from it would land at the wrong time.
|
||||
// Missing it is the safe direction.
|
||||
func (p *Poller) discardBacklog(ctx context.Context) {
|
||||
// getUpdates returns at most 100 per call, so one call is not the queue. The
|
||||
// loop is bounded rather than "until empty": the timeout is 0, so an instance
|
||||
// that keeps handing back a full batch would spin, and a thousand skipped
|
||||
// messages is already a box that was down for a long time.
|
||||
skipped := 0
|
||||
for range 10 {
|
||||
updates, err := p.getUpdates(ctx, 0)
|
||||
if err != nil {
|
||||
// Not fatal. The offset stays where it was, so the first real poll sees
|
||||
// what is left and answers it late. Say so rather than hide it.
|
||||
log.Printf("telegram intake: could not skip the backlog, old messages may be answered: %v", err)
|
||||
return
|
||||
}
|
||||
skipped += len(updates)
|
||||
if len(updates) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if skipped > 0 {
|
||||
log.Printf("telegram intake: skipped %d message(s) queued while the daemon was down", skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// handle dispatches one update. Anything that is neither a message from the
|
||||
// owner's chat nor a callback on one of Maven's own keyboards is dropped in
|
||||
// silence: a reply to a stranger confirms the bot exists and who it belongs to.
|
||||
func (p *Poller) handle(ctx context.Context, u update) {
|
||||
switch {
|
||||
case u.CallbackQuery != nil:
|
||||
p.onCallback(ctx, u.CallbackQuery)
|
||||
case u.Message != nil:
|
||||
p.onMessage(ctx, u.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Poller) onMessage(ctx context.Context, m *message) {
|
||||
text := strings.TrimSpace(m.Text)
|
||||
if text == "" || !p.fromOwner(m.Chat.idString()) {
|
||||
return
|
||||
}
|
||||
// The conversation id keys the dialogue, so a clarify question asked in the
|
||||
// chat is not answered by an utterance typed on the web.
|
||||
reply, traceID, err := p.turn(ctx, "telegram:"+m.Chat.idString(), text)
|
||||
if err != nil {
|
||||
log.Printf("telegram intake: turn: %v", err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(reply) == "" {
|
||||
return
|
||||
}
|
||||
if err := p.send(ctx, reply, p.correctionKeyboard(traceID)); err != nil {
|
||||
log.Printf("telegram intake: reply: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// onCallback handles a tap on a correction button. Every path from the owner
|
||||
// answers the callback: telegram spins a clock on the button until it is
|
||||
// answered, and an unanswered tap reads as a gesture that was dropped. A tap
|
||||
// from anyone else gets silence, the same as a message from a stranger.
|
||||
func (p *Poller) onCallback(ctx context.Context, cb *callbackQuery) {
|
||||
if !p.fromOwner(cb.Message.Chat.idString()) {
|
||||
return
|
||||
}
|
||||
traceID, target, kind := parseCallback(cb.Data)
|
||||
if kind == callbackUnknown || p.correct == nil {
|
||||
p.answerCallback(ctx, cb.ID, "")
|
||||
return
|
||||
}
|
||||
// A tap on "не то" only opens the second row. Nothing is written yet: the
|
||||
// target is worth much more than the negative, so he gets the chance to name
|
||||
// it before the gesture is spent.
|
||||
if kind == callbackAskTarget {
|
||||
p.answerCallback(ctx, cb.ID, "")
|
||||
if err := p.editKeyboard(ctx, cb.Message.Chat.idString(), cb.Message.MessageID, targetKeyboard(traceID)); err != nil {
|
||||
log.Printf("telegram intake: open the target row: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := p.correct(ctx, traceID, target); err != nil {
|
||||
log.Printf("telegram intake: correct turn %d: %v", traceID, err)
|
||||
p.answerCallback(ctx, cb.ID, "не записалось")
|
||||
return
|
||||
}
|
||||
p.answerCallback(ctx, cb.ID, "записала")
|
||||
// The buttons come off, because the correction is given and a live keyboard
|
||||
// on an answered turn invites correcting it twice.
|
||||
if err := p.editKeyboard(ctx, cb.Message.Chat.idString(), cb.Message.MessageID, nil); err != nil {
|
||||
log.Printf("telegram intake: clear the keyboard: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fromOwner — one chat, and it is the one the sink already sends to. Telegram
|
||||
// chat ids are not guessable, but they are also not secret: they travel in
|
||||
// every forwarded message. So this is the whole authorisation and it is an
|
||||
// allowlist of one.
|
||||
func (p *Poller) fromOwner(chatID string) bool {
|
||||
return chatID != "" && chatID == p.cfgChatID()
|
||||
}
|
||||
|
||||
func (p *Poller) cfgChatID() string { return strings.TrimSpace(p.sink.cfg.ChatID) }
|
||||
@@ -0,0 +1,216 @@
|
||||
package telegramsink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The turn he types in the chat is the turn the web would run, and the reply
|
||||
// carries the one gesture beside it.
|
||||
func TestIntakeRunsTheTurnAndOffersTheCorrection(t *testing.T) {
|
||||
b := newFakeBot(t)
|
||||
rec := &recorder{reply: "поняла", traceID: 91}
|
||||
p := newTestPoller(t, b, rec)
|
||||
|
||||
p.handle(context.Background(), msg(ownerChat, " поужинал "))
|
||||
|
||||
if got := rec.took(); len(got) != 1 || got[0] != "поужинал" {
|
||||
t.Fatalf("turns %q, want the trimmed utterance once", got)
|
||||
}
|
||||
// The dialogue is keyed per chat, so a clarify asked here is not answered on
|
||||
// the web.
|
||||
if rec.conversation != "telegram:"+ownerChat {
|
||||
t.Errorf("conversation %q does not name the chat", rec.conversation)
|
||||
}
|
||||
sends := b.called("sendMessage")
|
||||
if len(sends) != 1 {
|
||||
t.Fatalf("%d sends, want 1", len(sends))
|
||||
}
|
||||
if sends[0].body["text"] != "поняла" {
|
||||
t.Errorf("sent %v, want the reply", sends[0].body["text"])
|
||||
}
|
||||
if sends[0].body["protect_content"] != true {
|
||||
t.Error("his own data went out forwardable")
|
||||
}
|
||||
kb, _ := json.Marshal(sends[0].body["reply_markup"])
|
||||
if !strings.Contains(string(kb), "w:91") {
|
||||
t.Errorf("keyboard %s does not point at the turn's trace", kb)
|
||||
}
|
||||
}
|
||||
|
||||
// A turn nothing persisted has no row to correct, and a button that would name
|
||||
// one reports a failure he cannot act on.
|
||||
func TestIntakeSkipsTheGestureWithNoTrace(t *testing.T) {
|
||||
b := newFakeBot(t)
|
||||
p := newTestPoller(t, b, &recorder{reply: "поняла", traceID: 0})
|
||||
|
||||
p.handle(context.Background(), msg(ownerChat, "привет"))
|
||||
|
||||
sends := b.called("sendMessage")
|
||||
if len(sends) != 1 {
|
||||
t.Fatalf("%d sends, want 1", len(sends))
|
||||
}
|
||||
if _, ok := sends[0].body["reply_markup"]; ok {
|
||||
t.Error("offered a correction on a turn with no trace")
|
||||
}
|
||||
}
|
||||
|
||||
// One chat, and a stranger is not answered at all: a reply confirms the bot
|
||||
// exists and whose it is.
|
||||
func TestIntakeIgnoresAnyOtherChat(t *testing.T) {
|
||||
b := newFakeBot(t)
|
||||
rec := &recorder{reply: "поняла", traceID: 5}
|
||||
p := newTestPoller(t, b, rec)
|
||||
|
||||
p.handle(context.Background(), msg("9999", "включи свет"))
|
||||
p.handle(context.Background(), update{UpdateID: 8, CallbackQuery: &callbackQuery{
|
||||
ID: "cb", Data: "t:5:note", Message: message{Chat: chat{ID: json.Number("9999")}},
|
||||
}})
|
||||
|
||||
if got := rec.took(); len(got) != 0 {
|
||||
t.Errorf("ran %q for a chat that is not the owner's", got)
|
||||
}
|
||||
if len(rec.corrections) != 0 {
|
||||
t.Errorf("wrote %v from a chat that is not the owner's", rec.corrections)
|
||||
}
|
||||
if len(b.calls) != 0 {
|
||||
t.Errorf("answered a stranger: %v", b.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Tapping "не то" opens the seven and writes nothing yet. The target is worth
|
||||
// much more than the negative, so it must not be spent before he can name it.
|
||||
func TestIntakeFirstTapOnlyOpensTheTargets(t *testing.T) {
|
||||
b := newFakeBot(t)
|
||||
rec := &recorder{}
|
||||
p := newTestPoller(t, b, rec)
|
||||
|
||||
p.handle(context.Background(), update{UpdateID: 9, CallbackQuery: &callbackQuery{
|
||||
ID: "cb", Data: "w:77", Message: message{MessageID: 11, Chat: chat{ID: json.Number(ownerChat)}},
|
||||
}})
|
||||
|
||||
if len(rec.corrections) != 0 {
|
||||
t.Fatalf("wrote %v before he named a target", rec.corrections)
|
||||
}
|
||||
if len(b.called("answerCallbackQuery")) != 1 {
|
||||
t.Error("left the clock spinning on the button")
|
||||
}
|
||||
edits := b.called("editMessageReplyMarkup")
|
||||
if len(edits) != 1 {
|
||||
t.Fatalf("%d edits, want the target row", len(edits))
|
||||
}
|
||||
kb, _ := json.Marshal(edits[0].body["reply_markup"])
|
||||
for _, want := range CorrectionTargets {
|
||||
if !strings.Contains(string(kb), `"`+want+`"`) {
|
||||
t.Errorf("target row %s is missing %s", kb, want)
|
||||
}
|
||||
}
|
||||
// And the way out, because he opened the row without knowing he had to name
|
||||
// anything.
|
||||
if !strings.Contains(string(kb), `"t:77:"`) {
|
||||
t.Errorf("target row %s prices out the untargeted negative", kb)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntakeWritesTheCorrection(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, data, want string
|
||||
}{
|
||||
{"with a target", "t:77:note", "note"},
|
||||
{"untargeted", "t:77:", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
b := newFakeBot(t)
|
||||
rec := &recorder{}
|
||||
p := newTestPoller(t, b, rec)
|
||||
|
||||
p.handle(context.Background(), update{UpdateID: 9, CallbackQuery: &callbackQuery{
|
||||
ID: "cb", Data: tc.data, Message: message{MessageID: 11, Chat: chat{ID: json.Number(ownerChat)}},
|
||||
}})
|
||||
|
||||
if len(rec.corrections) != 1 || rec.corrections[0] != (correction{77, tc.want}) {
|
||||
t.Fatalf("corrections %v, want trace 77 → %q", rec.corrections, tc.want)
|
||||
}
|
||||
// The buttons come off once the gesture is given.
|
||||
edits := b.called("editMessageReplyMarkup")
|
||||
if len(edits) != 1 {
|
||||
t.Fatalf("%d edits, want the keyboard cleared", len(edits))
|
||||
}
|
||||
kb, _ := json.Marshal(edits[0].body["reply_markup"])
|
||||
if strings.Contains(string(kb), "t:77") {
|
||||
t.Errorf("keyboard %s still invites a second correction", kb)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A write that failed says so on the button. Silence would read as recorded.
|
||||
func TestIntakeSaysWhenTheLabelDidNotLand(t *testing.T) {
|
||||
b := newFakeBot(t)
|
||||
rec := &recorder{correctErr: errors.New("no such routing trace")}
|
||||
p := newTestPoller(t, b, rec)
|
||||
|
||||
p.handle(context.Background(), update{UpdateID: 9, CallbackQuery: &callbackQuery{
|
||||
ID: "cb", Data: "t:77:fact", Message: message{MessageID: 11, Chat: chat{ID: json.Number(ownerChat)}},
|
||||
}})
|
||||
|
||||
answers := b.called("answerCallbackQuery")
|
||||
if len(answers) != 1 || answers[0].body["text"] == "" {
|
||||
t.Fatalf("answers %v, want a toast saying it did not land", answers)
|
||||
}
|
||||
if len(b.called("editMessageReplyMarkup")) != 0 {
|
||||
t.Error("cleared the buttons after a failed write, so he cannot try again")
|
||||
}
|
||||
}
|
||||
|
||||
// A question asked while the daemon was down has been answered by him or has
|
||||
// stopped mattering, and a reminder set from it would land at the wrong time.
|
||||
func TestIntakeDiscardsTheBacklog(t *testing.T) {
|
||||
b := newFakeBot(t, []update{msg(ownerChat, "напомни в 7 позвонить маме")})
|
||||
rec := &recorder{reply: "поняла", traceID: 3}
|
||||
p := newTestPoller(t, b, rec)
|
||||
|
||||
p.discardBacklog(context.Background())
|
||||
|
||||
if got := rec.took(); len(got) != 0 {
|
||||
t.Errorf("answered %q from the overnight queue", got)
|
||||
}
|
||||
// And the offset moved past it, so the next poll does not see it again.
|
||||
if p.offset != 8 {
|
||||
t.Errorf("offset %d, want the skipped update acknowledged", p.offset)
|
||||
}
|
||||
}
|
||||
|
||||
// The poller does not start without somewhere to send the turn.
|
||||
func TestNewPollerNeedsATurn(t *testing.T) {
|
||||
sink, err := New(Config{BotToken: "t", ChatID: ownerChat})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := NewPoller(sink, nil, nil); err == nil {
|
||||
t.Error("built a poller that reads the chat and answers nothing")
|
||||
}
|
||||
if _, err := NewPoller(nil, func(context.Context, string, string) (string, int64, error) {
|
||||
return "", 0, nil
|
||||
}, nil); err == nil {
|
||||
t.Error("built a poller with no sink to answer through")
|
||||
}
|
||||
}
|
||||
|
||||
// A chat id the intake half cannot match is refused before anything reads the
|
||||
// chat. Config validation calls the same check, so this is the boot error.
|
||||
func TestValidateIntakeChatID(t *testing.T) {
|
||||
for _, ok := range []string{"123", "-1001234567890", " 42 "} {
|
||||
if err := ValidateIntakeChatID(ok); err != nil {
|
||||
t.Errorf("ValidateIntakeChatID(%q): %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "@maven", "-", "12a", "1 2"} {
|
||||
if err := ValidateIntakeChatID(bad); err == nil {
|
||||
t.Errorf("ValidateIntakeChatID(%q) accepted; want error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// intakeharness_test.go — a fake bot API and a recorder for what the poller
|
||||
// asked the daemon to do. Shared by the intake tests beside it.
|
||||
package telegramsink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeBot stands in for the bot API. It hands out queued updates once, records
|
||||
// every other call, and answers the ok=true envelope the poller checks.
|
||||
type fakeBot struct {
|
||||
mu sync.Mutex
|
||||
updates [][]update // one batch per getUpdates call, then empty
|
||||
calls []botCall
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
type botCall struct {
|
||||
method string
|
||||
body map[string]any
|
||||
}
|
||||
|
||||
func newFakeBot(t *testing.T, batches ...[]update) *fakeBot {
|
||||
t.Helper()
|
||||
b := &fakeBot{updates: batches}
|
||||
b.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
|
||||
b.mu.Lock()
|
||||
b.calls = append(b.calls, botCall{method: method, body: body})
|
||||
var batch []update
|
||||
if method == "getUpdates" && len(b.updates) > 0 {
|
||||
batch, b.updates = b.updates[0], b.updates[1:]
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": batch})
|
||||
}))
|
||||
t.Cleanup(b.srv.Close)
|
||||
return b
|
||||
}
|
||||
|
||||
func (b *fakeBot) called(method string) []botCall {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
var out []botCall
|
||||
for _, c := range b.calls {
|
||||
if c.method == method {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recorder collects what the poller asked the daemon to do.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
turns []string
|
||||
conversation string
|
||||
traceID int64
|
||||
corrections []correction
|
||||
reply string
|
||||
err error
|
||||
correctErr error
|
||||
}
|
||||
|
||||
type correction struct {
|
||||
traceID int64
|
||||
shouldBe string
|
||||
}
|
||||
|
||||
func (r *recorder) turn(_ context.Context, conversation, text string) (string, int64, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.turns = append(r.turns, text)
|
||||
r.conversation = conversation
|
||||
return r.reply, r.traceID, r.err
|
||||
}
|
||||
|
||||
func (r *recorder) correct(_ context.Context, traceID int64, shouldBe string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.corrections = append(r.corrections, correction{traceID, shouldBe})
|
||||
return r.correctErr
|
||||
}
|
||||
|
||||
func (r *recorder) took() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]string(nil), r.turns...)
|
||||
}
|
||||
|
||||
const ownerChat = "4242"
|
||||
|
||||
func newTestPoller(t *testing.T, b *fakeBot, rec *recorder) *Poller {
|
||||
t.Helper()
|
||||
sink, err := New(Config{BotToken: "secret-token", ChatID: ownerChat, BaseURL: b.srv.URL})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := NewPoller(sink, rec.turn, rec.correct)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func msg(chatID, text string) update {
|
||||
return update{UpdateID: 7, Message: &message{
|
||||
MessageID: 11, Text: text, Chat: chat{ID: json.Number(chatID)},
|
||||
}}
|
||||
}
|
||||
@@ -72,6 +72,13 @@ type Config struct {
|
||||
// Timeout — per-request; 0 = DefaultTimeout. a dead relay can't hang the
|
||||
// tick loop.
|
||||
Timeout time.Duration
|
||||
|
||||
// Intake — read the chat as well as write to it (V-637). Off by default,
|
||||
// like the search and weather blocks: a bot that only pushes cannot be
|
||||
// talked into anything, and turning that off has to stay a deletion. When
|
||||
// set, a message from ChatID becomes a turn and its reply carries the
|
||||
// correction gesture. ChatID is the only accepted sender.
|
||||
Intake bool `json:"intake,omitempty"`
|
||||
}
|
||||
|
||||
// Sink — implements delivery.Sink via the telegram bot sendMessage API. one
|
||||
@@ -130,6 +137,11 @@ type sendMessageReq struct {
|
||||
Text string `json:"text"`
|
||||
DisableNotification bool `json:"disable_notification"` // false = ring (always — these are alarms)
|
||||
ProtectContent bool `json:"protect_content"` // true = no forwarding out of chat
|
||||
|
||||
// ReplyMarkup — the inline keyboard, used only by the intake half (V-637):
|
||||
// a reply to a turn he typed carries the correction gesture. nil on every
|
||||
// push the sink sends, and omitted from the wire when nil.
|
||||
ReplyMarkup *inlineKeyboard `json:"reply_markup,omitempty"`
|
||||
}
|
||||
|
||||
// telegramResp — the shape telegram returns. ok=false on logical error with
|
||||
|
||||
@@ -41,6 +41,78 @@ type PendingQuestion struct {
|
||||
Attempts int // questions already asked
|
||||
// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
|
||||
MaxAttempts int
|
||||
// Suspends counts how many times this question has stepped aside for
|
||||
// something he asked instead, and come back on the end of the answer. It is
|
||||
// deliberately NOT an attempt: a side query is not a failed answer, and
|
||||
// charging it a retry is the V-554 shape. See CanResume for why it is
|
||||
// counted at all.
|
||||
Suspends int
|
||||
// Rides counts every turn this question has ridden out on the end of
|
||||
// someone else's reply, over the whole life of the request. Unlike Suspends
|
||||
// it is never reset and never re-based, which is the only property that
|
||||
// matters about it (V-663).
|
||||
Rides int
|
||||
}
|
||||
|
||||
// MaxSuspends — how many times one question may step aside and come back before
|
||||
// she lets the request go (V-654).
|
||||
//
|
||||
// It exists because suspension had no bound of any kind. A side query spends no
|
||||
// attempt, so MaxAttempts never applies to it, and it restarts the 90s clock, so
|
||||
// the TTL never arrives either. Measured on 2026-08-07: one unfilled time slot
|
||||
// rode the end of six consecutive unrelated replies and stopped only when a
|
||||
// seventh turn happened to read as a failed answer.
|
||||
//
|
||||
// Three, matching DefaultMaxAttempts, and for the same reason. Once he has
|
||||
// asked for three other things without touching the question, the likely truth
|
||||
// is that he has moved on and has not said so.
|
||||
const MaxSuspends = 3
|
||||
|
||||
// MaxRides — how many turns one question may ride out on the end of an
|
||||
// unrelated reply, counted over its whole life (V-663).
|
||||
//
|
||||
// MaxSuspends did not move the measurement it was written for. Twenty-six of
|
||||
// 140 turns carried a tail before it landed and twenty-six carried one after.
|
||||
// Every bound on this question is rearmed by something ordinary:
|
||||
//
|
||||
// - The TTL is an inactivity timer, and both noteSuspended and reaskOrGiveUp
|
||||
// restart it, so it cannot arrive while he keeps talking.
|
||||
// - Suspends is zeroed by any turn that reads as an answer, which is where
|
||||
// "спасибо" and "привет" land. It resets before anything is known to have
|
||||
// been filled.
|
||||
// - askRemainingGap builds a fresh question for the second gap, so a reminder
|
||||
// with two gaps gets a new allowance halfway through.
|
||||
//
|
||||
// So Suspends only bites on four strictly consecutive side queries with nothing
|
||||
// chat-like between them, which is not the shape real conversation has. Rides is
|
||||
// the same idea with the resets taken out: set once, incremented, carried
|
||||
// across a re-park, and read by nothing that could lower it.
|
||||
//
|
||||
// The shape it is aimed at is measured, not imagined. In the 2026-08-08 run one
|
||||
// question about a reminder's day rode turns 7 to 13 and ended only because
|
||||
// turn 14 was a new request. Three asides, then two turns that read as failed
|
||||
// answers, then two more asides. The asides spend no attempt and the answers
|
||||
// reset Suspends, so the two bounds take turns being rearmed by the other's
|
||||
// traffic.
|
||||
//
|
||||
// Four, not three. It has to be looser than MaxSuspends or that bound is dead
|
||||
// code, because Rides is never lower than Suspends and would always fire first.
|
||||
//
|
||||
// Do not read this as a fix for the whole ride. It ends the measured one a turn
|
||||
// early and no more. Most of that ride's length is attempts, spent by turns
|
||||
// like "спасибо" and "привет" being read as failed answers to a question about
|
||||
// a day. That is a defect in classifyTurnRole and not in any bound here.
|
||||
const MaxRides = 4
|
||||
|
||||
// CanResume reports whether this question may step aside once more. False ⇒ the
|
||||
// caller lets the request go and says so; it must never simply stop resuming,
|
||||
// because a question dropped in silence reads as one that was answered.
|
||||
//
|
||||
// Two bounds, and they answer different questions. Suspends asks whether he has
|
||||
// walked away from this exchange in the last few turns. Rides asks whether this
|
||||
// question has been riding long enough that the answer is no regardless.
|
||||
func (q *PendingQuestion) CanResume() bool {
|
||||
return q.Suspends < MaxSuspends && q.Rides < MaxRides
|
||||
}
|
||||
|
||||
// Action reads the parked question as the typed action it is assembling
|
||||
|
||||
+21
-2
@@ -724,8 +724,15 @@ type chatReq struct {
|
||||
Conversation string `json:"conversation,omitempty"`
|
||||
}
|
||||
type chatResp struct {
|
||||
Reply string `json:"reply"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reply string `json:"reply"`
|
||||
Source string `json:"source,omitempty"`
|
||||
TraceID int64 `json:"trace_id,omitempty"`
|
||||
}
|
||||
|
||||
// correctTurnReq — the owner correcting one persisted turn (V-630).
|
||||
type correctTurnReq struct {
|
||||
TraceID int64 `json:"trace_id"`
|
||||
ShouldBe string `json:"should_be,omitempty"`
|
||||
}
|
||||
|
||||
// ChatReply — one text turn's answer plus which query source claimed it.
|
||||
@@ -738,6 +745,12 @@ type chatResp struct {
|
||||
type ChatReply struct {
|
||||
Reply string
|
||||
Source string
|
||||
// TraceID is the persisted routing trace for this turn (V-629), and it is
|
||||
// what makes a correction one gesture: the surface already has the id, so
|
||||
// saying "that was wrong" costs a button and no lookup. 0 ⇒ nothing was
|
||||
// persisted, which is a box with no database, and the surface offers no
|
||||
// correction rather than a broken one.
|
||||
TraceID int64
|
||||
}
|
||||
|
||||
type proposeToolReq struct {
|
||||
@@ -937,6 +950,12 @@ var ErrTaskDuplicate = errors.New("ipc: another live task already has this text"
|
||||
// is down" must not read the same to a caller deciding whether to store an id.
|
||||
var ErrNoEntity = errors.New("ipc: no such entity")
|
||||
|
||||
// ErrNoSuchTrace — the turn a correction names is not in routing_traces. Given
|
||||
// a wire twin because it is the expected outcome of correcting a turn older than
|
||||
// the retention bound, and "that turn is gone" and "the database is broken" must
|
||||
// not read the same to the surface offering the gesture.
|
||||
var ErrNoSuchTrace = errors.New("ipc: no such routing trace")
|
||||
|
||||
// ErrTaskResolved — a resolved task is not editable.
|
||||
var ErrTaskResolved = errors.New("ipc: task is resolved")
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package ipc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A cancelled context has to abort a call that is already in flight. It did not
|
||||
// until V-638: call checked ctx once before sending and then blocked in
|
||||
// roundtrip with no connection deadline, so a daemon that read the frame and
|
||||
// never answered parked the caller for as long as the socket stayed open.
|
||||
//
|
||||
// The server here is that daemon: it accepts, reads nothing, replies nothing.
|
||||
|
||||
func deafServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
sock := filepath.Join(t.TempDir(), "deaf.sock")
|
||||
ln, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// Hold it open and say nothing. Closed by the listener cleanup.
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
}
|
||||
}()
|
||||
return sock
|
||||
}
|
||||
|
||||
func TestClientCancelAbortsAReadInFlight(t *testing.T) {
|
||||
c, err := Dial(deafServer(t))
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := c.Ping(ctx)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
// Ping is read-only, so the cancellation is reported as itself rather
|
||||
// than as an ambiguous mutation.
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("got %v, want context.Canceled", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("a cancelled Ping did not return")
|
||||
}
|
||||
}
|
||||
|
||||
// A mutation cancelled while awaiting the reply may already have committed, so
|
||||
// it is ErrAmbiguousOutcome and never a retry. That split is the invariant
|
||||
// internal/ipc/maperr_test.go's neighbours rest on.
|
||||
func TestClientCancelLeavesAMutationAmbiguous(t *testing.T) {
|
||||
c, err := Dial(deafServer(t))
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := c.WriteFact(ctx, WriteFactReq{Key: "water", Value: "drank"})
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, ErrAmbiguousOutcome) {
|
||||
t.Errorf("got %v, want ErrAmbiguousOutcome", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("a cancelled WriteFact did not return")
|
||||
}
|
||||
}
|
||||
|
||||
// The deadline itself, with no cancellation: a call on a context with no
|
||||
// deadline used to have no bound at all. This one has one and must respect it.
|
||||
func TestClientDeadlineBoundsACall(t *testing.T) {
|
||||
c, err := Dial(deafServer(t))
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
start := time.Now()
|
||||
if _, err := c.Ping(ctx); err == nil {
|
||||
t.Fatal("a deaf server answered a Ping")
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||
t.Errorf("Ping took %v, want the context deadline to bound it", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// blockingAPI parks Presence until its context is cancelled and records what
|
||||
// cancelled it. Every other method is the unimplemented floor.
|
||||
type blockingAPI struct {
|
||||
UnimplementedCoreAPI
|
||||
entered chan struct{}
|
||||
err chan error
|
||||
}
|
||||
|
||||
func (b *blockingAPI) Presence(ctx context.Context) (Presence, error) {
|
||||
close(b.entered)
|
||||
<-ctx.Done()
|
||||
b.err <- ctx.Err()
|
||||
return Presence{}, ctx.Err()
|
||||
}
|
||||
|
||||
// serveConn dispatched under context.Background() until V-638, so Close could
|
||||
// only abandon a dispatch in flight and never tell it to stop.
|
||||
func TestServerCloseCancelsADispatchInFlight(t *testing.T) {
|
||||
api := &blockingAPI{entered: make(chan struct{}), err: make(chan error, 1)}
|
||||
srv, err := Listen(filepath.Join(t.TempDir(), "core.sock"), api)
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
served := make(chan struct{})
|
||||
go func() { _ = srv.Serve(); close(served) }()
|
||||
|
||||
cli, err := Dial(srv.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
go func() { _, _ = cli.Presence(context.Background()) }()
|
||||
|
||||
select {
|
||||
case <-api.entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the handler was never dispatched")
|
||||
}
|
||||
|
||||
_ = srv.Close()
|
||||
<-served
|
||||
select {
|
||||
case got := <-api.err:
|
||||
if !errors.Is(got, context.Canceled) {
|
||||
t.Errorf("handler saw %v, want context.Canceled", got)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Close did not cancel the dispatch")
|
||||
}
|
||||
}
|
||||
|
||||
// The watchdog closes the conn, and it races the end of the call: a
|
||||
// cancellation landing as the reply arrives can close a conn the call was
|
||||
// already done with. That is survivable either way, because a write to a closed
|
||||
// socket is errWriteLost and errWriteLost re-dials and retries, so this test
|
||||
// passes with or without the drop in roundtrip's defer. What it pins is that
|
||||
// the recovery is real and costs one round trip at most, never an error the
|
||||
// caller sees.
|
||||
func TestClientSurvivesACancelledCall(t *testing.T) {
|
||||
_, _, cli, _ := newServerWithStore(t)
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go cancel() // races the reply on purpose
|
||||
_, _ = cli.Ping(ctx)
|
||||
cancel()
|
||||
|
||||
if _, err := cli.Ping(context.Background()); err != nil {
|
||||
t.Fatalf("call %d after a cancelled one: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
-13
@@ -26,9 +26,20 @@ type Client struct {
|
||||
conn net.Conn
|
||||
path string // the address as configured, kept for errors and logs
|
||||
addr netaddr.Addr // parsed, so a dropped conn can be re-dialed (core restart)
|
||||
mu sync.Mutex
|
||||
mu sync.Mutex // one request at a time, so a frame and its reply pair up
|
||||
|
||||
// connMu guards the conn field alone, and is held only across an assignment
|
||||
// or a read. It exists so Close and the cancellation watchdog can reach the
|
||||
// connection without waiting for the call that is holding c.mu (V-638).
|
||||
connMu sync.Mutex
|
||||
}
|
||||
|
||||
// defaultCallTimeout bounds a call whose context carries no deadline. It is
|
||||
// the same 120s internal/voice/client.go settles on: long enough for a model
|
||||
// call on a cold resident model, short enough that a daemon which stopped
|
||||
// answering does not park the caller forever.
|
||||
const defaultCallTimeout = 120 * time.Second
|
||||
|
||||
// errWriteLost marks a conn drop while sending the request frame: the request
|
||||
// never reached the server (or the server never saw a complete frame), so
|
||||
// retrying is always safe regardless of method — nothing was applied to
|
||||
@@ -103,11 +114,22 @@ func Dial(path string) (*Client, error) {
|
||||
return &Client{conn: c, path: path, addr: addr}, nil
|
||||
}
|
||||
|
||||
// Close closes the connection out from under a call in flight, on purpose: a
|
||||
// shutdown must not wait out a parked read. It takes connMu and never c.mu, so
|
||||
// it cannot block behind the call it is interrupting.
|
||||
//
|
||||
// The lock is taken and released by hand, around the two field accesses and
|
||||
// nothing else. The socket close happens outside it, because a close on a tcp
|
||||
// conn can block and connMu is on the path of every call.
|
||||
func (c *Client) Close() error {
|
||||
if c.conn == nil {
|
||||
c.connMu.Lock()
|
||||
conn := c.conn
|
||||
c.conn = nil
|
||||
c.connMu.Unlock()
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
return c.conn.Close()
|
||||
return conn.Close()
|
||||
}
|
||||
|
||||
// DialWait is Dial with patience: it retries with capped backoff until the
|
||||
@@ -163,16 +185,26 @@ func (c *Client) call(ctx context.Context, m Method, params, result any) error {
|
||||
}
|
||||
|
||||
var resp Response
|
||||
err := c.roundtrip(m, raw, &resp)
|
||||
err := c.roundtrip(ctx, m, raw, &resp)
|
||||
switch {
|
||||
case errors.Is(err, errWriteLost):
|
||||
// The request never left; a duplicate send can't double-apply.
|
||||
// Redial (roundtrip re-dials on a nil conn) and retry exactly once.
|
||||
err = c.roundtrip(m, raw, &resp)
|
||||
// Not when the caller has given up — a retry would only be a second
|
||||
// frame nobody is waiting for.
|
||||
if ctx.Err() == nil {
|
||||
err = c.roundtrip(ctx, m, raw, &resp)
|
||||
}
|
||||
case errors.Is(err, errReadLost):
|
||||
if readOnlyMethods[m] {
|
||||
if ctx.Err() != nil {
|
||||
// The caller cancelled the read it was waiting for. Nothing
|
||||
// was applied, so this is the cancellation and not an
|
||||
// ambiguity.
|
||||
return ctx.Err()
|
||||
}
|
||||
// A duplicate read can't double-apply either — safe to replay.
|
||||
err = c.roundtrip(m, raw, &resp)
|
||||
err = c.roundtrip(ctx, m, raw, &resp)
|
||||
} else {
|
||||
// The mutation may have already committed server-side. Do not
|
||||
// retry: report the ambiguity instead of guessing.
|
||||
@@ -199,19 +231,55 @@ func (c *Client) call(ctx context.Context, m Method, params, result any) error {
|
||||
// failure is wrapped in errReadLost (ambiguous — call() only retries it for
|
||||
// read-only methods). Either way a failed conn is dropped so the next call
|
||||
// re-dials clean. Caller holds c.mu.
|
||||
func (c *Client) roundtrip(m Method, raw json.RawMessage, resp *Response) error {
|
||||
if c.conn == nil {
|
||||
conn, err := netaddr.Dial(c.addr)
|
||||
//
|
||||
// The connection carries a deadline derived from ctx, falling back to
|
||||
// defaultCallTimeout, and a watchdog closes it if ctx is cancelled mid-call
|
||||
// (V-638). Before that a daemon which stopped answering parked the caller for
|
||||
// as long as the socket stayed open. The watchdog closes the conn rather than
|
||||
// calling drop, because drop wants c.mu and the caller is holding it — the
|
||||
// closed socket fails the read, and roundtrip drops it on the way out.
|
||||
func (c *Client) roundtrip(ctx context.Context, m Method, raw json.RawMessage, resp *Response) error {
|
||||
conn := c.currentConn()
|
||||
if conn == nil {
|
||||
dialed, err := netaddr.Dial(c.addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: dial %s: %v", errWriteLost, c.addr, err)
|
||||
}
|
||||
c.conn = conn
|
||||
c.setConn(dialed)
|
||||
conn = dialed
|
||||
}
|
||||
if err := writeFrame(c.conn, Request{Method: m, Params: raw}); err != nil {
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Now().Add(defaultCallTimeout))
|
||||
}
|
||||
defer conn.SetDeadline(time.Time{})
|
||||
|
||||
// The watchdog and the end of the call race by construction: a cancellation
|
||||
// landing just as the reply arrives can close a conn this call is already
|
||||
// done with, and c.conn would still point at the closed socket. So a call
|
||||
// whose context ended does not leave the conn behind for the next one,
|
||||
// whichever of the two got there first.
|
||||
done := make(chan struct{})
|
||||
defer func() {
|
||||
close(done)
|
||||
if ctx.Err() != nil {
|
||||
c.drop()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = conn.Close()
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
|
||||
if err := writeFrame(conn, Request{Method: m, Params: raw}); err != nil {
|
||||
c.drop()
|
||||
return fmt.Errorf("%w: %v", errWriteLost, err)
|
||||
}
|
||||
if err := readFrame(c.conn, resp); err != nil {
|
||||
if err := readFrame(conn, resp); err != nil {
|
||||
c.drop()
|
||||
return fmt.Errorf("%w: %v", errReadLost, err)
|
||||
}
|
||||
@@ -220,12 +288,26 @@ func (c *Client) roundtrip(m Method, raw json.RawMessage, resp *Response) error
|
||||
|
||||
// drop closes and forgets the current conn so the next call re-dials.
|
||||
func (c *Client) drop() {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) currentConn() net.Conn {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
return c.conn
|
||||
}
|
||||
|
||||
func (c *Client) setConn(conn net.Conn) {
|
||||
c.connMu.Lock()
|
||||
defer c.connMu.Unlock()
|
||||
c.conn = conn
|
||||
}
|
||||
|
||||
// hydrate rehydrates a wire RpcError into the matching package sentinel. The
|
||||
// code↔sentinel table is the only place the wire "knows" about errors; keep it
|
||||
// in sync with codeOf in wire.go.
|
||||
@@ -247,6 +329,8 @@ func hydrate(e *RpcError) error {
|
||||
return fmt.Errorf("%w: %s", ErrReminderState, e.Message)
|
||||
case codeToolNotFound:
|
||||
return fmt.Errorf("%w: %s", ErrToolNotFound, e.Message)
|
||||
case codeNoSuchTrace:
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchTrace, e.Message)
|
||||
case codeUnknownMethod:
|
||||
return fmt.Errorf("%w: %s", ErrUnknownMethod, e.Message)
|
||||
case codeBadParams:
|
||||
@@ -661,7 +745,11 @@ func (c *Client) Chat(ctx context.Context, conversation, text string) (ChatReply
|
||||
if err := c.call(ctx, MethodChat, chatReq{Text: text, Conversation: conversation}, &r); err != nil {
|
||||
return ChatReply{}, err
|
||||
}
|
||||
return ChatReply{Reply: r.Reply, Source: r.Source}, nil
|
||||
return ChatReply{Reply: r.Reply, Source: r.Source, TraceID: r.TraceID}, nil
|
||||
}
|
||||
|
||||
func (c *Client) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
|
||||
return c.call(ctx, MethodCorrectTurn, correctTurnReq{TraceID: traceID, ShouldBe: shouldBe}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
|
||||
@@ -176,6 +176,14 @@ type SystemAPI interface {
|
||||
// has run since the daemon started.
|
||||
TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error)
|
||||
|
||||
// CorrectTurn records that one persisted turn was routed wrongly, and what
|
||||
// it should have been (V-630). shouldBe empty means "wrong, target
|
||||
// unstated", which is a usable negative and must not cost more to give than
|
||||
// the full answer. Unlike TurnDecisions this DOES reach a table, because a
|
||||
// correction is the only supervised signal the box gets and it has to
|
||||
// outlive the trace that carried it.
|
||||
CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error
|
||||
|
||||
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
|
||||
// own table so machine-rate traces never crowd out human-rate facts.
|
||||
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
|
||||
|
||||
@@ -31,6 +31,7 @@ var mapErrPairs = []struct {
|
||||
{"ErrReminderNotFound", store.ErrReminderNotFound, ErrReminderNotFound},
|
||||
{"ErrReminderState", store.ErrReminderState, ErrReminderState},
|
||||
{"ErrToolNotFound", store.ErrToolNotFound, ErrToolNotFound},
|
||||
{"ErrNoSuchTrace", store.ErrNoSuchTrace, ErrNoSuchTrace},
|
||||
{"ErrTaskNoDoneWhen", store.ErrTaskNoDoneWhen, ErrTaskNoDoneWhen},
|
||||
{"ErrTaskDuplicate", store.ErrTaskDuplicate, ErrTaskDuplicate},
|
||||
{"ErrTaskResolved", store.ErrTaskResolved, ErrTaskResolved},
|
||||
|
||||
+40
-9
@@ -17,9 +17,11 @@ import (
|
||||
// Server — the core side of the boundary. Listens on a unix domain socket,
|
||||
// accepts module connections, frames requests to a CoreAPI and responses back.
|
||||
// One Server per daemon process; concurrent connections are handled in their
|
||||
// own goroutine but share the single CoreAPI (and therefore the single store
|
||||
// writer — store is single-connection, SetMaxOpenConns(1), so serialization is
|
||||
// already guaranteed at the db; the Server adds no locking of its own).
|
||||
// own goroutine but share the single CoreAPI, and so the single store writer.
|
||||
// The store opens at SetMaxOpenConns(1), so serialisation is already guaranteed
|
||||
// at the database and the Server adds no locking of its own. That cap is an
|
||||
// invariant this comment depends on, measured and kept on 07-08-2026 (V-642,
|
||||
// docs/evals/2026-08-07-store-connection-cap.md).
|
||||
type Server struct {
|
||||
api atomic.Value // stores CoreAPI
|
||||
path string
|
||||
@@ -30,6 +32,14 @@ type Server struct {
|
||||
done chan struct{}
|
||||
accept sync.Mutex // guards wg.Add vs Close's wg.Wait sequence
|
||||
|
||||
// ctx — server-scoped, cancelled by Close, and the parent of every request
|
||||
// context. serveConn dispatched under context.Background() until V-638, so
|
||||
// a dispatch in flight during shutdown could not be told to stop and the
|
||||
// closeGrace below could only abandon it. Cancelling gives a handler that
|
||||
// respects its context the chance to return instead.
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
// conns — every accepted connection still being served. Close needs these
|
||||
// because closing the listener does nothing to a connection already
|
||||
// accepted: serveConn is parked in readFrame waiting for a peer that may
|
||||
@@ -208,11 +218,14 @@ func Listen(path string, api CoreAPI) (*Server, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Server{
|
||||
path: path,
|
||||
addr: addr,
|
||||
ln: ln,
|
||||
done: make(chan struct{}),
|
||||
path: path,
|
||||
addr: addr,
|
||||
ln: ln,
|
||||
done: make(chan struct{}),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
s.api.Store(api)
|
||||
return s, nil
|
||||
@@ -250,7 +263,10 @@ func (s *Server) Serve() error {
|
||||
|
||||
func (s *Server) serveConn(c net.Conn) {
|
||||
caller, callerOK := peerCaller(c)
|
||||
ctx := context.Background()
|
||||
// Derived from the server's, so Close cancels a dispatch in flight, and
|
||||
// cancelled when this conn ends so nothing a handler spawned outlives it.
|
||||
ctx, cancel := context.WithCancel(s.serverContext())
|
||||
defer cancel()
|
||||
if callerOK {
|
||||
ctx = WithCaller(ctx, caller)
|
||||
}
|
||||
@@ -274,6 +290,15 @@ func (s *Server) serveConn(c net.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
// serverContext is s.ctx, or Background for a Server built as a zero value
|
||||
// rather than by Listen (the wiring tests do that).
|
||||
func (s *Server) serverContext() context.Context {
|
||||
if s.ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return s.ctx
|
||||
}
|
||||
|
||||
func (s *Server) safeDispatch(ctx context.Context, req Request) (result json.RawMessage, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
@@ -514,7 +539,10 @@ var methodTable = map[Method]handlerFunc{
|
||||
}),
|
||||
MethodChat: withParams(func(ctx context.Context, api CoreAPI, p chatReq) (chatResp, error) {
|
||||
reply, err := api.Chat(ctx, p.Conversation, p.Text)
|
||||
return chatResp{Reply: reply.Reply, Source: reply.Source}, err
|
||||
return chatResp{Reply: reply.Reply, Source: reply.Source, TraceID: reply.TraceID}, err
|
||||
}),
|
||||
MethodCorrectTurn: withParams(func(ctx context.Context, api CoreAPI, p correctTurnReq) (struct{}, error) {
|
||||
return struct{}{}, api.CorrectTurn(ctx, p.TraceID, p.ShouldBe)
|
||||
}),
|
||||
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
|
||||
return api.TickTrace(ctx)
|
||||
@@ -717,6 +745,9 @@ func (s *Server) Close() error {
|
||||
default:
|
||||
close(s.done)
|
||||
}
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
err := s.ln.Close()
|
||||
// Closing the listener stops new connections; it does nothing to the ones
|
||||
// already accepted. Close those too, or every serveConn parked in readFrame
|
||||
|
||||
@@ -213,6 +213,13 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
|
||||
}
|
||||
|
||||
// CorrectTurn — unlike TickTrace and TurnDecisions this one is a table, so the
|
||||
// store adapter answers it for real (V-630). A correction has to land whether
|
||||
// the caller reached the daemon or the store directly.
|
||||
func (a *storeAPI) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
|
||||
return mapErr(a.s.CorrectTurn(ctx, traceID, shouldBe, time.Now()))
|
||||
}
|
||||
|
||||
// TurnDecisions — same story as TickTrace: the arbitration record is a daemon
|
||||
// ring, not a table, so there is nothing here to read it from (V-564).
|
||||
func (a *storeAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
|
||||
@@ -412,6 +419,8 @@ func mapErr(err error) error {
|
||||
return ErrReminderState
|
||||
case errors.Is(err, store.ErrToolNotFound):
|
||||
return ErrToolNotFound
|
||||
case errors.Is(err, store.ErrNoSuchTrace):
|
||||
return ErrNoSuchTrace
|
||||
case errors.Is(err, store.ErrTaskNoDoneWhen):
|
||||
return ErrTaskNoDoneWhen
|
||||
case errors.Is(err, store.ErrTaskDuplicate):
|
||||
|
||||
@@ -144,6 +144,10 @@ func (UnimplementedCoreAPI) RevertFact(ctx context.Context, key string) (int64,
|
||||
func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
|
||||
return TickTrace{}, ErrNotImplemented
|
||||
}
|
||||
func (UnimplementedCoreAPI) CorrectTurn(ctx context.Context, traceID int64, shouldBe string) error {
|
||||
return ErrNotImplemented
|
||||
}
|
||||
|
||||
func (UnimplementedCoreAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
|
||||
return nil, ErrNotImplemented
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ const (
|
||||
MethodRevertFact Method = "revert_fact"
|
||||
MethodTickTrace Method = "tick_trace"
|
||||
MethodTurnDecisions Method = "turn_decisions"
|
||||
MethodCorrectTurn Method = "correct_turn"
|
||||
MethodMorningStatus Method = "morning_status"
|
||||
MethodMCPServers Method = "mcp_servers"
|
||||
MethodDayPlan Method = "day_plan"
|
||||
@@ -125,6 +126,7 @@ const (
|
||||
codeReminderMissing = "reminder_not_found"
|
||||
codeReminderState = "reminder_state"
|
||||
codeToolNotFound = "tool_not_found"
|
||||
codeNoSuchTrace = "no_such_trace"
|
||||
codeUnknownMethod = "unknown_method"
|
||||
codeBadParams = "bad_params"
|
||||
codeForbidden = "forbidden"
|
||||
@@ -160,6 +162,8 @@ func codeOf(err error) string {
|
||||
return codeReminderState
|
||||
case errors.Is(err, ErrToolNotFound):
|
||||
return codeToolNotFound
|
||||
case errors.Is(err, ErrNoSuchTrace):
|
||||
return codeNoSuchTrace
|
||||
case errors.Is(err, ErrUnknownMethod):
|
||||
return codeUnknownMethod
|
||||
case errors.Is(err, ErrBadParams):
|
||||
|
||||
@@ -97,6 +97,11 @@ func NarrativeRequests() []string { return words("narrative_requests") }
|
||||
// the set's own note for why this one is a list and not a seed set.
|
||||
func RepairMarkers() []string { return words("repair_markers") }
|
||||
|
||||
// RepairNegatives lists the ways he says the previous turn was wrong without
|
||||
// saying what it should have been. Matched against the whole utterance, never as
|
||||
// substrings — see the set's own note.
|
||||
func RepairNegatives() []string { return words("repair_negatives") }
|
||||
|
||||
// FirstPerson lists every form of the first-person pronoun. Callers use it to
|
||||
// decide that a sentence is about him: internal/router/complaint.go keeps a
|
||||
// complaint out of the fact store unless one of these appears, because losing a
|
||||
@@ -114,6 +119,11 @@ func PartsOfDay() []string { return words("parts_of_day") }
|
||||
// ReminderVerbs returns the imperatives that open a reminder.
|
||||
func ReminderVerbs() []string { return words("reminder_verbs") }
|
||||
|
||||
// Pleasantries returns the whole utterances that greet, thank or say goodbye.
|
||||
// Whole utterances and not tokens: see the set's own note for why the tokens
|
||||
// are unsafe alone.
|
||||
func Pleasantries() []string { return words("pleasantries") }
|
||||
|
||||
// TaskDoneWords returns the words that finish a task, and TaskDropWords the
|
||||
// words that abandon one. Two sets rather than one with a value, because the
|
||||
// store records which of the two happened and the caller has to say so.
|
||||
@@ -135,6 +145,30 @@ func ConfirmNo() []string { return words("confirm_no") }
|
||||
// TaskDropWords — see TaskDoneWords.
|
||||
func TaskDropWords() []string { return words("task_drop_words") }
|
||||
|
||||
// WaterNouns and DrinkVerbs are the two halves of a water fact: he has to name
|
||||
// the drink and the drinking, because "вода" alone is a word about water and
|
||||
// "выпил" alone does not say what. The other four self-care sets need only one
|
||||
// word each. All six are matched over tokens by lemma — except ShowerWords, see
|
||||
// its own note.
|
||||
func WaterNouns() []string { return words("water_nouns") }
|
||||
|
||||
// DrinkVerbs — see WaterNouns.
|
||||
func DrinkVerbs() []string { return words("drink_verbs") }
|
||||
|
||||
// MealWords returns the nouns and verbs of having eaten.
|
||||
func MealWords() []string { return words("meal_words") }
|
||||
|
||||
// ShowerWords returns the shower noun. Match these EXACTLY and not by lemma:
|
||||
// the dictionary makes "душ" and "душа" one word, and only one of them is a
|
||||
// shower. The set's note says why exact matching costs nothing here.
|
||||
func ShowerWords() []string { return words("shower_words") }
|
||||
|
||||
// BreakWords returns the noun and verbs of taking a break.
|
||||
func BreakWords() []string { return words("break_words") }
|
||||
|
||||
// SleepWords returns the verbs of having slept.
|
||||
func SleepWords() []string { return words("sleep_words") }
|
||||
|
||||
// SlotValueFrame returns the words that can surround a bare slot value without
|
||||
// making the utterance a request of its own. A caller strips these (along with
|
||||
// the numbers and the other closed time sets) to see whether an utterance
|
||||
|
||||
@@ -147,6 +147,10 @@
|
||||
"got it wrong", "not a ", "that was wrong"
|
||||
]
|
||||
},
|
||||
"repair_negatives": {
|
||||
"note": "The ways he says she got it wrong WITHOUT saying what it should have been. Matched against the WHOLE utterance, not as substrings, which is what keeps them apart from repair_markers: \u0022\u044d\u0442\u043e \u043d\u0435\u0022 is a fragment that needs an intent word after it, while these are complete sentences. A member that could appear inside an ordinary sentence does not belong here.",
|
||||
"words": ["не так поняла", "неправильно поняла", "ты не поняла", "не поняла меня", "ты ошиблась", "не так", "неправильно", "это неправильно", "that was wrong", "got it wrong", "you got it wrong", "wrong"]
|
||||
},
|
||||
"first_person": {
|
||||
"note": "Every form of the first-person pronoun, plus the English ones. Closed class in the strictest sense: the language has these and no others. A sentence carrying one is about him, which is what makes it a fact rather than a passing complaint.",
|
||||
"words": [
|
||||
@@ -172,11 +176,25 @@
|
||||
"morning", "afternoon", "evening", "night"
|
||||
]
|
||||
},
|
||||
"pleasantries": {
|
||||
"note": "Whole utterances that greet, thank or say goodbye. They ask for nothing and answer nothing, so a parked question must neither consume them as a failed answer nor be dropped by them (V-663). Matched as WHOLE utterances and never as tokens, because the tokens are not safe alone: \"вечер\" answers \"это утра или вечера?\" and \"нет\" answers a confirm. Anything that could fill a slot stays out. The control words (\"стоп\", \"отмена\") stay out too, because isCancel already owns them and they mean something stronger.",
|
||||
"words": [
|
||||
"привет", "приветик", "здравствуй", "здравствуйте",
|
||||
"доброе утро", "добрый день", "добрый вечер",
|
||||
"пока", "прощай", "до свидания", "спокойной ночи",
|
||||
"спасибо", "спасибо тебе", "большое спасибо", "благодарю",
|
||||
"извини", "извините", "прости", "простите",
|
||||
"hi", "hello", "hey", "bye", "goodbye",
|
||||
"good morning", "good evening", "good night",
|
||||
"thanks", "thank you", "thanks a lot", "sorry"
|
||||
]
|
||||
},
|
||||
"reminder_verbs": {
|
||||
"note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530).",
|
||||
"note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530). The alarm verbs joined them in V-627. \"разбуди меня в 6:30\" is a reminder that fires at the hour he gets up, and the set knew no form of it, so an alarm reached IntentReminder only by resembling one to the embedder.",
|
||||
"words": [
|
||||
"напомни", "напомните", "напомнить", "напоминай",
|
||||
"remind"
|
||||
"разбуди", "разбудите", "разбудить", "буди",
|
||||
"remind", "wake"
|
||||
]
|
||||
},
|
||||
"half_hour": {
|
||||
@@ -252,6 +270,50 @@
|
||||
"yes", "yeah", "yep", "yup", "ok", "okay", "sure", "confirm", "affirmative"
|
||||
]
|
||||
},
|
||||
"water_nouns": {
|
||||
"note": "The water noun, in the forms he drinks it in, plus English (V-586). Closed because it is one noun: Russian gives it six cases and two numbers and that is the whole list. Both \"вода\" and \"водой\" are listed even though one declension covers both, because the vendored dictionary lemmatises \"воды\" to \"вод\" and \"водой\" to \"вода\" — two lemmas for one noun, so the set has to name both or a caller matching by lemma misses half of them. Matched over tokens with morph.SameWord, never as a substring: the \"вод\" this replaced fired on \"водитель\" and \"заводить\".",
|
||||
"words": [
|
||||
"вода", "водой", "водичка", "water"
|
||||
]
|
||||
},
|
||||
"drink_verbs": {
|
||||
"note": "Drinking, in the aspects and prefixes he speaks (V-586). Closed in the sense that matters: these are the verbs that make a water noun a water FACT, and the list is her vocabulary rather than a discovery about Russian. \"пил\" and \"пили\" are listed as surface forms because the dictionary lemmatises them to \"пила\", the saw; the perfective forms lemmatise correctly and one member each covers them. Whole tokens only — the substring \"пил\" this replaced fired on \"пилот\".",
|
||||
"words": [
|
||||
"пить", "пил", "пили", "пей", "выпить", "попить", "допить", "запить",
|
||||
"drink", "drank", "drinking"
|
||||
]
|
||||
},
|
||||
"meal_words": {
|
||||
"note": "Eating: the meal nouns and the verbs of having one (V-586). Closed the same way capture_verbs is — these are the words that write a meal fact, decided here. The verbs are listed in the infinitive because that is the lemma the dictionary returns, so \"поужинал\" and \"позавтракал\" match without their own entries. \"есть\" and \"ел\" are deliberately ABSENT: \"есть\" is also the existential, and \"есть новости по бэкапу\" is a question rather than a meal. The English \"ate\" carried a guard against \"backup\" when this was a substring test; over tokens the guard is unnecessary.",
|
||||
"words": [
|
||||
"обед", "обедать", "пообедать",
|
||||
"ужин", "ужинать", "поужинать",
|
||||
"завтрак", "завтракать", "позавтракать",
|
||||
"еда", "перекус", "перекусить",
|
||||
"поесть", "кушать", "покушать",
|
||||
"meal", "ate", "lunch", "dinner", "breakfast"
|
||||
]
|
||||
},
|
||||
"shower_words": {
|
||||
"note": "The shower, and the one set here matched EXACTLY rather than by lemma (V-586). The dictionary lemmatises \"душ\" to \"душа\", so a lemma test cannot tell a shower from a soul, and \"на душе легко\" is not a fact about washing. The accusative of an inanimate noun is its nominative, so \"принял душ\" and \"сходил в душ\" are both the bare form and exact matching loses nothing he actually says. The substring this replaced also fired on \"душно\".",
|
||||
"words": [
|
||||
"душ", "душем", "shower", "showered"
|
||||
]
|
||||
},
|
||||
"break_words": {
|
||||
"note": "Taking a break, noun and verb (V-586). Closed like meal_words and for the same reason. \"отдых\" and \"отдыхать\" are both listed because the noun and the verb are separate lemmas; the perfective \"отдохнул\" lemmatises to \"отдохнуть\".",
|
||||
"words": [
|
||||
"перерыв", "отдых", "отдыхать", "отдохнуть", "передохнуть",
|
||||
"break", "rest"
|
||||
]
|
||||
},
|
||||
"sleep_words": {
|
||||
"note": "Sleeping (V-586). The imperfective surface forms \"спал\" and \"спала\" are listed because the dictionary lemmatises them to \"спасть\", a different verb, and one entry for the pair is the honest fix; the prefixed forms lemmatise consistently and their infinitives cover them. \"сон\" is absent: the noun names a dream as readily as a night's sleep, and it was not in the pattern this replaces either.",
|
||||
"words": [
|
||||
"спать", "спал", "поспать", "поспал", "проспать", "выспаться",
|
||||
"sleep", "slept", "sleeping"
|
||||
]
|
||||
},
|
||||
"confirm_no": {
|
||||
"note": "The answers that decline a parked confirm. Same matching rule as confirm_yes and the same reason. The multi-word members are here rather than assembled by a caller because \"надо\" alone is not an answer and \"не надо\" is the opposite of one: the two must land on opposite sides, and only the phrase says which. \"не\" on its own is NOT a member — \"не забудь купить хлеб\" is a reminder, not a refusal.",
|
||||
"words": [
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Package modes holds the routing mode inventory: the roughly thirty distinct
|
||||
// downstream behaviours mavend has, each mapped back to one of the seven public
|
||||
// intents (V-631, umbrella V-628).
|
||||
//
|
||||
// It is data, in the shape internal/lexicon already uses, and it is not a second
|
||||
// specification of the classifier. Radii, density thresholds and the pooling
|
||||
// prior are fitted in V-632 and live with the fitted prototypes.
|
||||
//
|
||||
// Two rules decide whether something is a mode. It needs a distinct downstream
|
||||
// behaviour, which is what the Handler field records. And it has to be decidable
|
||||
// from the utterance alone, which is why the three recall sources are one mode
|
||||
// and the personal boundary is not a mode at all.
|
||||
package modes
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
//go:embed modes_v1.json
|
||||
var files embed.FS
|
||||
|
||||
// Mode is one routing class.
|
||||
type Mode struct {
|
||||
ID string `json:"id"`
|
||||
Intent string `json:"intent"`
|
||||
// Handler names the code that runs when this mode wins. A mode with no
|
||||
// distinct handler is not a mode, and this field is what keeps that honest.
|
||||
Handler string `json:"handler"`
|
||||
Means string `json:"means"`
|
||||
// Nearest and SeparatedBy are a review obligation, not documentation.
|
||||
// Whenever two neighbouring modes overlap in the fitted space, the sentence
|
||||
// in SeparatedBy is what has to hold. If nothing separates them, they were
|
||||
// one mode and this file is wrong.
|
||||
Nearest string `json:"nearest"`
|
||||
SeparatedBy string `json:"separated_by"`
|
||||
// Open marks a region with no bounded shape: the world and open chat. Those
|
||||
// carry RejectPolicy, and nothing else may.
|
||||
Open bool `json:"open"`
|
||||
RejectPolicy string `json:"reject_policy,omitempty"`
|
||||
PrototypeCount int `json:"prototype_count"`
|
||||
MinSeedExamples int `json:"min_seed_examples"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Examples []string `json:"examples"`
|
||||
}
|
||||
|
||||
// Inventory is the whole file. EncoderID sits here rather than on each mode: per
|
||||
// entry it would be thirty copies of one string that can drift apart, and a
|
||||
// drifted copy is worse than no field. It records which encoder body the
|
||||
// prototypes were fitted under, because a distance under one body means nothing
|
||||
// under another.
|
||||
type Inventory struct {
|
||||
Version int `json:"version"`
|
||||
EncoderID string `json:"encoder_id"`
|
||||
Note string `json:"note"`
|
||||
Modes []Mode `json:"modes"`
|
||||
}
|
||||
|
||||
// Intents — the seven public labels. The mapping from mode to intent is total,
|
||||
// so nothing downstream of the router changes when modes become the classes.
|
||||
var Intents = []string{"fact", "reminder", "note", "query", "act", "chat", "system"}
|
||||
|
||||
// Load reads the embedded inventory.
|
||||
func Load() (*Inventory, error) {
|
||||
b, err := files.ReadFile("modes_v1.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("modes: read: %w", err)
|
||||
}
|
||||
var inv Inventory
|
||||
if err := json.Unmarshal(b, &inv); err != nil {
|
||||
return nil, fmt.Errorf("modes: parse: %w", err)
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
// ByID indexes the inventory.
|
||||
func (inv *Inventory) ByID() map[string]Mode {
|
||||
out := make(map[string]Mode, len(inv.Modes))
|
||||
for _, m := range inv.Modes {
|
||||
out[m.ID] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Fittable reports whether the mode has enough real seed examples to fit
|
||||
// prototypes from. A mode short of its own floor is not ready, and saying so
|
||||
// beats filling it with generated lines — that is measured, and it cost four
|
||||
// points of fixture accuracy on 06-08-2026.
|
||||
func (m Mode) Fittable() bool { return len(m.Examples) >= m.MinSeedExamples }
|
||||
@@ -0,0 +1,185 @@
|
||||
package modes
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func load(t *testing.T) *Inventory {
|
||||
t.Helper()
|
||||
inv, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return inv
|
||||
}
|
||||
|
||||
// The mapping back to the seven public labels must be total, ids unique, and a
|
||||
// reject policy only where the region is open.
|
||||
func TestInventoryShape(t *testing.T) {
|
||||
inv := load(t)
|
||||
if inv.EncoderID == "" {
|
||||
t.Error("no encoder_id: a fitted distance means nothing without the body it was fitted under")
|
||||
}
|
||||
valid := map[string]bool{}
|
||||
for _, i := range Intents {
|
||||
valid[i] = true
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range inv.Modes {
|
||||
if seen[m.ID] {
|
||||
t.Errorf("%s: duplicate id", m.ID)
|
||||
}
|
||||
seen[m.ID] = true
|
||||
if !valid[m.Intent] {
|
||||
t.Errorf("%s: intent %q is not one of the seven", m.ID, m.Intent)
|
||||
}
|
||||
if m.Handler == "" {
|
||||
t.Errorf("%s: no handler, so it is not a mode", m.ID)
|
||||
}
|
||||
if m.SeparatedBy == "" {
|
||||
t.Errorf("%s: no separated_by, so nothing states the review obligation", m.ID)
|
||||
}
|
||||
if m.Open && m.RejectPolicy == "" {
|
||||
t.Errorf("%s: open with no reject_policy", m.ID)
|
||||
}
|
||||
if !m.Open && m.RejectPolicy != "" {
|
||||
t.Errorf("%s: reject_policy on a bounded mode", m.ID)
|
||||
}
|
||||
if m.PrototypeCount < 1 {
|
||||
t.Errorf("%s: prototype_count %d", m.ID, m.PrototypeCount)
|
||||
}
|
||||
}
|
||||
// No id is a prefix of another. act.tool.hoststats was, and it turned out to
|
||||
// run the same handler as act.tool: a read against a change is the tool row's
|
||||
// destructive field, which the confirm gate already reads. Handler is prose,
|
||||
// so a duplicated behaviour hides there. A nested id is the tell that shows.
|
||||
for _, a := range inv.Modes {
|
||||
for _, b := range inv.Modes {
|
||||
if a.ID != b.ID && strings.HasPrefix(b.ID, a.ID+".") {
|
||||
t.Errorf("%s is nested under %s, so one of them is not a mode", b.ID, a.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nearest names a real mode, or the review obligation points at nothing.
|
||||
for _, m := range inv.Modes {
|
||||
if m.Nearest != "" && !seen[m.Nearest] {
|
||||
t.Errorf("%s: nearest %q is not in the inventory", m.ID, m.Nearest)
|
||||
}
|
||||
if m.Nearest == m.ID {
|
||||
t.Errorf("%s: nearest is itself", m.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filepath.Join(wd, "..", "..")
|
||||
}
|
||||
|
||||
func seedRows(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
paths, err := filepath.Glob(filepath.Join(repoRoot(t), "models", "seeds", "*.txt"))
|
||||
if err != nil || len(paths) == 0 {
|
||||
t.Fatalf("no seed files: %v", err)
|
||||
}
|
||||
out := map[string]bool{}
|
||||
for _, p := range paths {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[strings.ToLower(line)] = true
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Every example is a real seed row. Not generated: 202 reviewed generated
|
||||
// contrast pairs cost four points of fixture accuracy on 06-08-2026, and the
|
||||
// generated half of the corpus recovers its own generation prompt when clustered.
|
||||
func TestExamplesComeFromSeedRows(t *testing.T) {
|
||||
inv := load(t)
|
||||
seeds := seedRows(t)
|
||||
for _, m := range inv.Modes {
|
||||
if len(m.Examples) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, e := range m.Examples {
|
||||
if !seeds[strings.ToLower(e)] {
|
||||
t.Errorf("%s: example %q is not a seed row", m.ID, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The fixture is the sole held-out measurement. An example drawn from it makes
|
||||
// every number after that unfalsifiable.
|
||||
func TestExamplesAreNotFixtureCases(t *testing.T) {
|
||||
inv := load(t)
|
||||
b, err := os.ReadFile(filepath.Join(repoRoot(t), "internal", "router", "eval", "ru_routing_v1.json"))
|
||||
if err != nil {
|
||||
t.Skipf("fixture not readable: %v", err)
|
||||
}
|
||||
var raw struct {
|
||||
Cases []struct {
|
||||
Utterance string `json:"utterance"`
|
||||
} `json:"cases"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
t.Fatalf("fixture shape changed, and this invariant must not silently skip: %v", err)
|
||||
}
|
||||
held := map[string]bool{}
|
||||
for _, c := range raw.Cases {
|
||||
if c.Utterance != "" {
|
||||
held[strings.ToLower(strings.TrimSpace(c.Utterance))] = true
|
||||
}
|
||||
}
|
||||
if len(held) == 0 {
|
||||
t.Fatal("read no utterances from the fixture")
|
||||
}
|
||||
for _, m := range inv.Modes {
|
||||
for _, e := range m.Examples {
|
||||
if held[strings.ToLower(e)] {
|
||||
t.Errorf("%s: example %q is a fixture case", m.ID, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not a failure, a report. Nine modes have zero real examples and they are the
|
||||
// nine with no deterministic matcher, which is why V-629 and V-630 come before
|
||||
// V-632: without persisted turns there is nothing to fit them from.
|
||||
func TestFittableReport(t *testing.T) {
|
||||
inv := load(t)
|
||||
var ready, short, empty []string
|
||||
for _, m := range inv.Modes {
|
||||
switch {
|
||||
case len(m.Examples) == 0:
|
||||
empty = append(empty, m.ID)
|
||||
case m.Fittable():
|
||||
ready = append(ready, m.ID)
|
||||
default:
|
||||
short = append(short, m.ID)
|
||||
}
|
||||
}
|
||||
t.Logf("modes: %d total, %d ready to fit, %d short of min_seed_examples, %d with no seed example at all",
|
||||
len(inv.Modes), len(ready), len(short), len(empty))
|
||||
t.Logf(" no examples: %s", strings.Join(empty, ", "))
|
||||
t.Logf(" short: %s", strings.Join(short, ", "))
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
{
|
||||
"version": 1,
|
||||
"encoder_id": "e5-small-routing-v1",
|
||||
"note": "Written from the handlers on 06-08-2026 for V-631. Examples are drawn only from train_seeds.jsonl, which is src=seed. The 91-case fixture is not touched. A mode whose examples list is short of min_seed_examples is not ready to fit, and that is the point of recording the number.",
|
||||
"modes": [
|
||||
{
|
||||
"id": "query.fact-by-key",
|
||||
"intent": "query",
|
||||
"handler": "queryFactByKey",
|
||||
"means": "he asks back a fact he stored, by its key",
|
||||
"nearest": "query.recall",
|
||||
"separated_by": "a key exists in the fact store; recall has to search",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["сколько я спал сегодня", "какой сегодня вес", "сколько воды я выпил сегодня", "когда последний раз поливал цветы", "когда кормил кота в последний раз", "сколько времени прошло с последней тренировки", "how many hours did I sleep this week"]
|
||||
},
|
||||
{
|
||||
"id": "query.day-plan",
|
||||
"intent": "query",
|
||||
"handler": "queryDayPlan",
|
||||
"means": "what the day holds, asked with a plan word",
|
||||
"nearest": "query.calendar",
|
||||
"separated_by": "a plan word is present; the calendar listing is the general case",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["что у меня сегодня по плану", "какие планы на завтра", "планы на сегодня", "какие у меня планы на завтра"]
|
||||
},
|
||||
{
|
||||
"id": "query.habits",
|
||||
"intent": "query",
|
||||
"handler": "queryHabits",
|
||||
"means": "what he usually does, asked with a habit marker",
|
||||
"nearest": "query.calendar",
|
||||
"separated_by": "обычно, каждый, по средам; not a single dated occasion",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": []
|
||||
},
|
||||
{
|
||||
"id": "query.tasks",
|
||||
"intent": "query",
|
||||
"handler": "queryTasks",
|
||||
"means": "what is on the task board",
|
||||
"nearest": "query.day-plan",
|
||||
"separated_by": "a task noun or an explicit что … сделать, with no date",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": []
|
||||
},
|
||||
{
|
||||
"id": "query.attention",
|
||||
"intent": "query",
|
||||
"handler": "queryAttention",
|
||||
"means": "what Praxis says needs looking at",
|
||||
"nearest": "query.tasks",
|
||||
"separated_by": "an attention marker; the board is Maven's, attention is Praxis's",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": []
|
||||
},
|
||||
{
|
||||
"id": "query.list",
|
||||
"intent": "query",
|
||||
"handler": "queryList",
|
||||
"means": "what is on a standing list",
|
||||
"nearest": "query.tasks",
|
||||
"separated_by": "an explicit list marker",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": []
|
||||
},
|
||||
{
|
||||
"id": "query.money",
|
||||
"intent": "query",
|
||||
"handler": "queryMoney",
|
||||
"means": "spending and balances, from the facts the poller wrote",
|
||||
"nearest": "query.fact-by-key",
|
||||
"separated_by": "a money noun plus an actual ask",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["какой баланс на счету", "сколько стоит свет в этом месяце", "сколько электричества мы потратили"]
|
||||
},
|
||||
{
|
||||
"id": "query.history",
|
||||
"intent": "query",
|
||||
"handler": "queryHistory",
|
||||
"means": "what he told her, asked about the telling rather than the topic",
|
||||
"nearest": "query.recall",
|
||||
"separated_by": "both halves of a history phrase and no named topic",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": []
|
||||
},
|
||||
{
|
||||
"id": "query.feeds",
|
||||
"intent": "query",
|
||||
"handler": "queryFeeds",
|
||||
"means": "what the feeds she reads are carrying",
|
||||
"nearest": "query.world",
|
||||
"separated_by": "a feed noun plus an ask; the world source would invent news",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["что нового"]
|
||||
},
|
||||
{
|
||||
"id": "query.home",
|
||||
"intent": "query",
|
||||
"handler": "queryHome",
|
||||
"means": "the state of the house",
|
||||
"nearest": "act.tool",
|
||||
"separated_by": "it asks rather than switches; a device word plus an ask",
|
||||
"open": false,
|
||||
"prototype_count": 3,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["какая температура в комнате"]
|
||||
},
|
||||
{
|
||||
"id": "query.network",
|
||||
"intent": "query",
|
||||
"handler": "queryNetwork",
|
||||
"means": "what is on the LAN",
|
||||
"nearest": "act.tool",
|
||||
"separated_by": "the subject is the network, not this box",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["что с интернетом", "какая скорость интернета", "сколько трафика сегодня"]
|
||||
},
|
||||
{
|
||||
"id": "query.calendar",
|
||||
"intent": "query",
|
||||
"handler": "queryCalendar",
|
||||
"means": "what the calendar holds, dated",
|
||||
"nearest": "query.day-plan",
|
||||
"separated_by": "date-aware, and the only source a continuation turn still asks",
|
||||
"open": false,
|
||||
"prototype_count": 4,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["что у меня сегодня по календарю", "что сегодня в календаре", "покажи календарь на сегодня", "расписание на сегодня", "что у меня завтра", "есть ли что-то завтра", "сколько времени до встречи", "какие напоминания на сегодня"]
|
||||
},
|
||||
{
|
||||
"id": "query.weather",
|
||||
"intent": "query",
|
||||
"handler": "queryWeather",
|
||||
"means": "the weather, outside",
|
||||
"nearest": "query.home",
|
||||
"separated_by": "outside rather than in a room; the home source bails on weather wording",
|
||||
"open": false,
|
||||
"prototype_count": 3,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["какая погода", "какая погода в москве", "сколько градусов", "температура на улице", "холодно сегодня", "будет дождь", "погода на сегодня", "weather in london", "какой завтра прогноз погоды", "какая температура воздуха"]
|
||||
},
|
||||
{
|
||||
"id": "query.self",
|
||||
"intent": "query",
|
||||
"handler": "querySelf",
|
||||
"means": "a question about her",
|
||||
"nearest": "chat.open",
|
||||
"separated_by": "it wants a fact about her, not a conversation",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["как тебя зовут", "сколько тебе лет", "у тебя есть чувства", "do you have feelings"]
|
||||
},
|
||||
{
|
||||
"id": "query.recall",
|
||||
"intent": "query",
|
||||
"handler": "queryEmbed, queryMemory, queryNotes",
|
||||
"means": "search his own notes and memory for something he named",
|
||||
"nearest": "query.fact-by-key",
|
||||
"separated_by": "no key exists, so the text has to be searched",
|
||||
"open": false,
|
||||
"prototype_count": 4,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["покажи заметки про сервер", "найди заметку про сервер", "найди мою заметку о бэкапах", "поищи заметку про роутер", "найди заметку где я записал пароль", "что я записывал про полив", "покажи заметку про починку крана", "найди в заметках про home assistant", "find my note about the database backup", "search my notes for the wifi password", "what did I note about the garden", "покажи мои заметки за неделю"]
|
||||
},
|
||||
{
|
||||
"id": "query.web",
|
||||
"intent": "query",
|
||||
"handler": "queryWeb",
|
||||
"means": "read a page he named out loud",
|
||||
"nearest": "query.world",
|
||||
"separated_by": "he supplied the URL; it is an instruction, not a question",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": []
|
||||
},
|
||||
{
|
||||
"id": "query.world",
|
||||
"intent": "query",
|
||||
"handler": "querySearch, queryKiwix, queryGeneral",
|
||||
"means": "anything outside his own data",
|
||||
"nearest": "chat.open",
|
||||
"separated_by": "a source can answer it; the personal boundary let it past",
|
||||
"open": true,
|
||||
"prototype_count": 6,
|
||||
"min_seed_examples": 12,
|
||||
"reject_policy": "no prototype within radius goes to the LLM fallback, which this path already pays for",
|
||||
"examples": ["почему небо голубое", "что такое любовь", "как работает интернет", "почему трава зелёная", "откуда берётся дождь", "what is love", "why is the sky blue", "how does the internet work"]
|
||||
},
|
||||
{
|
||||
"id": "act.tool",
|
||||
"intent": "act",
|
||||
"handler": "tools.Exec against the enabled allowlist",
|
||||
"means": "switch, start, stop or read something the tool allowlist names",
|
||||
"nearest": "query.home",
|
||||
"separated_by": "it names a tool the allowlist carries; destructive is the tool row’s field, not a mode of its own",
|
||||
"open": false,
|
||||
"prototype_count": 6,
|
||||
"min_seed_examples": 8,
|
||||
"note": "act.tool.hoststats was a mode here until 06-08-2026 and is not one: it ran the same tools.Exec, and read against change is the tool row’s destructive field, which the confirm gate already reads. Its nine examples went with it, because they are question-shaped query seeds that no configured alias matches, so no tool answers them today. replySystem's память/загрузк/аптайм arm answers “системная статистика пока не подключена.” and always did.",
|
||||
"examples": ["включи свет на кухне", "выключи кондиционер", "открой шторы", "закрой окно", "перезагрузи роутер", "запусти пылесос", "заблокируй дверь", "maven, restart nginx", "перезапусти nginx", "останови контейнер", "maven, сделай бэкап", "запусти обновление системы"]
|
||||
},
|
||||
{
|
||||
"id": "act.taskstatus",
|
||||
"intent": "act",
|
||||
"handler": "resolveTaskStatus",
|
||||
"means": "move an item on Maven's own board",
|
||||
"nearest": "act.praxis",
|
||||
"separated_by": "the board is Maven's; Praxis owns attention, not this",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": []
|
||||
},
|
||||
{
|
||||
"id": "act.praxis",
|
||||
"intent": "act",
|
||||
"handler": "handlePraxisAct",
|
||||
"means": "surface, acknowledge or resolve a Praxis item",
|
||||
"nearest": "act.taskstatus",
|
||||
"separated_by": "the item lives in Praxis, and the three lifecycle words differ",
|
||||
"open": false,
|
||||
"prototype_count": 3,
|
||||
"min_seed_examples": 8,
|
||||
"examples": []
|
||||
},
|
||||
{
|
||||
"id": "act.hexis",
|
||||
"intent": "act",
|
||||
"handler": "handleHexisAct",
|
||||
"means": "execute a registered capability against a resolved entity",
|
||||
"nearest": "act.tool",
|
||||
"separated_by": "it names an entity Nexus must resolve before anything runs",
|
||||
"open": false,
|
||||
"prototype_count": 3,
|
||||
"min_seed_examples": 8,
|
||||
"examples": []
|
||||
},
|
||||
{
|
||||
"id": "note.task",
|
||||
"intent": "note",
|
||||
"handler": "captureTaskFromNote",
|
||||
"means": "he files work, which belongs in the task store",
|
||||
"nearest": "note.recall",
|
||||
"separated_by": "it is work to be done, not something to remember",
|
||||
"open": false,
|
||||
"prototype_count": 3,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["заметка: починить ручку на двери", "заметка: сменить масло в машине", "заметка: заменить лампочку в коридоре", "заметка: записаться к стоматологу", "заметка: переклеить обои в спальне", "заметка: проверить уровень масла", "запиши: проверить проводку на даче", "заметка: обновить прошивку роутера"]
|
||||
},
|
||||
{
|
||||
"id": "note.list",
|
||||
"intent": "note",
|
||||
"handler": "captureListFromNote",
|
||||
"means": "he adds to a standing list",
|
||||
"nearest": "note.task",
|
||||
"separated_by": "a list marker; the item is bought, not done",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["запиши что нужно купить в магазине", "купить новый фильтр для аквариума", "заметка: купить новый фильтр для воды", "запиши: купить семена для огорода", "запиши: купить подарок на день рождения"]
|
||||
},
|
||||
{
|
||||
"id": "note.recall",
|
||||
"intent": "note",
|
||||
"handler": "WriteNote plus memStore.Insert",
|
||||
"means": "free text he wants indexed for later recall",
|
||||
"nearest": "fact.self",
|
||||
"separated_by": "nothing keys it, and the subject need not be him",
|
||||
"open": false,
|
||||
"prototype_count": 4,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["запиши рецепт: 3 яйца, мука, молоко", "запиши пароль от wifi в заметки", "запиши адрес: москва, тверская 7", "запиши время работы химчистки", "запиши цену на стройматериалы", "запиши размеры полки для шкафа", "note: check the DNS config after update", "note: staggered cooldown by time of day", "запиши книгу, которую посоветовали"]
|
||||
},
|
||||
{
|
||||
"id": "fact.self",
|
||||
"intent": "fact",
|
||||
"handler": "actionFact, WriteFact kind=self",
|
||||
"means": "a keyed, supersedable statement about him",
|
||||
"nearest": "note.recall",
|
||||
"separated_by": "the store has a key for it and the subject is him",
|
||||
"open": false,
|
||||
"prototype_count": 6,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["отметь что я выпил воды", "запиши что я пообедал", "отметь тренировку 45 минут", "записываю вес 72 килограмма", "принял лекарство", "выпил кофе", "отметь температуру 36.6", "записываю давление 120 на 80", "вес 73.5 килограмма", "сон 7 часов", "slept 6h", "walked 8000 steps"]
|
||||
},
|
||||
{
|
||||
"id": "reminder.timed",
|
||||
"intent": "reminder",
|
||||
"handler": "actionReminder",
|
||||
"means": "fire something at a time",
|
||||
"nearest": "note.task",
|
||||
"separated_by": "it carries a time; a task has none",
|
||||
"open": false,
|
||||
"prototype_count": 4,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["напомни завтра в 9 утра позвонить", "напомни через 4 часа размяться", "напомни завтра в 9 утра позвонить", "напомни в пятницу вынести мусор", "напомни через 15 минут снять бельё", "remind me in 30 minutes to drink water", "remind me at 6pm to take out the trash", "remind me tomorrow at 8am to call the doctor"]
|
||||
},
|
||||
{
|
||||
"id": "system.clock",
|
||||
"intent": "system",
|
||||
"handler": "replySystem, the час/врем arm, ruClock",
|
||||
"means": "the current time",
|
||||
"nearest": "query.world",
|
||||
"separated_by": "answered from the box's own clock, not from a source",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["который час", "сколько времени", "сколько сейчас времени", "который час у нас", "который час в Москве"]
|
||||
},
|
||||
{
|
||||
"id": "system.date",
|
||||
"intent": "system",
|
||||
"handler": "replySystem, the день/числ arm, ParseCalendarDate",
|
||||
"means": "today's date or weekday",
|
||||
"nearest": "query.calendar",
|
||||
"separated_by": "it asks what day it is, not what is on that day",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["какой сегодня день", "какое сегодня число", "какой сегодня день недели"]
|
||||
},
|
||||
{
|
||||
"id": "system.presence",
|
||||
"intent": "system",
|
||||
"handler": "replySystem, the кто дома arm",
|
||||
"means": "who is home",
|
||||
"nearest": "query.home",
|
||||
"separated_by": "the subject is people, not devices",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["кто сейчас дома", "сколько человек дома", "есть ли кто дома", "все ли дома", "кто дома сейчас"]
|
||||
},
|
||||
{
|
||||
"id": "system.quiet",
|
||||
"intent": "system",
|
||||
"handler": "quiet_toggle.go, matched pre-route",
|
||||
"means": "turn the quiet mode on or off",
|
||||
"nearest": "act.tool",
|
||||
"separated_by": "it flips a daemon-wide setting from any channel, so the match is exact",
|
||||
"open": false,
|
||||
"prototype_count": 2,
|
||||
"min_seed_examples": 8,
|
||||
"examples": ["тихий режим", "не шуми", "не беспокоить", "включи тихий режим", "выключи тихий режим", "громкий режим", "quiet mode on", "quiet off"]
|
||||
},
|
||||
{
|
||||
"id": "chat.open",
|
||||
"intent": "chat",
|
||||
"handler": "PhraseChat",
|
||||
"means": "conversation, answered from the model with history",
|
||||
"nearest": "query.self",
|
||||
"separated_by": "nothing else claimed it and no source can answer it",
|
||||
"open": true,
|
||||
"prototype_count": 6,
|
||||
"min_seed_examples": 12,
|
||||
"reject_policy": "stays a measured positive class even while acting as a fallback region, or it silently absorbs every genuine miss",
|
||||
"examples": ["привет", "как дела", "о чём поговорим", "чем занимаешься", "расскажи историю", "пошути", "анекдот", "что ты думаешь о жизни", "i'm bored", "tell me a joke", "what's up", "how are you"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -45,6 +45,14 @@ const (
|
||||
// is the only authority the voice path can offer, and this is the one act
|
||||
// it is not enough for (Vikunja #449, #523).
|
||||
ActNeedsAuthedSurface = "act_needs_authed_surface"
|
||||
// ActUnknownTarget — the verb reached a tool and the target did not reach
|
||||
// anything. Named rather than run, because the alias match swallowed the verb
|
||||
// and handed on the next word of the sentence (V-634).
|
||||
ActUnknownTarget = "act_unknown_target"
|
||||
// RepairNoted — he said the turn was wrong and did not say what it should
|
||||
// have been. She confirms the label landed and does not ask, because the
|
||||
// answer would be one of her own intent names (V-636).
|
||||
RepairNoted = "repair_noted"
|
||||
|
||||
EcoDenied = "eco_denied"
|
||||
EcoDown = "eco_down"
|
||||
@@ -76,7 +84,7 @@ const (
|
||||
var actKeys = []string{
|
||||
ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich,
|
||||
ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs,
|
||||
ActNeedsAuthedSurface,
|
||||
ActNeedsAuthedSurface, ActUnknownTarget, RepairNoted,
|
||||
EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
|
||||
AttentionNone, AttentionList, AttentionFail,
|
||||
AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
|
||||
@@ -102,6 +110,8 @@ var actFloor = map[string]string{
|
||||
ActServerDown: "инструмент есть, но сервер не подключён.",
|
||||
ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
|
||||
ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
|
||||
RepairNoted: "поняла, отметила, что ответила не так.",
|
||||
ActUnknownTarget: "«{name}» — не знаю такой цели. назови её как в системе.",
|
||||
ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.",
|
||||
|
||||
EcoDenied: "{name} отклоняет доступ, проверь токен.",
|
||||
|
||||
@@ -60,6 +60,14 @@
|
||||
"fixed": true,
|
||||
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
|
||||
},
|
||||
"repair_noted": {
|
||||
"fixed": true,
|
||||
"variants": ["поняла, отметила, что ответила не так."]
|
||||
},
|
||||
"act_unknown_target": {
|
||||
"fixed": true,
|
||||
"variants": ["«{name}» — не знаю такой цели. назови её как в системе."]
|
||||
},
|
||||
"act_needs_authed_surface": {
|
||||
"fixed": true,
|
||||
"variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."]
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
// Before V-633 the matcher only ever matched the English tool name, so no
|
||||
// Russian utterance could reach a tool: 55 of the 69 lines in models/seeds/act.txt
|
||||
// routed to IntentAct and then fell to proposeGap. These are those lines.
|
||||
func TestActMatcherAliases(t *testing.T) {
|
||||
m := DefaultActMatcher{
|
||||
Fns: []string{"status", "ps", "uptime", "disk", "memory", "logs",
|
||||
"restart", "stop", "start", "docker-restart", "docker-stop", "reboot"},
|
||||
Aliases: map[string][]string{
|
||||
"status": {"статус", "покажи статус"},
|
||||
"ps": {"статус докера", "что запущено"},
|
||||
"uptime": {"покажи uptime", "как работает сервер"},
|
||||
"disk": {"сколько места на диске"},
|
||||
"memory": {"свободная память"},
|
||||
"logs": {"покажи логи", "логи"},
|
||||
"restart": {"перезагрузи", "перезапусти"},
|
||||
"docker-restart": {"перезагрузи контейнер"},
|
||||
"reboot": {"перезагрузи сервер"},
|
||||
},
|
||||
}
|
||||
cases := []struct {
|
||||
utterance string
|
||||
wantFn string
|
||||
wantArgs []string
|
||||
}{
|
||||
{"покажи статус nginx", "status", []string{"nginx"}},
|
||||
{"статус sshd", "status", []string{"sshd"}},
|
||||
{"статус докера", "ps", nil},
|
||||
{"что запущено", "ps", nil},
|
||||
{"сколько места на диске", "disk", nil},
|
||||
{"свободная память", "memory", nil},
|
||||
{"покажи uptime", "uptime", nil},
|
||||
{"логи nginx", "logs", []string{"nginx"}},
|
||||
{"перезагрузи nginx", "restart", []string{"nginx"}},
|
||||
// Longest phrase first, so the two-word alias wins over the one word
|
||||
// inside it and the act reaches the right tool.
|
||||
{"перезагрузи контейнер maven", "docker-restart", []string{"maven"}},
|
||||
{"перезагрузи сервер", "reboot", nil},
|
||||
// The English names still match, unchanged.
|
||||
{"restart nginx", "restart", []string{"nginx"}},
|
||||
{"uptime", "uptime", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
fn, args, ok := m.Match(c.utterance)
|
||||
if !ok || fn != c.wantFn {
|
||||
t.Errorf("%q: got fn=%q ok=%v, want %q", c.utterance, fn, ok, c.wantFn)
|
||||
continue
|
||||
}
|
||||
if len(args) != len(c.wantArgs) {
|
||||
t.Errorf("%q: got args=%v, want %v", c.utterance, args, c.wantArgs)
|
||||
continue
|
||||
}
|
||||
for i := range args {
|
||||
if args[i] != c.wantArgs[i] {
|
||||
t.Errorf("%q: got args=%v, want %v", c.utterance, args, c.wantArgs)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Past tense is a fact, not a command, and aliases match exact tokens so it
|
||||
// stays one. This is the trap cmd/mavend/quiet_toggle.go documents.
|
||||
if fn, _, ok := m.Match("перезагрузил роутер"); ok {
|
||||
t.Errorf("past tense reached a tool: fn=%q", fn)
|
||||
}
|
||||
// A phrase nobody configured still refuses, so the router can clarify.
|
||||
if fn, _, ok := m.Match("свари кофе"); ok {
|
||||
t.Errorf("unconfigured phrase reached a tool: fn=%q", fn)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDumpPrompt writes the router prompt and grammar to disk so the training
|
||||
// workspace labels with the daemon's own contract rather than a retyped copy.
|
||||
// It is inert unless MAVEN_DUMP_PROMPT names a directory.
|
||||
func TestDumpPrompt(t *testing.T) {
|
||||
dir := os.Getenv("MAVEN_DUMP_PROMPT")
|
||||
if dir == "" {
|
||||
t.Skip("MAVEN_DUMP_PROMPT unset")
|
||||
}
|
||||
if err := os.WriteFile(dir+"/route_system.txt", []byte(routeSystem), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(dir+"/route_grammar.gbnf", []byte(routeGrammar), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -36,17 +36,27 @@ var fixtureJSON []byte
|
||||
//
|
||||
// Intent is empty exactly when WantClarify is set: the contract there is that
|
||||
// the router refuses instead of guessing.
|
||||
//
|
||||
// WantSource is a pointer because the destination has three states and a bare
|
||||
// string only has two (V-659). Absent means the case does not score a
|
||||
// destination at all, which is every intent but query: a fact, a reminder, a
|
||||
// note, an act, a chat or a system turn never reaches queryWalk. Present and
|
||||
// empty is the SourceUnknown contract — the decider must name nothing and let
|
||||
// the daemon walk the whole chain, which is the right answer whenever two
|
||||
// destinations can both answer and the utterance does not choose. Present and
|
||||
// named is a destination the route must produce.
|
||||
type Case struct {
|
||||
ID string `json:"id"`
|
||||
Utterance string `json:"utterance"`
|
||||
Lang string `json:"lang"`
|
||||
Intent router.Intent `json:"intent"`
|
||||
WantTime bool `json:"want_time"`
|
||||
WantFn bool `json:"want_fn"`
|
||||
WantFactKey string `json:"want_fact_key"`
|
||||
WantClarify bool `json:"want_clarify"`
|
||||
Tags []string `json:"tags"`
|
||||
Note string `json:"note"`
|
||||
ID string `json:"id"`
|
||||
Utterance string `json:"utterance"`
|
||||
Lang string `json:"lang"`
|
||||
Intent router.Intent `json:"intent"`
|
||||
WantTime bool `json:"want_time"`
|
||||
WantFn bool `json:"want_fn"`
|
||||
WantFactKey string `json:"want_fact_key"`
|
||||
WantClarify bool `json:"want_clarify"`
|
||||
WantSource *router.Source `json:"want_source,omitempty"`
|
||||
Tags []string `json:"tags"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// Fixture — the versioned envelope, same shape as
|
||||
@@ -118,6 +128,11 @@ type Outcome struct {
|
||||
// (a slot gap is a parser fix; a wrong intent is a router fix).
|
||||
IntentOK bool
|
||||
Reasons []string
|
||||
// SourceReason is set when the case labelled a destination and the route
|
||||
// named a different one. It is kept out of Reasons on purpose: the
|
||||
// destination is the second half of a route and it is scored separately,
|
||||
// so a wrong destination must not move the intent number (V-659).
|
||||
SourceReason string
|
||||
}
|
||||
|
||||
// Report — the aggregate. Accuracy is the headline; the rest exists so a
|
||||
@@ -139,7 +154,15 @@ type Report struct {
|
||||
// (reminder grammar → applyAction's time parser). Not a miss, but not a
|
||||
// full router-level win either; tracked so the two aren't conflated.
|
||||
SlotsDeferred int
|
||||
Outcomes []Outcome
|
||||
// SourceTotal counts the cases carrying a want_source, and SourceHit the
|
||||
// ones whose route named it. Reported apart from Passed because intent and
|
||||
// destination are two decisions, and one number hides which one moved.
|
||||
SourceTotal int
|
||||
SourceHit int
|
||||
// SourceConfusion counts want→got destination pairs. "" reads as the
|
||||
// SourceUnknown floor on either side.
|
||||
SourceConfusion map[string]int
|
||||
Outcomes []Outcome
|
||||
// Confusion counts want→got intent pairs, decided cases only.
|
||||
Confusion map[string]int
|
||||
// ByTag accuracy for the fixture's tags ("hard", "homelab", …).
|
||||
@@ -172,6 +195,17 @@ func (r Report) IntentAccuracy() float64 {
|
||||
return float64(r.IntentHit) / float64(r.Total)
|
||||
}
|
||||
|
||||
// SourceAccuracy — fraction of the labelled cases whose route named the right
|
||||
// destination. Denominator is SourceTotal and not Total, because most of the
|
||||
// fixture never reaches a query source and scoring those would report a
|
||||
// percentage of nothing.
|
||||
func (r Report) SourceAccuracy() float64 {
|
||||
if r.SourceTotal == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.SourceHit) / float64(r.SourceTotal)
|
||||
}
|
||||
|
||||
// Score runs every case through r and aggregates. It never fails the run on a
|
||||
// route error — an erroring case scores as a miss and is counted in Errors,
|
||||
// because "the model was down" and "the model was wrong" are different numbers
|
||||
@@ -186,11 +220,12 @@ func Score(ctx context.Context, name string, r Router, f Fixture) (Report, error
|
||||
return Report{}, err
|
||||
}
|
||||
rep := Report{
|
||||
Name: name,
|
||||
Total: len(f.Cases),
|
||||
Confusion: map[string]int{},
|
||||
ByTag: map[string]TagStat{},
|
||||
ByLang: map[string]TagStat{},
|
||||
Name: name,
|
||||
Total: len(f.Cases),
|
||||
Confusion: map[string]int{},
|
||||
SourceConfusion: map[string]int{},
|
||||
ByTag: map[string]TagStat{},
|
||||
ByLang: map[string]TagStat{},
|
||||
}
|
||||
lat := make([]time.Duration, 0, len(f.Cases))
|
||||
|
||||
@@ -242,6 +277,29 @@ func Score(ctx context.Context, name string, r Router, f Fixture) (Report, error
|
||||
}
|
||||
}
|
||||
|
||||
// The destination is scored outside the switch and outside Pass. A case
|
||||
// that clarified or landed the wrong intent named no destination, and
|
||||
// that is a real miss rather than a case to skip — otherwise the
|
||||
// denominator quietly drops every turn the route already lost. Only a
|
||||
// route error is skipped, because "the model was down" is the Errors
|
||||
// number and not a destination result.
|
||||
if c.WantSource != nil && err == nil {
|
||||
rep.SourceTotal++
|
||||
switch {
|
||||
case !o.IntentOK:
|
||||
// The route never got to a destination, so a match on the
|
||||
// SourceUnknown floor here would be a coincidence scored as a
|
||||
// win: a clarify names nothing and would satisfy "" for free.
|
||||
o.SourceReason = fmt.Sprintf("no destination, route missed %q", c.Intent)
|
||||
rep.SourceConfusion[string(*c.WantSource)+"→(no route)"]++
|
||||
case d.Source == *c.WantSource:
|
||||
rep.SourceHit++
|
||||
default:
|
||||
rep.SourceConfusion[string(*c.WantSource)+"→"+string(d.Source)]++
|
||||
o.SourceReason = fmt.Sprintf("source %q, want %q", d.Source, *c.WantSource)
|
||||
}
|
||||
}
|
||||
|
||||
o.Pass = len(o.Reasons) == 0
|
||||
if o.Pass {
|
||||
rep.Passed++
|
||||
@@ -298,25 +356,40 @@ func (r Report) String() string {
|
||||
r.Name, r.Passed, r.Total, 100*r.Accuracy(), 100*r.IntentAccuracy())
|
||||
fmt.Fprintf(&b, " clarify: %d false (asked, shouldn't) / %d missed (guessed, shouldn't) | errors: %d | slots deferred to daemon: %d\n",
|
||||
r.FalseClarify, r.MissedClarify, r.Errors, r.SlotsDeferred)
|
||||
if r.SourceTotal > 0 {
|
||||
fmt.Fprintf(&b, " destination: %d/%d labelled cases (%.1f%%)\n",
|
||||
r.SourceHit, r.SourceTotal, 100*r.SourceAccuracy())
|
||||
}
|
||||
fmt.Fprintf(&b, " latency: p50 %s p95 %s max %s\n", r.P50, r.P95, r.Max)
|
||||
fmt.Fprintf(&b, " by lang: %s\n", renderStats(r.ByLang))
|
||||
fmt.Fprintf(&b, " by tag: %s\n", renderStats(r.ByTag))
|
||||
if len(r.Confusion) > 0 {
|
||||
fmt.Fprintf(&b, " confusion: %s\n", renderCounts(r.Confusion))
|
||||
}
|
||||
if len(r.SourceConfusion) > 0 {
|
||||
fmt.Fprintf(&b, " destination confusion: %s\n", renderCounts(r.SourceConfusion))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Failures — the per-case detail, sorted by ID so two runs diff cleanly.
|
||||
// Failures — the per-case detail, sorted by ID so two runs diff cleanly. A case
|
||||
// that landed its intent and missed its destination is listed too, marked, so
|
||||
// the half that moved is readable without diffing two percentages.
|
||||
func (r Report) Failures() string {
|
||||
var b strings.Builder
|
||||
out := append([]Outcome(nil), r.Outcomes...)
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Case.ID < out[j].Case.ID })
|
||||
for _, o := range out {
|
||||
if o.Pass {
|
||||
continue
|
||||
switch {
|
||||
case !o.Pass:
|
||||
reasons := o.Reasons
|
||||
if o.SourceReason != "" {
|
||||
reasons = append(append([]string(nil), reasons...), o.SourceReason)
|
||||
}
|
||||
fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Utterance, strings.Join(reasons, "; "))
|
||||
case o.SourceReason != "":
|
||||
fmt.Fprintf(&b, " %s %q: route ok, %s\n", o.Case.ID, o.Case.Utterance, o.SourceReason)
|
||||
}
|
||||
fmt.Fprintf(&b, " %s %q: %s\n", o.Case.ID, o.Case.Utterance, strings.Join(o.Reasons, "; "))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -266,6 +266,11 @@ func baselineGrammars(acts router.ActMatcher) []router.Grammar {
|
||||
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
||||
// anything while its grammar set is the daemon's grammar set.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
// After the agenda rules and before the feed and list rules, same as
|
||||
// voicewire.go: "что такое лента" is a definition question and the feed
|
||||
// rule would claim it on the noun alone (V-655). Missing here until V-659,
|
||||
// so the fixture was scoring a grammar set the daemon does not run.
|
||||
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).
|
||||
|
||||
@@ -6,37 +6,41 @@
|
||||
"Held-out routing contract. Every utterance here is absent from models/seeds/*.txt (TestFixtureIsHeldOut enforces it verbatim) — scoring a classifier on its own seed phrases measures memorisation, not routing.",
|
||||
"This is a CONTRACT, not a snapshot of current behaviour. Cases the classifier cascade fails today are expected to stay in the file and fail loudly; that failure count is the number Vikunja #319 compares against the LLM router before #320 flips the default.",
|
||||
"Slot expectations are deployment-independent on purpose. want_fn is a boolean (the act must resolve to SOME allowlisted fn) because the allowlist lives in deploy config, not here. want_fact_key names the loop's rule keys (water/meal/sleep/break/shower) — a fact that lands under the wrong key silently starves the predicate that reads it.",
|
||||
"want_clarify cases carry intent \"\": the contract is that the router refuses rather than guesses. A confident answer there is a worse failure than a miss."
|
||||
"want_clarify cases carry intent \"\": the contract is that the router refuses rather than guesses. A confident answer there is a worse failure than a miss.",
|
||||
"want_source is the second half of a route (V-655). It is present only on query cases, because no other intent reaches queryWalk, and absent there means absent rather than SourceUnknown. Empty is a label and not a gap: it asserts that the decider must name nothing and let the daemon walk the whole chain in order, his data first.",
|
||||
"Seven cases assert that 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 observations into the fact store recall reads. That is a finding about the enum, not a gap in the labelling.",
|
||||
"ru-query-020 and ru-query-024 are the same utterance, as are ru-query-021 and ru-query-025. Both pairs differ in tags and note only, so both pairs are counted twice in every number this fixture reports.",
|
||||
"Every want_source is the destination that SHOULD claim the turn, which on ru-query-026 through 030 is not the one that did. Those five were observed failing on the box on 2026-08-07 (docs/evals/2026-08-07-week-of-usage.md). A fixture that passes on the day it is written measures nothing."
|
||||
],
|
||||
"cases": [
|
||||
{ "id": "ru-query-001", "utterance": "сколько воды я выпил с утра", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
||||
{ "id": "ru-query-002", "utterance": "я сегодня вообще пил воду", "lang": "ru", "intent": "query", "tags": ["hard", "fact-shaped"], "note": "past-tense fact lexicon in a question — the classifier's fact centroid pulls this hard" },
|
||||
{ "id": "ru-query-003", "utterance": "во сколько я лёг вчера", "lang": "ru", "intent": "query", "tags": ["temporal"] },
|
||||
{ "id": "ru-query-004", "utterance": "давно я не тренировался", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
||||
{ "id": "ru-query-005", "utterance": "напоминания на завтра есть", "lang": "ru", "intent": "query", "tags": ["hard", "reminder-shaped"], "note": "asks about reminders, does not create one" },
|
||||
{ "id": "ru-query-006", "utterance": "что я записывал про кота", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
||||
{ "id": "ru-query-007", "utterance": "сколько раз я ел вчера", "lang": "ru", "intent": "query", "tags": ["aggregate", "hard"] },
|
||||
{ "id": "ru-query-008", "utterance": "мой вес за последний месяц", "lang": "ru", "intent": "query", "tags": ["no-verb"] },
|
||||
{ "id": "ru-query-009", "utterance": "когда я в последний раз принимал витамины", "lang": "ru", "intent": "query", "tags": ["temporal"] },
|
||||
{ "id": "ru-query-010", "utterance": "есть новости по бэкапу базы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
|
||||
{ "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener" },
|
||||
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
||||
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
|
||||
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
||||
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
|
||||
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
|
||||
{ "id": "ru-query-024", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["calendar", "no-question-word"], "note": "the rest of the day, with no possessive and no plan word to anchor on; the model called it a fact and the write had to be caught downstream (Vikunja #498)" },
|
||||
{ "id": "ru-query-025", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "no-question-word"], "note": "a narrative request carries no question mark and no interrogative, so it routed fact; contrast ru-chat-003, where the same verb asks for a joke" },
|
||||
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
||||
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
|
||||
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
|
||||
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
|
||||
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
|
||||
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
|
||||
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
|
||||
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
|
||||
{ "id": "ru-query-001", "utterance": "сколько воды я выпил с утра", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["aggregate"] },
|
||||
{ "id": "ru-query-002", "utterance": "я сегодня вообще пил воду", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["hard", "fact-shaped"], "note": "past-tense fact lexicon in a question — the classifier's fact centroid pulls this hard" },
|
||||
{ "id": "ru-query-003", "utterance": "во сколько я лёг вчера", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["temporal"] },
|
||||
{ "id": "ru-query-004", "utterance": "давно я не тренировался", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["hard", "no-question-word"] },
|
||||
{ "id": "ru-query-005", "utterance": "напоминания на завтра есть", "lang": "ru", "intent": "query", "want_source": "", "tags": ["hard", "reminder-shaped"], "note": "asks about reminders, does not create one. want_source is the floor on purpose: no query source reads the reminder store, and day-plan is SourceCalendar over a table this box does not write." },
|
||||
{ "id": "ru-query-006", "utterance": "что я записывал про кота", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["recall"] },
|
||||
{ "id": "ru-query-007", "utterance": "сколько раз я ел вчера", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["aggregate", "hard"] },
|
||||
{ "id": "ru-query-008", "utterance": "мой вес за последний месяц", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["no-verb"] },
|
||||
{ "id": "ru-query-009", "utterance": "когда я в последний раз принимал витамины", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["temporal"] },
|
||||
{ "id": "ru-query-010", "utterance": "есть новости по бэкапу базы", "lang": "ru", "intent": "query", "want_source": "", "tags": ["homelab"], "note": "recall, attention and network can each answer it, because mavpoll writes its netdata and uptime-kuma observations into the fact store recall reads. Naming one takes the other two off the turn." },
|
||||
{ "id": "ru-query-011", "utterance": "почему сервер тормозит", "lang": "ru", "intent": "query", "want_source": "", "tags": ["homelab", "hard"], "note": "diagnostic question, not a chat opener. network holds the box and attention holds the alarm about the box. The utterance does not choose, so neither does the label." },
|
||||
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["recall"] },
|
||||
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar"] },
|
||||
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
||||
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
|
||||
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
|
||||
{ "id": "ru-query-024", "utterance": "что дальше?", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["calendar", "no-question-word"], "note": "the rest of the day, with no possessive and no plan word to anchor on; the model called it a fact and the write had to be caught downstream (Vikunja #498)" },
|
||||
{ "id": "ru-query-025", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "no-question-word"], "note": "a narrative request carries no question mark and no interrogative, so it routed fact; contrast ru-chat-003, where the same verb asks for a joke" },
|
||||
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "want_source": "", "tags": ["hard", "no-question-word"], "note": "a deadline lives in the task list, the calendar or Praxis depending on where he put it. The destination depends on his data, not on his words." },
|
||||
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["aggregate"] },
|
||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["hard", "chat-shaped"] },
|
||||
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "want_source": "", "tags": ["homelab"], "note": "disk headroom. network is the only source that reads the box, but the phrasing is a capacity question and not a LAN one." },
|
||||
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "want_source": "calendar", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
|
||||
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
|
||||
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "want_source": "recall", "tags": ["fact-shaped"] },
|
||||
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "want_source": "", "tags": ["temporal"], "note": "the completion time is a fact the poller wrote, so recall answers it. A person asking this wants the operational answer. Both are true." },
|
||||
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "want_source": "recall", "tags": ["imperative"] },
|
||||
|
||||
{ "id": "ru-fact-001", "utterance": "только что выпил кружку воды", "lang": "ru", "intent": "fact", "want_fact_key": "water" },
|
||||
{ "id": "ru-fact-002", "utterance": "воды попил наконец", "lang": "ru", "intent": "fact", "want_fact_key": "water", "tags": ["inverted"] },
|
||||
@@ -106,6 +110,11 @@
|
||||
{ "id": "amb-005", "utterance": "потом", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "filler"] },
|
||||
{ "id": "amb-006", "utterance": "the thing from earlier", "lang": "en", "want_clarify": true, "tags": ["ambiguous", "anaphora"] },
|
||||
{ "id": "amb-007", "utterance": "напомни", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder"], "note": "the reminder verb and nothing else — she knows the shape of the request and not one thing about it. Answered 'не получилось разобрать время напоминания' on the box until V-548: the subjectless-reminder gate tested Slots.Text == \"\", and fillSlots had put the verb in that slot" },
|
||||
{ "id": "amb-008", "utterance": "ну напомни же", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder", "filler"], "note": "the same request wrapped in particles, which is why filler_particles is a lexicon set — without it the particles read as the subject" }
|
||||
{ "id": "amb-008", "utterance": "ну напомни же", "lang": "ru", "want_clarify": true, "tags": ["ambiguous", "reminder", "filler"], "note": "the same request wrapped in particles, which is why filler_particles is a lexicon set — without it the particles read as the subject" },
|
||||
{ "id": "ru-query-026", "utterance": "что такое TCP?", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "regression"], "note": "weather claimed it on 2026-08-07 and answered \"для какого города?\", because it read one percent closer than the leftover seeds. WorldQueryGrammars claims it at stage 0 now." },
|
||||
{ "id": "ru-query-027", "utterance": "сколько будет 17 на 23?", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "arithmetic", "regression"], "note": "same source, same day, same answer about a city. Arithmetic is not a place." },
|
||||
{ "id": "ru-query-028", "utterance": "какой у меня любимый язык?", "lang": "ru", "intent": "query", "want_source": "recall", "tags": ["recall", "possessive", "regression"], "note": "the feed answered it with kernel headlines. \"у меня\" is the whole signal and it points inward." },
|
||||
{ "id": "ru-query-029", "utterance": "кто такой Линус Торвальдс?", "lang": "ru", "intent": "query", "want_source": "world", "tags": ["world", "person", "regression"], "note": "the personal boundary answered \"не нашла у тебя такой записи\". A named public person is not his data." },
|
||||
{ "id": "ru-query-030", "utterance": "что там с бэкапами?", "lang": "ru", "intent": "query", "want_source": "", "tags": ["homelab", "regression"], "note": "search claimed it, which inverts the boundary outward. The fix is the chain order and not a destination: recall, attention and network all answer it, same as ru-query-010." }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The fact-parser corpus (V-586). DefaultFactParser is the only thing standing
|
||||
// between a spoken sentence and a written self-care fact, and until this file
|
||||
// it was covered by a handful of examples chosen by whoever last edited it. The
|
||||
// RU routing fixture does not cover it either: it holds three fact cases and
|
||||
// all three miss on intent, so the parser is never reached and a parser change
|
||||
// scores as "unchanged".
|
||||
//
|
||||
// So the corpus is here, and it scores BOTH implementations: the closed-class
|
||||
// parser that ships, and legacyFactParse below, a faithful copy of the
|
||||
// hand-written substring version 22edc3c replaced. The table is the measurement
|
||||
// — see docs/evals/2026-08-06-fact-parser.md for the numbers as of that day.
|
||||
//
|
||||
// Three case classes, and the third is the point:
|
||||
//
|
||||
// true positive — a sentence he would say, with the key it must write.
|
||||
// misfire — a sentence the substring parser wrote a fact for and
|
||||
// should not have. want is "".
|
||||
// false-negative — a sentence he would plausibly say whose word is in NO
|
||||
// lexicon set. want is the key a human would assign, and
|
||||
// the ship parser is EXPECTED to miss it. These measure the
|
||||
// cost of the word-list design, not a bug in it.
|
||||
//
|
||||
// Nothing here asserts a score. A closed class is a decision about vocabulary
|
||||
// and the bar belongs in a dated eval, not in an assertion that turns every
|
||||
// vocabulary edit into a red test. What it does fail on is a regression in the
|
||||
// two classes that are not judgement calls: a true positive that stops matching
|
||||
// and a misfire that starts.
|
||||
|
||||
type factCase struct {
|
||||
utterance string
|
||||
want string // "" — no fact
|
||||
class string // "tp", "misfire", "fn"
|
||||
broken bool // ship parser is known to get this wrong; see the 06-08 eval
|
||||
note string
|
||||
}
|
||||
|
||||
var factCorpus = []factCase{
|
||||
// ---- water, true positives -------------------------------------------
|
||||
{"выпил стакан воды", "water", "tp", false, ""},
|
||||
{"попил воды", "water", "tp", false, ""},
|
||||
{"я попил водички", "water", "tp", false, ""},
|
||||
{"пью воду", "water", "tp", false, ""},
|
||||
{"воду пил уже", "water", "tp", false, ""},
|
||||
{"допил воду", "water", "tp", true, "REGRESSION: the dictionary lemmatises допил to допилить, to finish sawing — the same saw collision drink_verbs already carries пил for, unfixed for the prefixed form"},
|
||||
{"запил таблетку водой", "water", "tp", false, ""},
|
||||
{"drank water", "water", "tp", false, ""},
|
||||
{"i drank some water", "water", "tp", false, ""},
|
||||
|
||||
// ---- meal, true positives --------------------------------------------
|
||||
{"поужинал", "meal", "tp", false, ""},
|
||||
{"я пообедал", "meal", "tp", false, ""},
|
||||
{"позавтракал кашей", "meal", "tp", false, ""},
|
||||
{"перекусил бутербродом", "meal", "tp", false, ""},
|
||||
{"покушал", "meal", "tp", false, ""},
|
||||
{"поел супа", "meal", "tp", false, ""},
|
||||
{"обед был в час", "meal", "tp", false, ""},
|
||||
{"ужинать буду позже", "meal", "tp", false, ""},
|
||||
{"i ate", "meal", "tp", false, ""},
|
||||
{"had lunch", "meal", "tp", false, ""},
|
||||
{"dinner done", "meal", "tp", false, ""},
|
||||
|
||||
// ---- shower, true positives ------------------------------------------
|
||||
{"принял душ", "shower", "tp", false, ""},
|
||||
{"сходил в душ", "shower", "tp", false, ""},
|
||||
{"душ принят", "shower", "tp", false, ""},
|
||||
{"ополоснулся душем", "shower", "tp", false, ""},
|
||||
{"took a shower", "shower", "tp", false, ""},
|
||||
{"i showered", "shower", "tp", false, ""},
|
||||
|
||||
// ---- break, true positives -------------------------------------------
|
||||
{"сделал перерыв", "break", "tp", false, ""},
|
||||
{"отдохнул полчаса", "break", "tp", false, ""},
|
||||
{"передохнул немного", "break", "tp", false, ""},
|
||||
{"отдыхаю", "break", "tp", false, ""},
|
||||
{"был перерыв на обед", "meal", "tp", false, "meal wins the switch; both keys are true of the sentence"},
|
||||
{"took a break", "break", "tp", false, ""},
|
||||
|
||||
// ---- sleep, true positives -------------------------------------------
|
||||
{"спал восемь часов", "sleep", "tp", false, ""},
|
||||
{"спала плохо", "sleep", "tp", false, "she may report her own; the parser is speaker-agnostic"},
|
||||
{"поспал днём", "sleep", "tp", false, ""},
|
||||
{"выспался наконец", "sleep", "tp", false, ""},
|
||||
{"проспал будильник", "sleep", "tp", false, ""},
|
||||
{"пойду спать", "sleep", "tp", false, ""},
|
||||
{"slept 8 hours", "sleep", "tp", false, ""},
|
||||
{"i slept badly", "sleep", "tp", false, ""},
|
||||
|
||||
// ---- misfires 22edc3c set out to reject -------------------------------
|
||||
{"пилот сказал что вылет через час", "", "misfire", false, "substring пил"},
|
||||
{"водитель уже подъехал", "", "misfire", false, "substring вод"},
|
||||
{"надо заводить машину", "", "misfire", false, "substring вод"},
|
||||
{"душа болит", "", "misfire", false, "substring душ"},
|
||||
{"в комнате душно", "", "misfire", false, "substring душ"},
|
||||
{"это была беда", "", "misfire", false, "substring еда"},
|
||||
{"наша победа", "", "misfire", false, "substring еда"},
|
||||
{"пила лежит в гараже", "", "misfire", false, "substring пил"},
|
||||
{"водитель пилота ждёт", "", "misfire", false, "both stems in one sentence — the old water arm fires"},
|
||||
{"обеденный перерыв отменили", "", "misfire", true, "neither parser gets this: the old one writes meal off the adjective, the new one writes break off перерыв. A cancelled break is not a break taken."},
|
||||
|
||||
// ---- hard negatives that decide the design ----------------------------
|
||||
{"есть новости по бэкапу базы", "", "misfire", false, "есть is the existential, not a meal"},
|
||||
{"напоминания на завтра есть", "", "misfire", false, "завтра carries завтрак as a substring"},
|
||||
{"на душе легко", "", "misfire", false, "душе is the soul, and also the prepositional of душ"},
|
||||
{"пилил доску весь вечер", "", "misfire", false, ""},
|
||||
{"поставь будильник на завтра", "", "misfire", false, "завтра again, no meal"},
|
||||
|
||||
// ---- false negatives: words in no lexicon set -------------------------
|
||||
// Water.
|
||||
{"выпил чаю", "water", "fn", false, "hydration by any liquid; чай is in no set"},
|
||||
{"глотнул воды", "water", "fn", false, "глотнуть is not a drink verb"},
|
||||
{"хлебнул воды", "water", "fn", false, "хлебнуть is not a drink verb"},
|
||||
{"воды хлебнул из бутылки", "water", "fn", false, ""},
|
||||
{"выпил стакан", "water", "fn", false, "the noun is elided; he says this"},
|
||||
{"i hydrated", "water", "fn", false, "hydrate is in no set"},
|
||||
{"finished my bottle of water", "water", "fn", false, "no drink verb in the English set"},
|
||||
|
||||
// Meal.
|
||||
{"ем суп", "meal", "fn", false, "есть is deliberately absent, and this is the cost"},
|
||||
{"съел бутерброд", "meal", "fn", false, "съесть is in no set"},
|
||||
{"наелся", "meal", "fn", false, "наесться is in no set"},
|
||||
{"пожрал", "meal", "fn", false, "coarse but spoken"},
|
||||
{"полдник был", "meal", "fn", false, "полдник is in no set"},
|
||||
{"i had a snack", "meal", "fn", false, "snack is in no set"},
|
||||
{"having supper", "meal", "fn", false, "supper is in no set"},
|
||||
{"brunch was good", "meal", "fn", false, "brunch is in no set"},
|
||||
{"i eat now", "meal", "fn", false, "eat is in no set — only ate is"},
|
||||
|
||||
// Shower.
|
||||
{"был в душе", "shower", "fn", false, "prepositional; anyExact cannot take it without taking the soul"},
|
||||
{"после душа полегчало", "shower", "fn", false, "genitive, same collision"},
|
||||
{"помылся", "shower", "fn", false, "помыться is in no set"},
|
||||
{"сходил в ванную", "shower", "fn", false, "ванная is in no set"},
|
||||
{"искупался", "shower", "fn", false, "искупаться is in no set"},
|
||||
{"i am showering", "shower", "fn", false, "showering is not a member and the set is exact-matched"},
|
||||
|
||||
// Break.
|
||||
{"сделал передышку", "break", "fn", false, "передышка is in no set"},
|
||||
{"перекур", "break", "fn", false, "перекур is in no set"},
|
||||
{"полежал немного", "break", "fn", false, "полежать is in no set"},
|
||||
{"сделал паузу", "break", "fn", false, "пауза is in no set"},
|
||||
{"i took five", "break", "fn", false, "idiomatic, in no set"},
|
||||
{"resting now", "break", "fn", false, "resting is not a member and rest is matched by lemma only"},
|
||||
|
||||
// Sleep.
|
||||
{"вздремнул", "sleep", "fn", false, "вздремнуть is in no set"},
|
||||
{"прикорнул на диване", "sleep", "fn", false, "прикорнуть is in no set"},
|
||||
{"дрых до обеда", "sleep", "fn", false, "дрыхнуть is in no set — and обед makes this a MEAL for both parsers"},
|
||||
{"недоспал", "sleep", "fn", false, "недоспать is in no set"},
|
||||
{"лёг в двенадцать", "sleep", "fn", false, "лечь is in no set"},
|
||||
{"сон был короткий", "sleep", "fn", false, "сон deliberately absent"},
|
||||
{"i napped", "sleep", "fn", false, "nap is in no set"},
|
||||
{"took a nap", "sleep", "fn", false, "break matches nothing here either"},
|
||||
}
|
||||
|
||||
// legacyFactParse — DefaultFactParser exactly as it stood at 22edc3c's parent
|
||||
// (0445693), copied here so the corpus scores the trade rather than describing
|
||||
// it. Do not fix it. It is a frozen baseline, and the day it stops being worth
|
||||
// comparing against, delete it and the two-column table with it.
|
||||
func legacyFactParse(utterance string) (string, string, bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||
hasRoot := func(root string) bool { return strings.Contains(s, root) }
|
||||
containsWord := func(w string) bool {
|
||||
for _, tok := range strings.Fields(s) {
|
||||
if tok == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case (containsWord("water") && containsWord("drank")) ||
|
||||
(hasRoot("вод") && (hasRoot("пил") || hasRoot("пью") || hasRoot("пей"))):
|
||||
return "water", `"drank"`, true
|
||||
case containsWord("meal") || (containsWord("ate") && !containsWord("backup")) || containsWord("lunch") || containsWord("dinner") ||
|
||||
hasRoot("поел") || hasRoot("поесть") || hasRoot("куша") || hasRoot("обед") || hasRoot("ужин") || hasRoot("завтрак") || hasRoot("еда"):
|
||||
return "meal", `"ate"`, true
|
||||
case containsWord("shower") || hasRoot("душ"):
|
||||
return "shower", `"took"`, true
|
||||
case containsWord("break") || hasRoot("перерыв") || hasRoot("отдох"):
|
||||
return "break", `"took"`, true
|
||||
case containsWord("slept") || containsWord("sleep") ||
|
||||
hasRoot("спал") || hasRoot("выспал"):
|
||||
if v, ok := parseDurationValue(afterWord(s, "slept")); ok {
|
||||
return "sleep", strconv.Quote(v), true
|
||||
}
|
||||
return "sleep", `"slept"`, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// TestFactParserCorpus scores both parsers over the corpus and prints the
|
||||
// side-by-side. Run it with -v; the table is the output.
|
||||
func TestFactParserCorpus(t *testing.T) {
|
||||
type tally struct{ tpHit, tpMiss, misfire, misfireOK, fnHit, fnMiss int }
|
||||
var now, old tally
|
||||
|
||||
var rows []string
|
||||
rows = append(rows, "| utterance | class | want | old (substring) | new (closed class) |")
|
||||
rows = append(rows, "|---|---|---|---|---|")
|
||||
|
||||
score := func(key string, ok bool, c factCase, tl *tally) string {
|
||||
got := ""
|
||||
if ok {
|
||||
got = key
|
||||
}
|
||||
switch c.class {
|
||||
case "tp":
|
||||
if got == c.want {
|
||||
tl.tpHit++
|
||||
} else {
|
||||
tl.tpMiss++
|
||||
}
|
||||
case "misfire":
|
||||
if got == c.want {
|
||||
tl.misfireOK++
|
||||
} else {
|
||||
tl.misfire++
|
||||
}
|
||||
case "fn":
|
||||
if got == c.want {
|
||||
tl.fnHit++
|
||||
} else {
|
||||
tl.fnMiss++
|
||||
}
|
||||
}
|
||||
if got == "" {
|
||||
return "—"
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
for _, c := range factCorpus {
|
||||
nk, _, nok := DefaultFactParser{}.Parse(c.utterance)
|
||||
ok, _, ook := legacyFactParse(c.utterance)
|
||||
gotNew := score(nk, nok, c, &now)
|
||||
gotOld := score(ok, ook, c, &old)
|
||||
want := c.want
|
||||
if want == "" {
|
||||
want = "—"
|
||||
}
|
||||
mark := ""
|
||||
if gotOld != gotNew {
|
||||
mark = " **≠**"
|
||||
}
|
||||
rows = append(rows, "| `"+c.utterance+"` | "+c.class+" | "+want+" | "+gotOld+" | "+gotNew+mark+" |")
|
||||
}
|
||||
|
||||
line := func(name string, tl tally) string {
|
||||
return name +
|
||||
": true positives " + strconv.Itoa(tl.tpHit) + "/" + strconv.Itoa(tl.tpHit+tl.tpMiss) +
|
||||
", misfires rejected " + strconv.Itoa(tl.misfireOK) + "/" + strconv.Itoa(tl.misfireOK+tl.misfire) +
|
||||
", false-negative cases recovered " + strconv.Itoa(tl.fnHit) + "/" + strconv.Itoa(tl.fnHit+tl.fnMiss)
|
||||
}
|
||||
t.Log("\n" + strings.Join(rows, "\n") + "\n\n" + line("old (substring)", old) + "\n" + line("new (closed class)", now))
|
||||
|
||||
// The two regressions that are not judgement calls.
|
||||
for _, c := range factCorpus {
|
||||
k, _, ok := DefaultFactParser{}.Parse(c.utterance)
|
||||
got := ""
|
||||
if ok {
|
||||
got = k
|
||||
}
|
||||
if c.class == "fn" {
|
||||
continue
|
||||
}
|
||||
if c.broken {
|
||||
// Recorded as wrong on 06-08. If it starts passing, someone fixed
|
||||
// it and the flag is now a lie — say so rather than staying green.
|
||||
if got == c.want {
|
||||
t.Errorf("%q now returns %q as wanted — drop its broken flag", c.utterance, got)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("%s %q: got %q, want %q (%s)", c.class, c.utterance, got, c.want, c.note)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user